From 8f62089d0da3355b6c042b55c45c7f06f51763fa Mon Sep 17 00:00:00 2001 From: sidPhoenix17 Date: Fri, 6 Mar 2026 14:04:55 -0800 Subject: [PATCH] Add auto-sync feature for periodic background syncing Enables opt-in automatic syncing via OS-level schedulers (launchd on macOS, cron on Linux). New CLI subcommands: auto-sync enable/disable/status. Config stored in ~/.config/droidctx/auto-sync.yaml, logs in auto-sync.log. Co-Authored-By: Claude Opus 4.6 --- README.md | 22 +++ droidctx/auto_sync.py | 81 ++++++++++ droidctx/main.py | 101 ++++++++++++ droidctx/scheduler.py | 161 +++++++++++++++++++ droidctx/sync_engine.py | 12 +- tests/test_auto_sync.py | 336 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 712 insertions(+), 1 deletion(-) create mode 100644 droidctx/auto_sync.py create mode 100644 droidctx/scheduler.py create mode 100644 tests/test_auto_sync.py diff --git a/README.md b/README.md index 7a45807..ea0bb62 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,28 @@ droidctx list-connectors droidctx list-connectors --type GRAFANA ``` +### `droidctx auto-sync` + +Keep your context files fresh automatically. Uses launchd on macOS and cron on Linux. + +```bash +# Enable auto-sync (runs every 30 minutes by default) +droidctx auto-sync enable --keyfile ./droidctx-context/credentials.yaml + +# Custom interval (in minutes) +droidctx auto-sync enable --keyfile ./droidctx-context/credentials.yaml --interval 60 + +# Check status +droidctx auto-sync status + +# Disable +droidctx auto-sync disable +``` + +Logs are written to `~/.config/droidctx/auto-sync.log`. + +> **macOS note:** You may need to allow droidctx in System Settings → Privacy & Security the first time. + ## Credentials Format Create a YAML file with your connector credentials. Run `droidctx init` to generate a template with all supported types, or `droidctx detect` to auto-populate from your CLI tools. diff --git a/droidctx/auto_sync.py b/droidctx/auto_sync.py new file mode 100644 index 0000000..7bd68d2 --- /dev/null +++ b/droidctx/auto_sync.py @@ -0,0 +1,81 @@ +"""Auto-sync configuration and orchestration.""" + +import shutil +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +import yaml + +CONFIG_DIR = Path.home() / ".config" / "droidctx" +CONFIG_FILE = CONFIG_DIR / "auto-sync.yaml" +LOG_FILE = CONFIG_DIR / "auto-sync.log" + + +def load_config() -> dict[str, Any]: + """Load auto-sync config from disk. Returns empty dict if missing.""" + if not CONFIG_FILE.exists(): + return {} + return yaml.safe_load(CONFIG_FILE.read_text()) or {} + + +def save_config(config: dict[str, Any]) -> None: + """Write auto-sync config to disk, creating directory if needed.""" + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + CONFIG_FILE.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False)) + + +def resolve_paths(keyfile: Path, output_dir: Optional[Path]) -> tuple[Path, Path]: + """Return (keyfile, output_dir) as absolute paths. + + If output_dir is None, defaults to the keyfile's parent directory. + """ + keyfile = keyfile.resolve() + if output_dir is None: + output_dir = keyfile.parent.resolve() + else: + output_dir = output_dir.resolve() + return keyfile, output_dir + + +def find_droidctx_binary() -> Optional[str]: + """Locate the droidctx executable on $PATH.""" + return shutil.which("droidctx") + + +def get_last_run_time() -> Optional[str]: + """Parse the log file and return the timestamp of the last run, or None.""" + if not LOG_FILE.exists(): + return None + try: + text = LOG_FILE.read_text().strip() + if not text: + return None + # Return the last non-empty line (most recent log entry) + for line in reversed(text.splitlines()): + line = line.strip() + if line: + return line + return None + except OSError: + return None + + +def build_config( + *, + keyfile: Path, + output_dir: Path, + interval_minutes: int, + droidctx_bin: str, +) -> dict[str, Any]: + """Build a config dict ready to be saved.""" + return { + "enabled": True, + "interval_minutes": interval_minutes, + "keyfile": str(keyfile), + "output_dir": str(output_dir), + "droidctx_bin": droidctx_bin, + "platform": sys.platform, + "created_at": datetime.now(timezone.utc).isoformat(), + } diff --git a/droidctx/main.py b/droidctx/main.py index 8d77d20..b99a2af 100644 --- a/droidctx/main.py +++ b/droidctx/main.py @@ -16,6 +16,12 @@ add_completion=False, no_args_is_help=True, ) +auto_sync_app = typer.Typer( + name="auto-sync", + help="Manage automatic periodic syncing.", + no_args_is_help=True, +) +app.add_typer(auto_sync_app, name="auto-sync") console = Console() @@ -286,6 +292,101 @@ def list_connectors( console.print(f"\n[dim]{len(CONNECTOR_CREDENTIALS)} connectors supported. Use --type for details.[/]\n") +@auto_sync_app.command() +def enable( + keyfile: Path = typer.Option(..., "--keyfile", "-k", help="Path to credentials YAML file"), + path: Optional[Path] = typer.Option(None, "--path", "-p", help="Output directory (default: same as keyfile dir)"), + interval: int = typer.Option(30, "--interval", "-i", help="Sync interval in minutes"), +): + """Enable automatic periodic syncing.""" + from droidctx.auto_sync import resolve_paths, find_droidctx_binary, save_config, build_config + from droidctx.scheduler import get_scheduler + + keyfile_abs, output_dir = resolve_paths(keyfile, path) + + if not keyfile_abs.exists(): + console.print(f"[red]Keyfile not found: {keyfile_abs}[/]") + raise typer.Exit(1) + + droidctx_bin = find_droidctx_binary() + if not droidctx_bin: + console.print("[red]Could not find 'droidctx' on PATH. Is it installed?[/]") + raise typer.Exit(1) + + config = build_config( + keyfile=keyfile_abs, + output_dir=output_dir, + interval_minutes=interval, + droidctx_bin=droidctx_bin, + ) + + scheduler = get_scheduler() + # Re-enable: uninstall old job first + if scheduler.is_active(): + scheduler.uninstall() + scheduler.install(config) + save_config(config) + + console.print(f"[bold green]Auto-sync enabled[/] (every {interval} min)") + console.print(f" keyfile: {keyfile_abs}") + console.print(f" output_dir: {output_dir}") + console.print(f" binary: {droidctx_bin}") + + +@auto_sync_app.command() +def disable(): + """Disable automatic periodic syncing.""" + from droidctx.auto_sync import load_config, save_config + from droidctx.scheduler import get_scheduler + + config = load_config() + scheduler = get_scheduler() + + if not config.get("enabled") and not scheduler.is_active(): + console.print("[yellow]Auto-sync is not currently enabled.[/]") + raise typer.Exit(0) + + if scheduler.is_active(): + scheduler.uninstall() + + config["enabled"] = False + save_config(config) + console.print("[bold green]Auto-sync disabled.[/]") + + +@auto_sync_app.command() +def status(): + """Show current auto-sync status.""" + from droidctx.auto_sync import load_config, get_last_run_time + from droidctx.scheduler import get_scheduler + + config = load_config() + if not config: + console.print("[yellow]Auto-sync has not been configured.[/]") + raise typer.Exit(0) + + scheduler = get_scheduler() + active = scheduler.is_active() + + table = Table(title="Auto-Sync Status") + table.add_column("Setting", style="bold") + table.add_column("Value") + + table.add_row("Enabled", "[green]yes[/]" if config.get("enabled") and active else "[red]no[/]") + table.add_row("Interval", f"{config.get('interval_minutes', '?')} minutes") + table.add_row("Keyfile", str(config.get("keyfile", "?"))) + table.add_row("Output dir", str(config.get("output_dir", "?"))) + table.add_row("Binary", str(config.get("droidctx_bin", "?"))) + table.add_row("Platform", str(config.get("platform", "?"))) + + last_run = get_last_run_time() + table.add_row("Last log entry", last_run or "[dim]none[/]") + + console.print() + console.print(table) + console.print() + + def _get_commented_reference(exclude: set[str] | None = None) -> str: """Return all connector templates as commented YAML for reference. diff --git a/droidctx/scheduler.py b/droidctx/scheduler.py new file mode 100644 index 0000000..b36cfe6 --- /dev/null +++ b/droidctx/scheduler.py @@ -0,0 +1,161 @@ +"""Platform-specific scheduler backends for auto-sync.""" + +import abc +import subprocess +import sys +import textwrap +from pathlib import Path +from typing import Any + + +class Scheduler(abc.ABC): + """Abstract base for OS-level periodic job schedulers.""" + + @abc.abstractmethod + def install(self, config: dict[str, Any]) -> None: + """Register the periodic sync job.""" + + @abc.abstractmethod + def uninstall(self) -> None: + """Remove the periodic sync job.""" + + @abc.abstractmethod + def is_active(self) -> bool: + """Return True if the job is currently registered.""" + + +# --------------------------------------------------------------------------- +# macOS launchd +# --------------------------------------------------------------------------- + +PLIST_PATH = Path.home() / "Library" / "LaunchAgents" / "io.drdroid.droidctx.auto-sync.plist" + +_PLIST_TEMPLATE = textwrap.dedent("""\ + + + + + Label + io.drdroid.droidctx.auto-sync + ProgramArguments + + {droidctx_bin} + sync + --keyfile + {keyfile} + --path + {output_dir} + + StartInterval + {interval_seconds} + StandardOutPath + {log_file} + StandardErrorPath + {log_file} + + +""") + + +class LaunchdScheduler(Scheduler): + """macOS launchd backend.""" + + def install(self, config: dict[str, Any]) -> None: + from droidctx.auto_sync import LOG_FILE + + plist_content = _PLIST_TEMPLATE.format( + droidctx_bin=config["droidctx_bin"], + keyfile=config["keyfile"], + output_dir=config["output_dir"], + interval_seconds=config["interval_minutes"] * 60, + log_file=str(LOG_FILE), + ) + PLIST_PATH.parent.mkdir(parents=True, exist_ok=True) + PLIST_PATH.write_text(plist_content) + subprocess.run( + ["launchctl", "load", str(PLIST_PATH)], + check=True, + capture_output=True, + ) + + def uninstall(self) -> None: + if PLIST_PATH.exists(): + subprocess.run( + ["launchctl", "unload", str(PLIST_PATH)], + check=True, + capture_output=True, + ) + PLIST_PATH.unlink() + + def is_active(self) -> bool: + return PLIST_PATH.exists() + + +# --------------------------------------------------------------------------- +# Linux cron +# --------------------------------------------------------------------------- + +CRON_MARKER = "# droidctx-auto-sync" + + +class CronScheduler(Scheduler): + """Linux crontab backend.""" + + def _read_crontab(self) -> str: + result = subprocess.run( + ["crontab", "-l"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + return "" + return result.stdout + + def _write_crontab(self, content: str) -> None: + subprocess.run( + ["crontab", "-"], + input=content, + check=True, + text=True, + capture_output=True, + ) + + def install(self, config: dict[str, Any]) -> None: + from droidctx.auto_sync import LOG_FILE + + # Remove old entry first + existing = self._read_crontab() + lines = [l for l in existing.splitlines() if CRON_MARKER not in l] + + cmd = ( + f"{config['droidctx_bin']} sync " + f"--keyfile {config['keyfile']} " + f"--path {config['output_dir']}" + ) + cron_line = f"*/{config['interval_minutes']} * * * * {cmd} >> {LOG_FILE} 2>&1 {CRON_MARKER}" + lines.append(cron_line) + + self._write_crontab("\n".join(lines) + "\n") + + def uninstall(self) -> None: + existing = self._read_crontab() + lines = [l for l in existing.splitlines() if CRON_MARKER not in l] + self._write_crontab("\n".join(lines) + "\n" if lines else "") + + def is_active(self) -> bool: + return CRON_MARKER in self._read_crontab() + + +# --------------------------------------------------------------------------- +# Factory +# --------------------------------------------------------------------------- + +def get_scheduler() -> Scheduler: + """Return the appropriate scheduler for the current platform.""" + if sys.platform == "darwin": + return LaunchdScheduler() + elif sys.platform.startswith("linux"): + return CronScheduler() + else: + raise NotImplementedError(f"Auto-sync is not supported on {sys.platform}") diff --git a/droidctx/sync_engine.py b/droidctx/sync_engine.py index c248074..c280560 100644 --- a/droidctx/sync_engine.py +++ b/droidctx/sync_engine.py @@ -32,6 +32,9 @@ def sync( """ console = console or Console() + from datetime import datetime, timezone + console.print(f"[dim]--- sync started at {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')} ---[/]") + # Suppress noisy toolkit logs unless verbose if not verbose: for noisy in [ @@ -172,6 +175,13 @@ def _run(n=name, ct=conn_type, cfg=config): console.print("[dim] Refer to this when investigating issues, writing queries, or understanding system topology.[/]") console.print("\n[bold]Optional (if you want agent to refresh the context):[/]\n") console.print("[dim] Before using context files, check the synced_at timestamp in the YAML frontmatter.[/]") - console.print("[dim] If the data is older than 6 hours, run `droidctx sync` to refresh the context.[/]\n") + console.print("[dim] If the data is older than 6 hours, run `droidctx sync` to refresh the context.[/]") + + # Hint about auto-sync if not already enabled + from droidctx.auto_sync import load_config as _load_auto_sync_config + auto_cfg = _load_auto_sync_config() + if not auto_cfg.get("enabled"): + console.print("\n[bold]Set up auto-sync to keep context fresh automatically:[/]\n") + console.print(f"[dim] droidctx auto-sync enable --keyfile {keyfile} --interval [/]\n") return results diff --git a/tests/test_auto_sync.py b/tests/test_auto_sync.py new file mode 100644 index 0000000..d12aa6a --- /dev/null +++ b/tests/test_auto_sync.py @@ -0,0 +1,336 @@ +"""Tests for auto-sync feature: config, schedulers, and CLI commands.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from droidctx.main import app + +runner = CliRunner() + + +# --------------------------------------------------------------------------- +# auto_sync module tests +# --------------------------------------------------------------------------- + +class TestAutoSyncConfig: + def test_save_and_load_roundtrip(self, tmp_path, monkeypatch): + from droidctx import auto_sync + + cfg_dir = tmp_path / "cfg" + cfg_file = cfg_dir / "auto-sync.yaml" + monkeypatch.setattr(auto_sync, "CONFIG_DIR", cfg_dir) + monkeypatch.setattr(auto_sync, "CONFIG_FILE", cfg_file) + + config = { + "enabled": True, + "interval_minutes": 15, + "keyfile": "/tmp/k.yaml", + "output_dir": "/tmp/out", + "droidctx_bin": "/usr/local/bin/droidctx", + "platform": "darwin", + } + auto_sync.save_config(config) + loaded = auto_sync.load_config() + assert loaded == config + + def test_load_missing_returns_empty(self, tmp_path, monkeypatch): + from droidctx import auto_sync + + monkeypatch.setattr(auto_sync, "CONFIG_FILE", tmp_path / "nope.yaml") + assert auto_sync.load_config() == {} + + def test_resolve_paths_defaults_output_to_keyfile_parent(self, tmp_path): + from droidctx.auto_sync import resolve_paths + + kf = tmp_path / "credentials.yaml" + kf.touch() + keyfile, output_dir = resolve_paths(kf, None) + assert output_dir == kf.parent.resolve() + + def test_resolve_paths_explicit_output(self, tmp_path): + from droidctx.auto_sync import resolve_paths + + kf = tmp_path / "credentials.yaml" + out = tmp_path / "custom" + keyfile, output_dir = resolve_paths(kf, out) + assert output_dir == out.resolve() + + def test_find_droidctx_binary(self): + from droidctx.auto_sync import find_droidctx_binary + + with patch("shutil.which", return_value="/usr/local/bin/droidctx"): + assert find_droidctx_binary() == "/usr/local/bin/droidctx" + + def test_find_droidctx_binary_not_found(self): + from droidctx.auto_sync import find_droidctx_binary + + with patch("shutil.which", return_value=None): + assert find_droidctx_binary() is None + + def test_get_last_run_time_no_log(self, tmp_path, monkeypatch): + from droidctx import auto_sync + + monkeypatch.setattr(auto_sync, "LOG_FILE", tmp_path / "nope.log") + assert auto_sync.get_last_run_time() is None + + def test_get_last_run_time_parses_last_line(self, tmp_path, monkeypatch): + from droidctx import auto_sync + + log = tmp_path / "auto-sync.log" + log.write_text("first line\nsecond line\nlast line\n") + monkeypatch.setattr(auto_sync, "LOG_FILE", log) + assert auto_sync.get_last_run_time() == "last line" + + def test_get_last_run_time_empty_log(self, tmp_path, monkeypatch): + from droidctx import auto_sync + + log = tmp_path / "auto-sync.log" + log.write_text("") + monkeypatch.setattr(auto_sync, "LOG_FILE", log) + assert auto_sync.get_last_run_time() is None + + +# --------------------------------------------------------------------------- +# Scheduler tests (all subprocess calls mocked) +# --------------------------------------------------------------------------- + +class TestLaunchdScheduler: + def test_install_writes_plist_and_loads(self, tmp_path, monkeypatch): + from droidctx import scheduler, auto_sync + + plist = tmp_path / "io.drdroid.droidctx.auto-sync.plist" + monkeypatch.setattr(scheduler, "PLIST_PATH", plist) + monkeypatch.setattr(auto_sync, "LOG_FILE", tmp_path / "auto-sync.log") + + config = { + "droidctx_bin": "/usr/local/bin/droidctx", + "keyfile": "/tmp/k.yaml", + "output_dir": "/tmp/out", + "interval_minutes": 10, + } + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + s = scheduler.LaunchdScheduler() + s.install(config) + + assert plist.exists() + content = plist.read_text() + assert "600" in content + assert "/usr/local/bin/droidctx" in content + mock_run.assert_called_once() + assert "launchctl" in mock_run.call_args[0][0] + + def test_uninstall_removes_plist(self, tmp_path, monkeypatch): + from droidctx import scheduler + + plist = tmp_path / "io.drdroid.droidctx.auto-sync.plist" + plist.write_text("") + monkeypatch.setattr(scheduler, "PLIST_PATH", plist) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + s = scheduler.LaunchdScheduler() + s.uninstall() + + assert not plist.exists() + mock_run.assert_called_once() + + def test_is_active(self, tmp_path, monkeypatch): + from droidctx import scheduler + + plist = tmp_path / "io.drdroid.droidctx.auto-sync.plist" + monkeypatch.setattr(scheduler, "PLIST_PATH", plist) + + s = scheduler.LaunchdScheduler() + assert not s.is_active() + + plist.write_text("") + assert s.is_active() + + +class TestCronScheduler: + def test_install_adds_cron_entry(self, monkeypatch): + from droidctx import scheduler, auto_sync + + monkeypatch.setattr(auto_sync, "LOG_FILE", Path("/tmp/auto-sync.log")) + + config = { + "droidctx_bin": "/usr/local/bin/droidctx", + "keyfile": "/tmp/k.yaml", + "output_dir": "/tmp/out", + "interval_minutes": 15, + } + + with patch("subprocess.run") as mock_run: + # First call: crontab -l returns empty + # Second call: crontab - writes new content + mock_run.side_effect = [ + MagicMock(returncode=1, stdout="", stderr=""), # no existing crontab + MagicMock(returncode=0), # write + ] + s = scheduler.CronScheduler() + s.install(config) + + write_call = mock_run.call_args_list[1] + written = write_call.kwargs.get("input", "") + assert "droidctx-auto-sync" in written + assert "*/15" in written + + def test_uninstall_removes_marker(self): + from droidctx import scheduler + + existing = "0 * * * * something\n*/30 * * * * droidctx sync >> log 2>&1 # droidctx-auto-sync\n" + + with patch("subprocess.run") as mock_run: + mock_run.side_effect = [ + MagicMock(returncode=0, stdout=existing), # read + MagicMock(returncode=0), # write + ] + s = scheduler.CronScheduler() + s.uninstall() + + write_call = mock_run.call_args_list[1] + written = write_call.kwargs.get("input", "") + assert "droidctx-auto-sync" not in written + assert "something" in written + + def test_is_active(self): + from droidctx import scheduler + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + returncode=0, + stdout="*/30 * * * * cmd # droidctx-auto-sync\n", + ) + s = scheduler.CronScheduler() + assert s.is_active() + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="") + s = scheduler.CronScheduler() + assert not s.is_active() + + +class TestGetScheduler: + def test_darwin(self): + from droidctx import scheduler + + with patch.object(scheduler.sys, "platform", "darwin"): + s = scheduler.get_scheduler() + assert isinstance(s, scheduler.LaunchdScheduler) + + def test_linux(self): + from droidctx import scheduler + + with patch.object(scheduler.sys, "platform", "linux"): + s = scheduler.get_scheduler() + assert isinstance(s, scheduler.CronScheduler) + + def test_windows_raises(self): + from droidctx import scheduler + + with patch.object(scheduler.sys, "platform", "win32"): + with pytest.raises(NotImplementedError): + scheduler.get_scheduler() + + +# --------------------------------------------------------------------------- +# CLI tests +# --------------------------------------------------------------------------- + +class TestAutoSyncCLI: + def test_enable_missing_keyfile(self, tmp_path): + result = runner.invoke( + app, + ["auto-sync", "enable", "--keyfile", str(tmp_path / "nope.yaml")], + ) + assert result.exit_code == 1 + assert "not found" in result.output + + @patch("droidctx.auto_sync.find_droidctx_binary", return_value=None) + def test_enable_binary_not_found(self, _mock_bin, tmp_path): + kf = tmp_path / "credentials.yaml" + kf.write_text("test: true") + result = runner.invoke( + app, + ["auto-sync", "enable", "--keyfile", str(kf)], + ) + assert result.exit_code == 1 + assert "Could not find" in result.output + + @patch("droidctx.scheduler.get_scheduler") + @patch("droidctx.auto_sync.find_droidctx_binary", return_value="/usr/local/bin/droidctx") + @patch("droidctx.auto_sync.save_config") + def test_enable_success(self, mock_save, _mock_bin, mock_get_sched, tmp_path): + kf = tmp_path / "credentials.yaml" + kf.write_text("test: true") + + mock_scheduler = MagicMock() + mock_scheduler.is_active.return_value = False + mock_get_sched.return_value = mock_scheduler + + result = runner.invoke( + app, + ["auto-sync", "enable", "--keyfile", str(kf), "--interval", "10"], + ) + assert result.exit_code == 0 + assert "enabled" in result.output.lower() + mock_scheduler.install.assert_called_once() + mock_save.assert_called_once() + + @patch("droidctx.scheduler.get_scheduler") + @patch("droidctx.auto_sync.load_config", return_value={"enabled": False}) + @patch("droidctx.auto_sync.save_config") + def test_disable_when_not_enabled(self, _mock_save, _mock_load, mock_get_sched): + mock_scheduler = MagicMock() + mock_scheduler.is_active.return_value = False + mock_get_sched.return_value = mock_scheduler + + result = runner.invoke(app, ["auto-sync", "disable"]) + assert result.exit_code == 0 + assert "not currently enabled" in result.output.lower() + + @patch("droidctx.scheduler.get_scheduler") + @patch("droidctx.auto_sync.load_config", return_value={"enabled": True}) + @patch("droidctx.auto_sync.save_config") + def test_disable_success(self, mock_save, _mock_load, mock_get_sched): + mock_scheduler = MagicMock() + mock_scheduler.is_active.return_value = True + mock_get_sched.return_value = mock_scheduler + + result = runner.invoke(app, ["auto-sync", "disable"]) + assert result.exit_code == 0 + assert "disabled" in result.output.lower() + mock_scheduler.uninstall.assert_called_once() + + @patch("droidctx.scheduler.get_scheduler") + @patch("droidctx.auto_sync.load_config", return_value={}) + def test_status_not_configured(self, _mock_load, _mock_sched): + result = runner.invoke(app, ["auto-sync", "status"]) + assert result.exit_code == 0 + assert "not been configured" in result.output.lower() + + @patch("droidctx.auto_sync.get_last_run_time", return_value="2026-03-06 14:00:00") + @patch("droidctx.scheduler.get_scheduler") + @patch("droidctx.auto_sync.load_config") + def test_status_shows_info(self, mock_load, mock_get_sched, _mock_last): + mock_load.return_value = { + "enabled": True, + "interval_minutes": 30, + "keyfile": "/tmp/k.yaml", + "output_dir": "/tmp/out", + "droidctx_bin": "/usr/local/bin/droidctx", + "platform": "darwin", + } + mock_scheduler = MagicMock() + mock_scheduler.is_active.return_value = True + mock_get_sched.return_value = mock_scheduler + + result = runner.invoke(app, ["auto-sync", "status"]) + assert result.exit_code == 0 + assert "30" in result.output + assert "/tmp/k.yaml" in result.output