diff --git a/README.md b/README.md index 5a4c6327..fca7928a 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,9 @@ shaped the way it is, what broke before, which invariants matter, and how workflows cross files and services. The wiki is plain markdown in your repo, indexed locally, and reviewed in Git like any other code change. -**Supported today:** macOS with Codex or Claude Code. Requires Python 3.12+. +**Supported today:** macOS and Linux with Codex or Claude Code. Requires Python +3.12+. Scheduled background automation (`launchd`) is macOS-only for now; on +Linux everything else works and you run `codealmanac sync` / `garden` yourself. ## Quickstart @@ -57,8 +59,9 @@ codealmanac setup --yes codealmanac setup --yes --runner claude ``` -Setup installs agent instructions for your chosen tools and three local macOS -`launchd` jobs. The jobs and all wiki work run locally. +Setup installs agent instructions for your chosen tools and, on macOS, three +local `launchd` jobs. On Linux the schedules are skipped (setup still completes) +and you trigger `sync` / `garden` manually. All wiki work runs locally. | Job | Default schedule | What it does | | --- | ---: | --- | diff --git a/docs/plans/2026-07-24-linux-support-option-a.md b/docs/plans/2026-07-24-linux-support-option-a.md new file mode 100644 index 00000000..6e2d95ef --- /dev/null +++ b/docs/plans/2026-07-24-linux-support-option-a.md @@ -0,0 +1,197 @@ +# Linux support — Option A: graceful automation skip on non-macOS + +## Problem + +Every lifecycle and read command already runs on Linux — `init`, `ingest`, +`garden`, `search`, indexing, the harness, telemetry are all platform-neutral +Python. The single blocker is **scheduled automation**. `create_app` wires +`AutomationService` with `LaunchdSchedulerAdapter`, which shells out to +`launchctl`. On Linux `launchctl` does not exist, so: + +- `codealmanac setup --yes` crashes with + `launchctl bootstrap failed for com.codealmanac.sync: [Errno 2] ... 'launchctl'` + and exits 1 (issue #31). +- `config set automation.*` hits the same path (both routes run + `AutomationService.reconcile_task` → `scheduler.install`). +- `config.toml` is written *before* the scheduler runs, so the crash leaves a + half-configured global state. + +This is **not** a rewrite. The `SchedulerAdapter` port already exists +(`services/automation/ports.py`, 3 methods) and `create_app` already accepts a +`scheduler` override. Launchd is just one implementation of that port. + +## Scope (Option A) + +Make the whole product usable on Linux **today** by degrading scheduled +automation gracefully instead of crashing. Scheduled background runs are not +provided on Linux in this slice; the user runs `codealmanac sync` / `garden` +manually (or wires their own cron). Real Linux scheduling (systemd user timers) +is **Option B**, a separate slice. + +### In scope + +1. Platform capability predicate in `core/` — single source of truth. +2. `UnsupportedSchedulerAdapter` implementing the existing port as a no-op. +3. Composition root selects the adapter by platform (launchd on macOS, + unsupported elsewhere). Only branch point for the platform fact in wiring. +4. Setup and automation-status render tell the user clearly, on non-macOS, that + scheduled automation is unavailable — and stop claiming schedules are + "installed"/"automatic" when nothing was scheduled. +5. Suppress the macOS-only "Background Items Added" heads-up off-macOS. + +### Out of scope + +- systemd / cron backends (Option B). +- Renaming `plist_path` / `Library/LaunchAgents` in the shared model (Option B + cleanup — the null adapter never touches those paths, so it can wait). +- Forcing `automation.*.enabled = false` in config on Linux. We keep config as + recorded intent so Option B activates it later without a re-run. Render, not + config, is where we tell the truth about what actually got scheduled. +- README / docs edits (fold into the commit if trivial; not gated here). + +## Design + +### Single platform predicate + +New `src/codealmanac/core/platform.py`: + +```python +import sys + +def scheduler_supported() -> bool: + """True when this platform has a scheduler backend CodeAlmanac can drive. + + Today the only backend is macOS launchd. Option B (systemd user timers) + will widen this. Consulted at the composition root (adapter selection) and + in render (what to tell the user about scheduling). + """ + return sys.platform == "darwin" +``` + +One predicate, two honest consult sites (wiring + render). This is dependency +injection + presentation, not a scattered special-case: there is exactly one +definition of "can we schedule here". + +### Null adapter + +New `src/codealmanac/integrations/automation/scheduler/unsupported.py`: + +```python +class UnsupportedSchedulerAdapter: + def install(self, job): return _not_installed(job) + def uninstall(self, job): return False + def status(self, job): return _not_installed(job) +``` + +`_not_installed` returns `ScheduledJobStatus(installed=False, loaded=False)`. +No filesystem writes, no `Library/LaunchAgents` dirs, no subprocess. Because +`install()` no longer raises, `setup` completes and exits 0 — which also +**removes the half-configured-state bug on Linux** (nothing fails, so nothing +is left partial). `reconcile_task` already ignores the `install()` return value. + +Export it from `integrations/automation/scheduler/__init__.py` and +`integrations/automation/__init__.py`. + +### Composition root selects the adapter + +`app.py`: + +```python +def default_scheduler_adapter() -> SchedulerAdapter: + if scheduler_supported(): + return LaunchdSchedulerAdapter() + return UnsupportedSchedulerAdapter() +``` + +`create_services` uses `adapters.scheduler or default_scheduler_adapter()`. +Tests that inject a fake scheduler are unaffected (override still wins). Cosmic +Python ch.13: platform wiring belongs at the composition root, not in services. + +### Honest render — reflect the outcome, not the platform + +The setup step-builders derive "installed"/"automatic" from +`result.config_update.automation[].enabled` — config *intent*, which on Linux is +`true` while nothing is actually scheduled. + +**Design correction (found during review/tests):** render must NOT re-derive +`sys.platform`. Terminal output describes *what actually happened*, and the +scheduler adapter — not the OS check — is the authority on whether a job was +activated. Re-checking the platform in render also broke tests that inject a +working fake scheduler to exercise macOS output on a Linux host. So the seam is +data, not platform: + +- `AutomationTaskApplyResult` gains a `scheduled: bool` field. `reconcile_task` + sets it from the adapter's real result: `scheduler.install(job).installed`. + The launchd adapter returns `installed=True`; the unsupported adapter returns + `installed=False`. It diverges from `enabled` exactly when the platform can't + schedule. +- `render/setup/result.py` reads `item.scheduled`: an `enabled and not + scheduled` task renders "manual — scheduling unavailable on this platform"; + wiki-maintenance and the "Background Items" confirmation are driven by the + *scheduled* set. When any task is `enabled and not scheduled`, a single + "Scheduled automation unavailable" note is rendered. +- `render/setup/background_items.py` keeps its original `len(tasks) == 0` guards + (no platform check). The unavailable-notice builder is unconditional; the + caller decides when to show it from result data. `platform_label()` is used + only for the cosmetic OS name in the message text. +- `render/automation.py` is unchanged: it already prints "not installed" per + task from the real adapter status, which is accurate on every platform. + +Platform detection lives in exactly one place: the composition root +(`default_scheduler_adapter`). Render stays platform-free. + +macOS path: launchd `install().installed` is `True` → `scheduled=True` → every +existing branch is taken unchanged → **terminal output byte-for-byte identical +on macOS** (non-negotiable per CLAUDE.md "Terminal output is behavior"). + +`--json` gains the additive `scheduled` field on the apply result; no existing +test asserts that structure, and it does not alter prose output. + +## File changes + +| File | Change | +|------|--------| +| `core/platform.py` | **new** — `scheduler_supported()` | +| `integrations/automation/scheduler/unsupported.py` | **new** — `UnsupportedSchedulerAdapter` | +| `integrations/automation/scheduler/__init__.py` | export new adapter | +| `integrations/automation/__init__.py` | export new adapter | +| `app.py` | `default_scheduler_adapter()`; use it in `create_services` | +| `services/automation/models.py` | add `scheduled: bool` to `AutomationTaskApplyResult` | +| `services/automation/service.py` | set `scheduled` from `install().installed` | +| `cli/render/setup/result.py` | steps + unavailable note driven by `scheduled` | +| `cli/render/setup/background_items.py` | unconditional unavailable-notice builder | +| `README.md` | supported-platforms + automation notes reflect Linux | + +## Test coverage + +New tests must sandbox `HOME` (repo convention) and force the platform rather +than depend on the host, so the suite behaves identically on macOS and Linux +CI. Patch `codealmanac.core.platform.scheduler_supported` (and its re-import +sites) via `monkeypatch`. + +- **`UnsupportedSchedulerAdapter`**: `install`/`status` return not-installed, + `uninstall` returns `False`, no filesystem side effects (no `Library/` + created under a temp home). +- **`create_app` selection**: with `scheduler_supported` patched `False`, a real + `create_app()` (no scheduler override) runs `config.update` / + `setup.run(--yes)` end-to-end **without raising** and exits 0; `config.toml` + is written. +- **No launchctl on the unsupported path**: assert the subprocess/`launchctl` + entrypoint is never reached (e.g. monkeypatch `subprocess.run` to fail the + test if called, or assert via the fake). +- **Render**: with support forced off, setup text shows the "unavailable" note + and no "Background Items" notice; with support on, existing macOS output is + byte-for-byte unchanged (snapshot/substring on both branches). +- Existing `test_automation_service.py` / `test_setup_service.py` continue to + pass unchanged (they inject fakes / run on the real adapter via override). + +Gates: `uv run pytest` and `uv run ruff check .`. + +## Review focus (must-fix / should-fix / consider) + +- **must-fix**: macOS output unchanged; no launchctl invocation on non-macOS; + `setup --yes` exit 0 on Linux; no partial state on the unsupported path. +- **should-fix**: render no longer claims schedules are "automatic"/"installed" + on Linux; platform predicate has exactly one definition. +- **consider**: whether `automation status` should say "unavailable on this + platform" vs. plain "not installed" (chosen: add the note — clarity). diff --git a/src/codealmanac/app.py b/src/codealmanac/app.py index ca69e029..7e144a3c 100644 --- a/src/codealmanac/app.py +++ b/src/codealmanac/app.py @@ -2,7 +2,11 @@ from dataclasses import dataclass from codealmanac import __version__ -from codealmanac.integrations.automation import LaunchdSchedulerAdapter +from codealmanac.core.platform import scheduler_supported +from codealmanac.integrations.automation import ( + LaunchdSchedulerAdapter, + UnsupportedSchedulerAdapter, +) from codealmanac.integrations.harnesses import default_harness_adapters from codealmanac.integrations.runs import ( PsutilRunProcessController, @@ -194,12 +198,18 @@ def create_app( return assemble_app(services, workflows) +def default_scheduler_adapter() -> SchedulerAdapter: + if scheduler_supported(): + return LaunchdSchedulerAdapter() + return UnsupportedSchedulerAdapter() + + def create_services( local_state: LocalStatePaths, adapters: AppAdapters, ) -> Services: repositories = RepositoriesService(RepositoryStore(local_state.database_path)) - automation = AutomationService(adapters.scheduler or LaunchdSchedulerAdapter()) + automation = AutomationService(adapters.scheduler or default_scheduler_adapter()) config_service = ConfigService( ConfigStore(), local_state.config_path, diff --git a/src/codealmanac/cli/render/setup/background_items.py b/src/codealmanac/cli/render/setup/background_items.py index 90e9f07b..dd62352d 100644 --- a/src/codealmanac/cli/render/setup/background_items.py +++ b/src/codealmanac/cli/render/setup/background_items.py @@ -2,6 +2,7 @@ from codealmanac.cli.render.brand import BAR, BLUE, RST, WHITE_BOLD from codealmanac.cli.render.terminal import wrap_with_prefixes, write_line +from codealmanac.core.platform import platform_label from codealmanac.services.automation.models import AutomationTask @@ -80,6 +81,17 @@ def background_item_confirmation_notice( ) +def scheduler_unavailable_notice() -> BackgroundItemNotice: + return BackgroundItemNotice( + title="Scheduled automation unavailable", + lines=( + f"CodeAlmanac can't schedule background jobs on {platform_label()} yet.", + "Run codealmanac sync and codealmanac garden manually, or wire them " + "into your own cron/systemd timer.", + ), + ) + + def automation_task_names(tasks: tuple[AutomationTask, ...]) -> str: names = tuple( { diff --git a/src/codealmanac/cli/render/setup/result.py b/src/codealmanac/cli/render/setup/result.py index fd1da93b..a6acfa71 100644 --- a/src/codealmanac/cli/render/setup/result.py +++ b/src/codealmanac/cli/render/setup/result.py @@ -11,6 +11,7 @@ from codealmanac.cli.render.setup.background_items import ( background_item_confirmation_notice, render_background_item_notice, + scheduler_unavailable_notice, ) from codealmanac.cli.render.setup.steps import SetupStep, render_setup_step from codealmanac.cli.render.setup.uninstall import render_uninstall_text @@ -55,17 +56,29 @@ def render_setup_text(result: SetupResult) -> None: if index < len(steps) - 1: write_line(BAR) background_notice = background_item_confirmation_notice( - enabled_automation_tasks(result) + scheduled_automation_tasks(result) ) if background_notice is not None: write_line(BAR) render_background_item_notice(background_notice) + if automation_scheduling_unavailable(result): + write_line(BAR) + render_background_item_notice(scheduler_unavailable_notice()) write_line("") render_next_steps_box(next_step_lines(result)) -def enabled_automation_tasks(result: SetupResult) -> tuple[AutomationTask, ...]: - return tuple(item.task for item in result.config_update.automation if item.enabled) +def scheduled_automation_tasks(result: SetupResult) -> tuple[AutomationTask, ...]: + return tuple( + item.task for item in result.config_update.automation if item.scheduled + ) + + +def automation_scheduling_unavailable(result: SetupResult) -> bool: + return any( + item.enabled and not item.scheduled + for item in result.config_update.automation + ) def next_step_lines(result: SetupResult) -> tuple[str, ...]: @@ -155,6 +168,8 @@ def automation_step(result: SetupResult, task: AutomationTask, label: str) -> Se item = applied.get(task) if item is None: return SetupStep(label, "skipped", "not requested") + if item.enabled and not item.scheduled: + return SetupStep(label, "manual", "scheduling unavailable on this platform") if item.enabled: return SetupStep(label, "installed", installed_automation_detail(result, task)) return SetupStep(label, "disabled", disabled_automation_detail(task)) @@ -163,7 +178,9 @@ def automation_step(result: SetupResult, task: AutomationTask, label: str) -> Se def wiki_maintenance_step(result: SetupResult) -> SetupStep: if len(result.config_update.automation) == 0: return SetupStep("Wiki maintenance", "manual", "no schedules installed") - installed = {item.task for item in result.config_update.automation if item.enabled} + installed = { + item.task for item in result.config_update.automation if item.scheduled + } if AutomationTask.SYNC in installed and AutomationTask.GARDEN in installed: return SetupStep( "Wiki maintenance", diff --git a/src/codealmanac/core/platform.py b/src/codealmanac/core/platform.py new file mode 100644 index 00000000..a016465b --- /dev/null +++ b/src/codealmanac/core/platform.py @@ -0,0 +1,17 @@ +import platform +import sys + + +def scheduler_supported() -> bool: + """True when this platform has a scheduler backend CodeAlmanac can drive. + + Today the only backend is macOS launchd. Option B (systemd user timers) + will widen this. Consulted at the composition root to pick the scheduler + adapter, and in render to decide what to tell the user about scheduling. + """ + return sys.platform == "darwin" + + +def platform_label() -> str: + """Human-facing OS name for messaging, e.g. "Linux".""" + return platform.system() or sys.platform diff --git a/src/codealmanac/integrations/automation/__init__.py b/src/codealmanac/integrations/automation/__init__.py index 7f9a0284..951a3d99 100644 --- a/src/codealmanac/integrations/automation/__init__.py +++ b/src/codealmanac/integrations/automation/__init__.py @@ -1,3 +1,6 @@ -from codealmanac.integrations.automation.scheduler import LaunchdSchedulerAdapter +from codealmanac.integrations.automation.scheduler import ( + LaunchdSchedulerAdapter, + UnsupportedSchedulerAdapter, +) -__all__ = ["LaunchdSchedulerAdapter"] +__all__ = ["LaunchdSchedulerAdapter", "UnsupportedSchedulerAdapter"] diff --git a/src/codealmanac/integrations/automation/scheduler/__init__.py b/src/codealmanac/integrations/automation/scheduler/__init__.py index bd8d556c..d29ede5d 100644 --- a/src/codealmanac/integrations/automation/scheduler/__init__.py +++ b/src/codealmanac/integrations/automation/scheduler/__init__.py @@ -1,5 +1,8 @@ from codealmanac.integrations.automation.scheduler.launchd import ( LaunchdSchedulerAdapter, ) +from codealmanac.integrations.automation.scheduler.unsupported import ( + UnsupportedSchedulerAdapter, +) -__all__ = ["LaunchdSchedulerAdapter"] +__all__ = ["LaunchdSchedulerAdapter", "UnsupportedSchedulerAdapter"] diff --git a/src/codealmanac/integrations/automation/scheduler/unsupported.py b/src/codealmanac/integrations/automation/scheduler/unsupported.py new file mode 100644 index 00000000..1d53d018 --- /dev/null +++ b/src/codealmanac/integrations/automation/scheduler/unsupported.py @@ -0,0 +1,30 @@ +from codealmanac.services.automation.models import ScheduledJob, ScheduledJobStatus + + +class UnsupportedSchedulerAdapter: + """No-op scheduler for platforms without a supported backend. + + On non-macOS hosts CodeAlmanac has no scheduler yet, so automation reconcile + must not shell out to `launchctl` or write launchd artifacts. Every method + reports "not installed" and touches nothing on disk, letting `setup` and + `config` complete cleanly instead of crashing. + """ + + def install(self, job: ScheduledJob) -> ScheduledJobStatus: + return not_installed(job) + + def uninstall(self, job: ScheduledJob) -> bool: + return False + + def status(self, job: ScheduledJob) -> ScheduledJobStatus: + return not_installed(job) + + +def not_installed(job: ScheduledJob) -> ScheduledJobStatus: + return ScheduledJobStatus( + task=job.task, + label=job.label, + plist_path=job.plist_path, + installed=False, + loaded=False, + ) diff --git a/src/codealmanac/services/automation/models.py b/src/codealmanac/services/automation/models.py index 64d7bd25..674bd26a 100644 --- a/src/codealmanac/services/automation/models.py +++ b/src/codealmanac/services/automation/models.py @@ -79,6 +79,10 @@ class AutomationTaskApplyResult(CodeAlmanacModel): interval: timedelta plist_path: Path changed: bool + # Whether the scheduler backend actually activated the job. Diverges from + # `enabled` on platforms without a scheduler (the config records intent but + # nothing is scheduled), letting render describe what really happened. + scheduled: bool = False class AutomationRemoveResult(CodeAlmanacModel): diff --git a/src/codealmanac/services/automation/service.py b/src/codealmanac/services/automation/service.py index 634ec466..d08a3e9f 100644 --- a/src/codealmanac/services/automation/service.py +++ b/src/codealmanac/services/automation/service.py @@ -37,16 +37,18 @@ def reconcile_task( ) -> AutomationTaskApplyResult: job = job_from_reconcile_request(self.jobs, request) if request.enabled: - self.scheduler.install(job) + scheduled = self.scheduler.install(job).installed changed = True else: changed = self.scheduler.uninstall(job) + scheduled = False return AutomationTaskApplyResult( task=request.task, enabled=request.enabled, interval=request.every, plist_path=job.plist_path, changed=changed, + scheduled=scheduled, ) def remove_all( diff --git a/tests/test_linux_support.py b/tests/test_linux_support.py new file mode 100644 index 00000000..fe01d8a3 --- /dev/null +++ b/tests/test_linux_support.py @@ -0,0 +1,163 @@ +"""Option A Linux support: automation degrades gracefully off macOS. + +These tests force the platform via ``sys.platform`` so they behave identically +on macOS and Linux CI. ``scheduler_supported`` reads ``sys.platform`` live, so +patching it flips the whole adapter-selection chain. +""" + +import subprocess +import sys +from pathlib import Path + +import pytest + +from codealmanac.app import create_app, default_scheduler_adapter +from codealmanac.cli.main import main +from codealmanac.core.platform import scheduler_supported +from codealmanac.integrations.automation import ( + LaunchdSchedulerAdapter, + UnsupportedSchedulerAdapter, +) +from codealmanac.services.automation.jobs import ( + AutomationJobFactory, + default_job_for_task, +) +from codealmanac.services.automation.models import AutomationTask +from codealmanac.services.config.models import ( + AutomationConfig, + GardenAutomationConfig, + HarnessConfig, + SyncAutomationConfig, + TelemetryConfig, + UpdateAutomationConfig, +) +from codealmanac.services.config.requests import UpdateUserConfigRequest +from codealmanac.services.harnesses.models import ( + HarnessKind, + HarnessReadiness, + HarnessRunResult, + HarnessRunStatus, +) +from codealmanac.settings import AppConfig + + +def force_platform(monkeypatch: pytest.MonkeyPatch, value: str) -> None: + monkeypatch.setattr(sys, "platform", value) + + +def test_scheduler_supported_tracks_platform(monkeypatch: pytest.MonkeyPatch) -> None: + force_platform(monkeypatch, "darwin") + assert scheduler_supported() is True + force_platform(monkeypatch, "linux") + assert scheduler_supported() is False + + +def test_default_adapter_selected_by_platform( + monkeypatch: pytest.MonkeyPatch, +) -> None: + force_platform(monkeypatch, "darwin") + assert isinstance(default_scheduler_adapter(), LaunchdSchedulerAdapter) + force_platform(monkeypatch, "linux") + assert isinstance(default_scheduler_adapter(), UnsupportedSchedulerAdapter) + + +def test_unsupported_adapter_is_a_noop(isolated_home: Path) -> None: + job = default_job_for_task(AutomationJobFactory(), AutomationTask.SYNC) + adapter = UnsupportedSchedulerAdapter() + + installed = adapter.install(job) + status = adapter.status(job) + + assert installed.installed is False + assert status.installed is False + assert adapter.uninstall(job) is False + # No launchd artifacts are written on the unsupported path. + assert not job.plist_path.exists() + assert not job.plist_path.parent.exists() + + +def test_app_picks_unsupported_scheduler_off_macos( + isolated_home: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + force_platform(monkeypatch, "linux") + app = create_app( + AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db") + ) + assert isinstance(app.automation.scheduler, UnsupportedSchedulerAdapter) + + +def test_config_update_does_not_crash_off_macos( + isolated_home: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + force_platform(monkeypatch, "linux") + # Fail loudly if the launchd path is ever taken on this platform. + monkeypatch.setattr( + "codealmanac.integrations.automation.scheduler.launchd.subprocess.run", + _fail_if_called, + ) + app = create_app( + AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db") + ) + + result = app.config.update(_enabled_automation_request(isolated_home)) + + # Config records intent, but nothing was actually scheduled. + assert Path(result.path).exists() + assert all(item.enabled for item in result.automation) + assert all(not item.scheduled for item in result.automation) + + +def test_cli_setup_reports_scheduling_unavailable_off_macos( + isolated_home: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + force_platform(monkeypatch, "linux") + app = create_app( + AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db"), + harness_adapters=(NoopHarnessAdapter(),), + ) + monkeypatch.setattr("codealmanac.cli.main.create_app", lambda: app) + + exit_code = main(["setup", "--yes"]) + output = capsys.readouterr().out + + assert exit_code == 0 + assert "Scheduled automation unavailable" in output + # The macOS-only "Background Items" nag is never shown off macOS. + assert "Background Items Added" not in output + + +class NoopHarnessAdapter: + kind = HarnessKind.CODEX + + def check(self) -> HarnessReadiness: + return HarnessReadiness(kind=self.kind, available=True, message="codex ready") + + def run(self, request: object, on_event: object = None) -> HarnessRunResult: + return HarnessRunResult( + kind=self.kind, + status=HarnessRunStatus.SUCCEEDED, + output_text="ok", + summary="ok", + ) + + +def _fail_if_called(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess: + raise AssertionError("launchctl must not be invoked on an unsupported platform") + + +def _enabled_automation_request(home: Path) -> UpdateUserConfigRequest: + return UpdateUserConfigRequest( + auto_commit=True, + harness=HarnessConfig(), + telemetry=TelemetryConfig(enabled=False), + automation=AutomationConfig( + sync=SyncAutomationConfig(enabled=True), + garden=GardenAutomationConfig(enabled=True), + update=UpdateAutomationConfig(enabled=True), + ), + home=home, + )