Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion src/collect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
35 changes: 34 additions & 1 deletion src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
96 changes: 96 additions & 0 deletions tests/test_docker_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
172 changes: 172 additions & 0 deletions tests/test_dry_run.py
Original file line number Diff line number Diff line change
@@ -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()