Skip to content
Open
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,11 @@ Setup installs agent instructions for your chosen tools and three local macOS
These schedules run locally in the background. Use
`codealmanac automation status` to see what is installed.

On platforms other than macOS, `setup` still installs agent instructions and
config but reports scheduled automation as unsupported instead of installing
schedules; `codealmanac config set automation.*`/`config apply` fail with a
clear error and change nothing.

If you don't have Codex or prefer Claude, use `--runner claude`.

`--target` only chooses which global agent instruction files to install; it does
Expand Down
14 changes: 14 additions & 0 deletions src/codealmanac/cli/render/setup/result.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,13 @@ def product_update_step(result: SetupResult) -> SetupStep:


def automation_step(result: SetupResult, task: AutomationTask, label: str) -> SetupStep:
if result.config_update.automation_error is not None:
return SetupStep(
label,
"unsupported",
result.config_update.automation_error,
warning=True,
)
applied = {item.task: item for item in result.config_update.automation}
item = applied.get(task)
if item is None:
Expand All @@ -147,6 +154,13 @@ def automation_step(result: SetupResult, task: AutomationTask, label: str) -> Se


def wiki_maintenance_step(result: SetupResult) -> SetupStep:
if result.config_update.automation_error is not None:
return SetupStep(
"Wiki maintenance",
"manual",
result.config_update.automation_error,
warning=True,
)
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}
Expand Down
6 changes: 6 additions & 0 deletions src/codealmanac/integrations/automation/scheduler/launchd.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import os
import plistlib
import subprocess
import sys
from dataclasses import dataclass
from datetime import timedelta
from pathlib import Path
Expand All @@ -24,6 +25,11 @@ class LaunchdInspection:


class LaunchdSchedulerAdapter:
def unavailable_reason(self) -> str | None:
if sys.platform != "darwin":
return "scheduled automation is macOS-only for now (needs launchd)"
return None

def install(self, job: ScheduledJob) -> ScheduledJobStatus:
job.plist_path.parent.mkdir(parents=True, exist_ok=True)
job.stdout_path.parent.mkdir(parents=True, exist_ok=True)
Expand Down
3 changes: 3 additions & 0 deletions src/codealmanac/services/automation/ports.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@ def uninstall(self, job: ScheduledJob) -> bool:

def status(self, job: ScheduledJob) -> ScheduledJobStatus:
"""Read persisted scheduler state for one job."""

def unavailable_reason(self) -> str | None:
"""Return why this scheduler cannot run here, or None when available."""
6 changes: 6 additions & 0 deletions src/codealmanac/services/automation/service.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from pathlib import Path

from codealmanac.core.errors import ExecutionFailed
from codealmanac.services.automation.jobs import (
AutomationJobFactory,
default_job_for_task,
Expand Down Expand Up @@ -36,6 +37,9 @@ def reconcile_task(
request: ReconcileAutomationTaskRequest,
) -> AutomationTaskApplyResult:
job = job_from_reconcile_request(self.jobs, request)
reason = self.scheduler.unavailable_reason()
if reason is not None:
raise ExecutionFailed(f"cannot apply scheduled automation: {reason}")
if request.enabled:
self.scheduler.install(job)
changed = True
Expand All @@ -54,6 +58,8 @@ def remove_all(
request: RemoveAllAutomationRequest,
) -> AutomationRemoveResult:
tasks = tuple(AutomationTask)
if self.scheduler.unavailable_reason() is not None:
return AutomationRemoveResult(tasks=tasks, removed=())
removed: list[Path] = []
for task in tasks:
job = default_job_for_task(
Expand Down
1 change: 1 addition & 0 deletions src/codealmanac/services/config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ class ConfigUpdateResult(CodeAlmanacModel):
path: str
entries: tuple["ConfigEntry", ...]
automation: tuple[AutomationTaskApplyResult, ...]
automation_error: str | None = None


class ConfigEntry(CodeAlmanacModel):
Expand Down
14 changes: 12 additions & 2 deletions src/codealmanac/services/config/service.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from datetime import timedelta
from pathlib import Path

from codealmanac.core.errors import ValidationFailed
from codealmanac.core.errors import ExecutionFailed, ValidationFailed
from codealmanac.core.paths import normalize_path
from codealmanac.services.automation.defaults import duration_text
from codealmanac.services.automation.models import (
Expand Down Expand Up @@ -77,11 +77,21 @@ def update(self, request: UpdateUserConfigRequest) -> ConfigUpdateResult:
path = normalize_path(self.user_config_path)
self.store.set_values(path, user_config_updates(request))
config = self.load_user()
applied = self.reconcile_all(config.automation, request)
# update() is setup's bulk onboarding call. A scheduler failure here
# (e.g. no launchd on this platform) should not abort instruction and
# harness config that already succeeded; set()/apply() are explicit,
# single-purpose commands and keep propagating scheduler failures.
automation_error = None
try:
applied = self.reconcile_all(config.automation, request)
except ExecutionFailed as error:
applied = ()
automation_error = str(error)
return ConfigUpdateResult(
path=path.as_posix(),
entries=config_entries(config),
automation=applied,
automation_error=automation_error,
)

def apply(self, request: ApplyConfigRequest) -> ConfigApplyResult:
Expand Down
3 changes: 3 additions & 0 deletions tests/test_automation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ def __init__(self):
self.uninstalled: list[ScheduledJob] = []
self.loaded: set[Path] = set()

def unavailable_reason(self) -> str | None:
return None

def install(self, job: ScheduledJob) -> ScheduledJobStatus:
self.installed.append(job)
self.loaded.add(job.plist_path)
Expand Down
86 changes: 86 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,9 @@ def __init__(self):
self.installed: list[ScheduledJob] = []
self.uninstalled: list[ScheduledJob] = []

def unavailable_reason(self) -> str | None:
return None

def install(self, job: ScheduledJob) -> ScheduledJobStatus:
self.installed.append(job)
return ScheduledJobStatus(
Expand All @@ -229,6 +232,22 @@ def status(self, job: ScheduledJob) -> ScheduledJobStatus:
)


class CliUnavailableSchedulerAdapter:
def unavailable_reason(self) -> str | None:
return "scheduled automation is macOS-only for now (needs launchd)"

def install(self, job: ScheduledJob) -> ScheduledJobStatus:
raise AssertionError("scheduler.install should not be called when unavailable")

def uninstall(self, job: ScheduledJob) -> bool:
raise AssertionError(
"scheduler.uninstall should not be called when unavailable"
)

def status(self, job: ScheduledJob) -> ScheduledJobStatus:
raise AssertionError("scheduler.status is not exercised in this test")


class CliUpdateMetadataProvider:
def __init__(self, metadata: PackageInstallMetadata):
self.metadata = metadata
Expand Down Expand Up @@ -399,6 +418,73 @@ def test_cli_setup_and_uninstall_codex_instructions(
)


def test_cli_setup_yes_skips_automation_cleanly_off_macos(
isolated_home: Path,
monkeypatch,
capsys,
):
scheduler = CliUnavailableSchedulerAdapter()
app = create_app(
AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db"),
scheduler=scheduler,
harness_adapters=(CliNoopHarnessAdapter(),),
)
monkeypatch.setattr("codealmanac.cli.main.create_app", lambda: app)

exit_code = main(["setup", "--yes", "--target", "codex"])

captured = capsys.readouterr()
agents_path = isolated_home / ".codex/AGENTS.md"
assert exit_code == 0
assert captured.err == ""
assert "unsupported" in captured.out
assert "macOS-only" in captured.out
assert CODEALMANAC_START in agents_path.read_text(encoding="utf-8")
assert not (isolated_home / "Library/LaunchAgents").exists()


def test_cli_config_set_automation_fails_cleanly_off_macos(
isolated_home: Path,
monkeypatch,
capsys,
):
app = create_app(
AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db"),
scheduler=CliUnavailableSchedulerAdapter(),
harness_adapters=(CliNoopHarnessAdapter(),),
)
monkeypatch.setattr("codealmanac.cli.main.create_app", lambda: app)

exit_code = main(["config", "set", "automation.sync.every", "8h"])

captured = capsys.readouterr()
assert exit_code == 1
assert captured.out == ""
assert "codealmanac: cannot apply scheduled automation" in captured.err
assert "macOS-only" in captured.err
assert not (isolated_home / "Library/LaunchAgents").exists()


def test_cli_uninstall_succeeds_even_when_scheduler_unavailable(
isolated_home: Path,
monkeypatch,
capsys,
):
app = create_app(
AppConfig(database_path=isolated_home / ".codealmanac/codealmanac.db"),
scheduler=CliUnavailableSchedulerAdapter(),
harness_adapters=(CliNoopHarnessAdapter(),),
package_uninstaller=CliPackageUninstaller(),
)
monkeypatch.setattr("codealmanac.cli.main.create_app", lambda: app)

exit_code = main(["uninstall", "--yes"])

captured = capsys.readouterr()
assert exit_code == 0
assert "CodeAlmanac uninstall" in captured.out


def test_cli_uninstall_without_yes_is_non_destructive_in_noninteractive_shell(
isolated_home: Path,
monkeypatch,
Expand Down
65 changes: 64 additions & 1 deletion tests/test_config_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@
from codealmanac.app import create_app
from codealmanac.core.errors import ExecutionFailed, ValidationFailed
from codealmanac.services.automation.models import ScheduledJob, ScheduledJobStatus
from codealmanac.services.config.models import ConfigKey
from codealmanac.services.config.models import (
AutomationConfig,
ConfigKey,
HarnessConfig,
)
from codealmanac.services.config.requests import (
ApplyConfigRequest,
SetConfigValueRequest,
UpdateUserConfigRequest,
)
from codealmanac.services.harnesses.models import HarnessKind
from codealmanac.settings import AppConfig
Expand All @@ -20,6 +25,9 @@ def __init__(self):
self.installed: list[ScheduledJob] = []
self.uninstalled: list[ScheduledJob] = []

def unavailable_reason(self) -> str | None:
return None

def install(self, job: ScheduledJob) -> ScheduledJobStatus:
self.installed.append(job)
return scheduler_status(job, installed=True)
Expand All @@ -32,6 +40,11 @@ def status(self, job: ScheduledJob) -> ScheduledJobStatus:
return scheduler_status(job, installed=False)


class UnavailableScheduler(FakeScheduler):
def unavailable_reason(self) -> str | None:
return "scheduled automation is macOS-only for now (needs launchd)"


class FailOnceScheduler(FakeScheduler):
def __init__(self, fail_task: str):
super().__init__()
Expand Down Expand Up @@ -235,6 +248,56 @@ def test_scheduler_failure_keeps_desired_toml_for_retry(
]


def test_config_set_fails_cleanly_when_scheduler_unavailable(
isolated_home: Path,
) -> None:
scheduler = UnavailableScheduler()
app = config_app(isolated_home, scheduler)

with pytest.raises(ExecutionFailed, match="macOS-only"):
app.config.set(set_request(ConfigKey.AUTOMATION_SYNC_EVERY, "8h"))

assert app.config.load_user().automation.sync.every == timedelta(hours=8)
assert scheduler.installed == []
assert scheduler.uninstalled == []


def test_config_apply_fails_cleanly_when_scheduler_unavailable(
isolated_home: Path,
) -> None:
scheduler = UnavailableScheduler()
app = config_app(isolated_home, scheduler)

with pytest.raises(ExecutionFailed, match="macOS-only"):
app.config.apply(ApplyConfigRequest(home=isolated_home))

assert scheduler.installed == []
assert scheduler.uninstalled == []


def test_config_update_skips_automation_cleanly_when_scheduler_unavailable(
isolated_home: Path,
) -> None:
scheduler = UnavailableScheduler()
app = config_app(isolated_home, scheduler)

result = app.config.update(
UpdateUserConfigRequest(
auto_commit=False,
harness=HarnessConfig(),
automation=AutomationConfig(),
home=isolated_home,
)
)

assert result.automation == ()
assert result.automation_error is not None
assert "macOS-only" in result.automation_error
assert scheduler.installed == []
assert scheduler.uninstalled == []
assert app.config.load_user().auto_commit is False


def test_config_apply_rejects_invalid_toml_without_scheduler_calls(
isolated_home: Path,
) -> None:
Expand Down
Loading