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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
git config --global user.name ci
git config --global user.email ci@example.invalid
- name: Install
run: uv sync --python 3.12
run: uv sync --locked --python 3.12
- name: Tests
run: uv run pytest -q
- name: Licence allowlist
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ uv.lock.bak
.pytest_out
config/
_site/
/ion.yml
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ deploy/install.sh ~/.ion/ion.yml
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.aska.ion.plist
```

It runs `ion run` daily at 06:30 and logs to `~/.ion/ion.log`. The shipped plist runs executors as dry runs; add `--apply` after `run` once you trust the proposals. Keep the source outside `~/Documents` (macOS privacy prompts).
It runs `ion run` daily at 06:30 and logs to `~/.ion/ion.log`. The installer gives the agent the same state directory as your shell (`ION_HOME`, default `~/.ion`) and an absolute config path (the argument, else `ION_CONFIG`, else `<state dir>/ion.yml`), so `ion pause` from your shell also stops the scheduled run. It refuses to install while state from before the rename is still in `~/.mimar`. The shipped plist runs executors as dry runs; add `--apply` after `run` once you trust the proposals. Keep the source outside `~/Documents` (macOS privacy prompts).

Phase 2 (when cost or uptime justify it): a GitHub App identity and a GitHub Actions schedule with `llm.backend: anthropic_api`. Only config changes.

Expand Down
6 changes: 4 additions & 2 deletions deploy/ai.aska.ion.plist
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@
<string>__HOME__/.local/bin:__HOME__/Library/Python/3.9/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>HOME</key>
<string>__HOME__</string>
<key>ION_HOME</key>
<string>__ION_HOME__</string>
</dict>
<key>StandardOutPath</key>
<string>__HOME__/.ion/ion.log</string>
<string>__ION_HOME__/ion.log</string>
<key>StandardErrorPath</key>
<string>__HOME__/.ion/ion.log</string>
<string>__ION_HOME__/ion.log</string>
<key>RunAtLoad</key>
<false/>
<key>ProcessType</key>
Expand Down
26 changes: 19 additions & 7 deletions deploy/install.sh
Original file line number Diff line number Diff line change
@@ -1,28 +1,40 @@
#!/usr/bin/env bash
# Installs the LaunchAgent plist for Ion. It does NOT load it; you decide when.
# deploy/install.sh [path/to/config.yml]
# The agent gets the same state directory (ION_HOME, default ~/.ion) and config (the argument, else ION_CONFIG, else
# <state dir>/ion.yml) as this shell, so `ion pause` here stops the scheduled run too.
set -euo pipefail
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONFIG="${1:-$HOME/.ion/ion.yml}"
STATE_DIR="${ION_HOME:-$HOME/.ion}"
CONFIG="${1:-${ION_CONFIG:-$STATE_DIR/ion.yml}}"
UV="${UV:-$(command -v uv || echo "$HOME/.local/bin/uv")}"
LABEL="ai.aska.ion"
DEST="$HOME/Library/LaunchAgents/$LABEL.plist"

if [ -z "${ION_HOME:-}" ] && [ -f "$HOME/.mimar/state.db" ] && [ ! -f "$STATE_DIR/state.db" ]; then
echo "ion state is still in ~/.mimar (the old name). Move it first, or the agent starts from empty state" >&2
echo "and ignores ~/.mimar/PAUSED: mkdir -p ~/.ion && mv ~/.mimar/* ~/.ion/" >&2
exit 1
fi
[ -f "$CONFIG" ] || { echo "config not found: $CONFIG" >&2; exit 1; }
[ -x "$UV" ] || { echo "uv not found (set UV=/path/to/uv)" >&2; exit 1; }
# launchd runs the agent in $SRC_DIR, so a relative path would name a different file there
CONFIG="$(cd "$(dirname "$CONFIG")" && pwd)/$(basename "$CONFIG")"
STATE_DIR="$(mkdir -p "$STATE_DIR" && cd "$STATE_DIR" && pwd)"

(cd "$SRC_DIR" && "$UV" sync --no-dev >/dev/null)
(cd "$SRC_DIR" && "$UV" sync --locked --no-dev >/dev/null)
ION_BIN="$SRC_DIR/.venv/bin/ion"
[ -x "$ION_BIN" ] || { echo "ion entry point missing at $ION_BIN" >&2; exit 1; }

mkdir -p "$HOME/.ion" "$HOME/Library/LaunchAgents"
sed -e "s|__ION_BIN__|$ION_BIN|g" -e "s|__CONFIG__|$CONFIG|g" \
-e "s|__SRC_DIR__|$SRC_DIR|g" -e "s|__HOME__|$HOME|g" \
mkdir -p "$HOME/Library/LaunchAgents"
esc() { printf '%s' "$1" | sed -e 's/[&|\\]/\\&/g'; }
sed -e "s|__ION_BIN__|$(esc "$ION_BIN")|g" -e "s|__CONFIG__|$(esc "$CONFIG")|g" \
-e "s|__SRC_DIR__|$(esc "$SRC_DIR")|g" -e "s|__ION_HOME__|$(esc "$STATE_DIR")|g" -e "s|__HOME__|$(esc "$HOME")|g" \
"$SRC_DIR/deploy/ai.aska.ion.plist" > "$DEST"
plutil -lint "$DEST"

echo "installed $DEST (not loaded)."
echo "check first: $ION_BIN --config $CONFIG doctor"
echo "check first: ION_HOME=$STATE_DIR $ION_BIN --config $CONFIG doctor"
echo "to enable: launchctl bootstrap gui/$(id -u) $DEST"
echo "to disable: launchctl bootout gui/$(id -u)/$LABEL"
echo "kill switch: $ION_BIN pause (and resume)"
echo "kill switch: ION_HOME=$STATE_DIR $ION_BIN pause (and resume)"
12 changes: 8 additions & 4 deletions ion/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,14 +131,18 @@ def find_config_file() -> Path | None:
env = os.environ.get("ION_CONFIG") or os.environ.get("MIMAR_CONFIG")
if env:
return Path(env).expanduser()
for c in (Path.cwd() / "ion.yml", home() / "ion.yml"):
unset = "ION_HOME" not in os.environ # then ~/.ion/ion.yml counts even while state is still in ~/.mimar
for c in (Path.cwd() / "ion.yml", home() / "ion.yml", *([Path("~/.ion/ion.yml").expanduser()] if unset else [])):
if c.exists():
return c
# Deprecation shim: this bot was called mimar until 2026-10-13.
for c in (Path.cwd() / "mimar.yml", Path("~/.mimar/mimar.yml").expanduser()):
legacy = Path("~/.mimar").expanduser()
for c in (Path.cwd() / "mimar.yml", legacy / "mimar.yml"):
if c.exists() and "ION_HOME" not in os.environ:
print(f"ion: reading legacy {c}; migrate: mkdir -p ~/.ion && mv {c} ~/.ion/ion.yml (the name 'mimar' is deprecated)",
file=sys.stderr)
# move the state too: config alone would leave state.db and the PAUSED kill switch behind in ~/.mimar
steps = ["mkdir -p ~/.ion"] + (["mv ~/.mimar/* ~/.ion/"] if legacy.is_dir() else [])
steps.append(f"mv {'~/.ion/mimar.yml' if c.parent == legacy else c} ~/.ion/ion.yml")
print(f"ion: reading legacy {c}; migrate: {' && '.join(steps)} (the name 'mimar' is deprecated)", file=sys.stderr)
return c
return None

Expand Down
4 changes: 2 additions & 2 deletions ion/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ class Waiting(Exception):
def home() -> Path:
p = Path(os.environ.get("ION_HOME", "~/.ion")).expanduser()
legacy = Path("~/.mimar").expanduser()
if "ION_HOME" not in os.environ and not p.exists() and legacy.is_dir():
return legacy # deprecation shim: operator state not migrated yet
if "ION_HOME" not in os.environ and not (p / "state.db").exists() and legacy.is_dir():
return legacy # deprecation shim: operator state not migrated yet (an empty ~/.ion does not count)
p.mkdir(parents=True, exist_ok=True)
return p

Expand Down
131 changes: 131 additions & 0 deletions tests/test_deploy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""deploy/install.sh and the LaunchAgent it renders.

Each test runs the real installer against a temp HOME (a stub `plutil`, and `UV=true` so nothing is synced), then runs
the rendered ProgramArguments the way launchd would: in WorkingDirectory, with only the plist's EnvironmentVariables.
`run` is swapped for `status`, which reads the same config, state dir and kill switch offline.
"""
import plistlib
import re
import shutil
import sqlite3
import subprocess
import sys
from pathlib import Path

import pytest

ROOT = Path(__file__).resolve().parent.parent
ION_BIN = Path(sys.executable).parent / "ion"
TRUE = shutil.which("true")

pytestmark = pytest.mark.skipif(not ION_BIN.exists() or not shutil.which("bash") or not TRUE,
reason="needs bash and the ion entry point next to the test interpreter")


def _src(tmp_path: Path) -> Path:
"""The layout install.sh expects (deploy/ and .venv/bin/ion), in a temp dir rather than this checkout."""
src = tmp_path / "src"
(src / "deploy").mkdir(parents=True)
for f in ("install.sh", "ai.aska.ion.plist"):
shutil.copy2(ROOT / "deploy" / f, src / "deploy" / f)
(src / ".venv" / "bin").mkdir(parents=True)
(src / ".venv" / "bin" / "ion").symlink_to(ION_BIN)
return src


def _install(tmp_path: Path, home: Path, config_arg: str, cwd: Path, env: dict | None = None) -> subprocess.CompletedProcess:
fake = tmp_path / "fakebin"
fake.mkdir(exist_ok=True)
(fake / "plutil").write_text('#!/bin/sh\necho "$2: OK"\n')
(fake / "plutil").chmod(0o755)
return subprocess.run(["bash", str(_src(tmp_path) / "deploy" / "install.sh"), config_arg], cwd=cwd,
env={"HOME": str(home), "PATH": f"{fake}:/usr/bin:/bin", "UV": TRUE, **(env or {})},
capture_output=True, text=True)


def _plist(home: Path) -> dict:
raw = (home / "Library" / "LaunchAgents" / "ai.aska.ion.plist").read_bytes()
# the template's header comment contains `--apply`, which CoreFoundation accepts and expat does not
return plistlib.loads(re.sub(rb"<!--.*?-->", b"", raw, flags=re.S))


def _as_launchd(plist: dict) -> subprocess.CompletedProcess:
argv = list(plist["ProgramArguments"])
argv[argv.index("run")] = "status"
return subprocess.run(argv, cwd=plist["WorkingDirectory"], env=dict(plist["EnvironmentVariables"]),
capture_output=True, text=True)


def _shell(home: Path, *args, ion_home: Path | None = None, cwd: Path | None = None) -> subprocess.CompletedProcess:
env = {"HOME": str(home), "PATH": "/usr/bin:/bin", **({"ION_HOME": str(ion_home)} if ion_home else {})}
return subprocess.run([str(ION_BIN), *args], env=env, cwd=cwd, capture_output=True, text=True)


def _legacy_state(home: Path) -> Path:
"""An operator from before the rename: ~/.mimar holds the config, a proposal and an engaged kill switch."""
legacy = home / ".mimar"
legacy.mkdir(parents=True)
(legacy / "mimar.yml").write_text("org: acme\n")
assert _shell(home, "--config", str(legacy / "mimar.yml"), "status").returncode == 0 # creates state.db
con = sqlite3.connect(legacy / "state.db")
con.execute("insert into proposals(id,num,kind,key,status,score,title,state) values"
"('P-0001',1,'archive','archive:old-tool','awaiting-approval',0.9,'archive old-tool','{}')")
con.commit()
con.close()
assert _shell(home, "pause").returncode == 0
return legacy


def test_relative_config_path_is_the_file_the_operator_named(tmp_path):
"""launchd runs the agent in the source tree, where a dev `ion.yml` may sit (README: `ion --config ion.yml doctor`)."""
home = tmp_path / "home"
(home / ".ion").mkdir(parents=True)
(home / ".ion" / "ion.yml").write_text("org: acme\n")
(tmp_path / "src").mkdir()
(tmp_path / "src" / "ion.yml").write_text("org: dev-sandbox\n")
p = _install(tmp_path, home, "ion.yml", cwd=home / ".ion")
assert p.returncode == 0, p.stdout + p.stderr
assert "org: acme" in _as_launchd(_plist(home)).stdout


def test_operator_ion_home_reaches_the_launch_agent(tmp_path):
"""`ion pause` from a shell with ION_HOME set must stop the scheduled run too."""
home, state = tmp_path / "home", tmp_path / "ion-state"
state.mkdir()
(state / "ion.yml").write_text("org: acme\n")
p = _install(tmp_path, home, "", cwd=tmp_path, env={"ION_HOME": str(state)}) # config defaults to $ION_HOME
assert p.returncode == 0, p.stdout + p.stderr
assert _shell(home, "pause", ion_home=state).returncode == 0
plist = _plist(home)
assert plist["StandardOutPath"] == str(state / "ion.log")
assert "kill switch: ON" in _as_launchd(plist).stdout


def test_install_refuses_to_abandon_unmigrated_mimar_state(tmp_path):
home = tmp_path / "home"
legacy = _legacy_state(home)
p = _install(tmp_path, home, str(legacy / "mimar.yml"), cwd=tmp_path)
assert p.returncode != 0 and "~/.mimar" in p.stderr
assert not (home / "Library" / "LaunchAgents" / "ai.aska.ion.plist").exists()
assert not (home / ".ion").exists()


def test_an_empty_ion_dir_does_not_hide_mimar_state(tmp_path):
"""The old migration note said `mkdir -p ~/.ion && mv <config> ~/.ion/ion.yml`; state.db and PAUSED stayed behind."""
home = tmp_path / "home"
legacy = _legacy_state(home)
(home / ".ion").mkdir()
(legacy / "mimar.yml").rename(home / ".ion" / "ion.yml")
out = _shell(home, "status", cwd=tmp_path).stdout
assert "kill switch: ON" in out and "P-0001" in out


def test_following_the_printed_migration_note_keeps_state(tmp_path):
home = tmp_path / "home"
_legacy_state(home)
note = _shell(home, "status", cwd=tmp_path).stderr
cmd = re.search(r"migrate: (.*) \(the name", note).group(1)
assert subprocess.run(["bash", "-c", cmd], env={"HOME": str(home), "PATH": "/usr/bin:/bin"}).returncode == 0
assert not (home / ".mimar" / "state.db").exists()
out = _shell(home, "status", cwd=tmp_path)
assert "kill switch: ON" in out.stdout and "P-0001" in out.stdout and "deprecated" not in out.stderr