diff --git a/src/collect.py b/src/collect.py index faeeb13..1101012 100644 --- a/src/collect.py +++ b/src/collect.py @@ -64,11 +64,17 @@ def _resolve_memory_conflicts( help="Comma-separated list of tools to collect from (e.g., claude,cursor)", ) @click.option("--no-memory", is_flag=True, help="Skip collecting memory entries") +@click.option( + "--dry-run", + is_flag=True, + help="Show what would be collected without writing to cache. (#25)", +) @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") -def collect(tools, no_memory, yes): +def collect(tools, no_memory, dry_run, yes): """Extract from installed AI tools and save to local cache. No login or network required. + Use --dry-run to preview what would be collected without writing. """ # --- Phase 1: Scan --- header("Scanning") @@ -163,6 +169,35 @@ def collect(tools, no_memory, yes): store_secrets_batch("local", all_secrets) success(f"Stored {len(all_secrets)} secret(s) in OS keychain") + # --- Dry-run: preview without writing (#25) --- + if dry_run: + from cache import get_cache_dir + + cache_dir = get_cache_dir() + info("\n[dry-run] Would write to cache:") + info(f" {cache_dir / 'skills.json'} ({len(new_skills)} skills)") + info(f" {cache_dir / 'mcp.json'} ({len(new_mcp_servers)} MCP servers)") + info(f" {cache_dir / 'memory.json'} ({len(selected_memory)} memory entries)") + + if new_skills: + info("\n Skills:") + for s in new_skills: + info(f" • {s.get('name', '?')} ({s.get('source_tool', '')})") + + if new_mcp_servers: + info("\n MCP Servers:") + for sv in new_mcp_servers: + info(f" • {sv.get('name', '?')} ({sv.get('source_tool', '')})") + + if selected_memory: + info("\n Memory files:") + for e in selected_memory: + label = e.get("label") or e.get("source_file") or e.get("content", "")[:40] + info(f" • {e.get('source_tool', '?')}/{label}") + + info("\n[dry-run] No files written.") + return + # Merge into existing cache (upsert, never delete) merged_skills = merge_skills(load_skills(), new_skills) merged_mcp = merge_mcp_servers(load_mcp_servers(), new_mcp_servers) diff --git a/src/main.py b/src/main.py index 44ead0a..f8df48f 100644 --- a/src/main.py +++ b/src/main.py @@ -121,7 +121,40 @@ def sync(tools, apply_all, no_memory, override_mcp, dry_run, yes): info(f"Skills: {len(collected_skills)} collected + {installed_count} installed") if dry_run: - info("[dry-run] No files written.") + # Show the file paths that would be written for each target tool (#24) + from appliers import get_applier + + info("\n[dry-run] Files that would be written:") + for tool_name in tool_list: + try: + applier = get_applier(tool_name) + info(f"\n [{tool_name}]") + # Skills + if collected_skills or installed_count: + if hasattr(applier, "SKILL_DIR") and applier.SKILL_DIR: + info(f" Skills dir: {applier.SKILL_DIR}") + # MCP config + for attr in ("_mcp_config", "_mcp_config_path"): + fn = getattr(type(applier), attr, None) or getattr(applier, attr, None) + if callable(fn): + try: + info(f" MCP config: {fn()}") + except Exception: + pass + # Memory target + mem_target = getattr(applier, "MEMORY_TARGET_FILE", None) + if callable(mem_target): + mem_target = mem_target # property — access below + try: + mf = applier.MEMORY_TARGET_FILE + if mf: + info(f" Memory: {mf}") + except Exception: + pass + except Exception as e: + info(f" [{tool_name}] (could not inspect: {e})") + + info("\n[dry-run] No files written.") return # Confirm diff --git a/tests/test_docker_integration.py b/tests/test_docker_integration.py index eb27858..cce9d9f 100644 --- a/tests/test_docker_integration.py +++ b/tests/test_docker_integration.py @@ -1449,3 +1449,99 @@ def test_status_after_round_trip(self, runner, cli, export_path): r = runner.invoke(cli, ["status"]) assert r.exit_code == 0 + + +# --------------------------------------------------------------------------- +# Phase 12: --dry-run for collect (#25) and sync (#24) +# --------------------------------------------------------------------------- + + +class TestCollectDryRun: + """End-to-end: collect --dry-run previews without writing cache.""" + + def test_collect_dry_run_no_files_written(self, runner, cli, tmp_path, monkeypatch): + """collect --dry-run must not create any cache files.""" + monkeypatch.setenv("HOME", str(tmp_path)) + (tmp_path / ".cursor").mkdir() + (tmp_path / ".cursor" / "mcp.json").write_text("{}") + + result = runner.invoke(cli, ["collect", "--dry-run", "--yes"]) + assert result.exit_code == 0, result.output + + cache_dir = tmp_path / ".apc" / "cache" + for fname in ("skills.json", "mcp.json", "memory.json"): + assert not (cache_dir / fname).exists(), f"{fname} written despite --dry-run" + + def test_collect_dry_run_prints_preview(self, runner, cli, tmp_path, monkeypatch): + """collect --dry-run output shows 'Would write to cache' preview.""" + monkeypatch.setenv("HOME", str(tmp_path)) + (tmp_path / ".cursor").mkdir() + (tmp_path / ".cursor" / "mcp.json").write_text( + json.dumps({"mcpServers": {"test": {"command": "npx", "args": []}}}) + ) + + result = runner.invoke(cli, ["collect", "--dry-run", "--yes"]) + assert result.exit_code == 0, result.output + # Output says "Would write to cache:" or "No files written." + out = result.output.lower() + assert "write to cache" in out or "no files written" in out + + def test_collect_without_dry_run_writes_cache(self, runner, cli, tmp_path, monkeypatch): + """Control: without --dry-run the cache IS written.""" + monkeypatch.setenv("HOME", str(tmp_path)) + (tmp_path / ".cursor").mkdir() + (tmp_path / ".cursor" / "mcp.json").write_text( + json.dumps({"mcpServers": {"test-mcp": {"command": "npx", "args": []}}}) + ) + + result = runner.invoke(cli, ["collect", "--yes"]) + assert result.exit_code == 0, result.output + + # At least one cache file must have been written + cache_dir = tmp_path / ".apc" / "cache" + cache_files = ("skills.json", "mcp.json", "memory.json") + written = [f for f in cache_files if (cache_dir / f).exists()] + assert written, f"No cache files written without --dry-run. Output:\n{result.output}" + + +class TestSyncDryRunIntegration: + """End-to-end: sync --dry-run previews without modifying tool files.""" + + def test_sync_dry_run_no_files_written(self, runner, cli, tmp_path, monkeypatch): + """sync --dry-run must not modify any tool config files.""" + monkeypatch.setenv("HOME", str(tmp_path)) + (tmp_path / ".cursor").mkdir() + mcp_path = tmp_path / ".cursor" / "mcp.json" + mcp_path.write_text(json.dumps({"mcpServers": {"test": {"command": "npx", "args": []}}})) + + runner.invoke(cli, ["collect", "--yes"]) + + mtime_before = mcp_path.stat().st_mtime + result = runner.invoke(cli, ["sync", "--tools", "cursor", "--dry-run"]) + assert result.exit_code == 0, result.output + assert mcp_path.stat().st_mtime == mtime_before, "sync --dry-run modified mcp.json" + + def test_sync_dry_run_output_mentions_tool(self, runner, cli, tmp_path, monkeypatch): + """sync --dry-run output references the target tool.""" + monkeypatch.setenv("HOME", str(tmp_path)) + (tmp_path / ".cursor").mkdir() + (tmp_path / ".cursor" / "mcp.json").write_text( + json.dumps({"mcpServers": {"test": {"command": "npx", "args": []}}}) + ) + + runner.invoke(cli, ["collect", "--yes"]) + result = runner.invoke(cli, ["sync", "--tools", "cursor", "--dry-run"]) + assert result.exit_code == 0, result.output + assert "cursor" in result.output or "dry-run" in result.output.lower() + + def test_sync_dry_run_shows_no_files_written(self, runner, cli, tmp_path, monkeypatch): + """sync --dry-run explicitly states no files written.""" + monkeypatch.setenv("HOME", str(tmp_path)) + (tmp_path / ".cursor").mkdir() + (tmp_path / ".cursor" / "mcp.json").write_text( + json.dumps({"mcpServers": {"test": {"command": "npx", "args": []}}}) + ) + + runner.invoke(cli, ["collect", "--yes"]) + result = runner.invoke(cli, ["sync", "--tools", "cursor", "--dry-run"]) + assert "No files written" in result.output or "dry-run" in result.output.lower() diff --git a/tests/test_dry_run.py b/tests/test_dry_run.py new file mode 100644 index 0000000..eedbbc2 --- /dev/null +++ b/tests/test_dry_run.py @@ -0,0 +1,172 @@ +"""Tests for --dry-run on apc collect (#25) and apc sync (#24). + +apc collect --dry-run: previews what would be collected without writing. +apc sync --dry-run: previews file paths per tool without writing. +""" + +import sys +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from click.testing import CliRunner + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _cli(): + from main import cli + + return cli + + +def _runner(): + return CliRunner() + + +def _mock_extractor(skills=None, mcp=None, memory=None): + """Return a MagicMock extractor with canned data.""" + ext = MagicMock() + ext.extract_skills.return_value = skills or [] + ext.extract_mcp_servers.return_value = mcp or [] + ext.extract_memory.return_value = memory or [] + return ext + + +# --------------------------------------------------------------------------- +# apc collect --dry-run (#25) +# --------------------------------------------------------------------------- + + +class TestCollectDryRun(unittest.TestCase): + """collect --dry-run must preview without touching the cache.""" + + def _invoke(self, skills=None, mcp=None, memory=None, extra_args=None): + import tempfile + + with tempfile.TemporaryDirectory() as td: + td = Path(td) + cache_dir = td / ".apc" / "cache" + extractor = _mock_extractor( + skills=skills or [{"name": "pdf", "source_tool": "claude-code", "body": "# PDF"}], + mcp=mcp + or [{"name": "test-mcp", "source_tool": "cursor", "command": "npx", "args": []}], + memory=memory or [], + ) + args = ["collect", "--dry-run", "--yes"] + (extra_args or []) + with ( + patch("collect.detect_installed_tools", return_value=["claude-code"]), + patch("collect.get_extractor", return_value=extractor), + patch("cache.get_cache_dir", return_value=cache_dir), + ): + result = _runner().invoke(_cli(), args) + return result, cache_dir + + def test_dry_run_flag_accepted(self): + result, _ = self._invoke() + assert result.exit_code == 0, result.output + + def test_dry_run_shows_cache_paths(self): + result, _ = self._invoke() + # Paths may wrap across lines in rich output; check for filename substrings + flat = result.output.replace("\n", " ") + assert "skills.j" in flat # skills.json (may wrap) + assert "mcp.json" in flat + assert "memory.j" in flat # memory.json (may wrap) + + def test_dry_run_shows_skill_count(self): + result, _ = self._invoke( + skills=[ + {"name": "pdf", "source_tool": "claude-code", "body": "# PDF"}, + {"name": "sk", "source_tool": "claude-code", "body": "# SK"}, + ] + ) + assert "2 skills" in result.output + + def test_dry_run_shows_mcp_count(self): + result, _ = self._invoke( + mcp=[ + {"name": "mcp-a", "source_tool": "cursor", "command": "npx", "args": []}, + {"name": "mcp-b", "source_tool": "cursor", "command": "npx", "args": []}, + {"name": "mcp-c", "source_tool": "cursor", "command": "npx", "args": []}, + ] + ) + assert "3 MCP" in result.output + + def test_dry_run_lists_skill_names(self): + result, _ = self._invoke( + skills=[{"name": "pdf", "source_tool": "claude-code", "body": "# PDF"}] + ) + assert "pdf" in result.output + + def test_dry_run_does_not_write_cache(self): + """Cache files must NOT be created when --dry-run is used.""" + result, cache_dir = self._invoke() + assert result.exit_code == 0, result.output + assert not (cache_dir / "skills.json").exists(), "skills.json written in dry-run" + assert not (cache_dir / "mcp.json").exists(), "mcp.json written in dry-run" + assert not (cache_dir / "memory.json").exists(), "memory.json written in dry-run" + + def test_dry_run_no_files_written_message(self): + result, _ = self._invoke(skills=[], mcp=[], memory=[]) + assert "No files written" in result.output or "dry-run" in result.output.lower() + + def test_dry_run_memory_entries_listed(self): + mem = [{"source_tool": "claude-code", "source_file": "CLAUDE.md", "content": "Some rule"}] + result, _ = self._invoke(memory=mem) + assert "claude-code" in result.output or "CLAUDE.md" in result.output + + +# --------------------------------------------------------------------------- +# apc sync --dry-run (#24) +# --------------------------------------------------------------------------- + + +class TestSyncDryRun(unittest.TestCase): + """sync --dry-run must preview file paths per tool without writing.""" + + def _invoke_sync_dry(self, tools="cursor"): + mock_bundle = { + "skills": [{"name": "pdf", "source_tool": "claude-code", "body": "# PDF"}], + "mcp_servers": [{"name": "test-mcp", "command": "npx", "args": []}], + "memory": [], + } + with ( + patch("main.load_local_bundle", return_value=mock_bundle), + patch("main.count_installed_skills", return_value=1), + patch("main.resolve_target_tools", return_value=[tools]), + ): + return _runner().invoke(_cli(), ["sync", "--dry-run", "--yes"]) + + def test_dry_run_flag_accepted(self): + result = self._invoke_sync_dry() + assert result.exit_code == 0, result.output + + def test_dry_run_shows_no_files_written(self): + result = self._invoke_sync_dry() + assert "No files written" in result.output or "dry-run" in result.output.lower() + + def test_dry_run_shows_tool_name(self): + result = self._invoke_sync_dry(tools="cursor") + assert "cursor" in result.output + + def test_dry_run_does_not_call_sync_all(self): + """sync_all must not be called in dry-run mode.""" + mock_bundle = { + "skills": [{"name": "pdf", "source_tool": "claude-code", "body": "# PDF"}], + "mcp_servers": [], + "memory": [], + } + with ( + patch("main.load_local_bundle", return_value=mock_bundle), + patch("main.count_installed_skills", return_value=1), + patch("main.resolve_target_tools", return_value=["cursor"]), + patch("main.sync_all") as mock_sync, + ): + _runner().invoke(_cli(), ["sync", "--dry-run", "--yes"]) + + mock_sync.assert_not_called()