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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@ project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added — agent-driven update detection

- **`autosentry update --check` now caches the PyPI lookup** for a day
(`~/.cache/autosentry`, XDG-aware; `--no-cache` forces a live query) and
**exits 0** whether or not you're behind, printing a recommendation
(`→ update available — run autosentry update`) when there's a newer
release. This lets the `/autosentry` skill run it on every invocation
without hammering PyPI or aborting triage chains.
- **`autosentry update --json`** emits `{"current","latest","is_outdated"}`
for scripts and agents that prefer to parse the result.
- **Homebrew installs are detected.** `update` recognizes a Cellar-based
install and runs `brew upgrade autosentry` (and recommends that command)
instead of falling back to `install.sh`.
- **Skill playbooks now nudge updates.** `AGENTS.md` and the per-tool
`/autosentry` wrappers (Claude, Cursor, Codex, OpenCode, Zed, Gemini)
run the version check during triage and recommend the upgrade command
when one is available, without upgrading unprompted.

### Changed — default posture is agent-first

- **`escalate_to_claude_after` default lowered** from `max_restarts // 2`
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -773,10 +773,18 @@ per-tool wrappers either embed it or reference it.

```bash
autosentry update # update to latest stable
autosentry update --check # report current vs latest, no install
autosentry update --check # current vs latest; recommends how to upgrade
autosentry update --check --json # machine-readable: {"current","latest","is_outdated"}
autosentry update --pre # allow pre-releases
```

`autosentry update` auto-detects how it was installed — uv tool, pipx,
`pip --user`, or **Homebrew** — and runs the matching upgrade (`brew upgrade
autosentry` for tap installs). `--check` caches the PyPI lookup for a day
(pass `--no-cache` to force a live query) and always exits 0, so the
`/autosentry` skill can run it on every invocation and nudge you when a newer
release is out.

Or use the standalone updater (works for installs made by `install.sh` even
when the CLI itself is broken):

Expand Down
72 changes: 55 additions & 17 deletions src/autosentry/cli/commands/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@

from __future__ import annotations

import json
from typing import cast

import typer

from autosentry.cli import app
from autosentry.cli.style import ERR, INFO, OK, WARN, console
from autosentry.cli.style import DIM, ERR, INFO, OK, WARN, console
from autosentry.updater import (
Method,
detect_install_method,
Expand All @@ -18,47 +19,84 @@
)


def _recommended_command() -> str:
"""The upgrade command to suggest, tailored to how autosentry was installed."""
if detect_install_method() == "brew":
return "brew upgrade autosentry"
return "autosentry update"


@app.command()
def update(
check_only: bool = typer.Option(False, "--check", help="Check for an update; do not install."),
json_out: bool = typer.Option(
False, "--json", help="Machine-readable check output (implies --check)."
),
no_cache: bool = typer.Option(
False, "--no-cache", help="Ignore the cached result and query PyPI live."
),
pre: bool = typer.Option(False, "--pre", help="Allow pre-release versions."),
version: str = typer.Option(
"", "--version", "-V", help="Pin to a specific version (otherwise: latest)."
),
method: str = typer.Option(
"",
"--method",
help="Force install method: uv | pipx | pip. Default: auto-detect.",
help="Force install method: uv | pipx | pip | brew. Default: auto-detect.",
),
) -> None:
"""Upgrade the installed autosentry CLI to the latest PyPI release.

Auto-detects the install method (uv tool, pipx, pip --user) and
delegates to the matching upgrade command. Falls back to running
Auto-detects the install method (uv tool, pipx, pip --user, Homebrew)
and delegates to the matching upgrade command. Falls back to running
install.sh from GitHub when the install method can't be determined.

Use --check to query PyPI without installing — exits non-zero
when an update is available, useful in scripts that want to nag.
--pre allows pre-release versions; --version pins to a specific
release; --method forces the install backend.
Use --check to compare against PyPI without installing; it recommends
the right upgrade command when you're behind and exits 0 either way.
The lookup is cached for a day (--no-cache forces a live query), and
--json emits the result for scripts. --pre allows pre-releases,
--version pins a release, --method forces the install backend.
"""
if check_only:
if check_only or json_out:
try:
result = updater_check(allow_pre=pre)
result = updater_check(allow_pre=pre, use_cache=not no_cache)
except RuntimeError as e:
console.print(f"[{ERR}]check failed:[/{ERR}] {e}")
if json_out:
typer.echo(json.dumps({"error": str(e)}))
else:
console.print(f"[{ERR}]check failed:[/{ERR}] {e}")
raise typer.Exit(code=1) from e
line = f"current: {result.current} · latest: {result.latest}"

if json_out:
typer.echo(
json.dumps(
{
"current": result.current,
"latest": result.latest,
"is_outdated": result.is_outdated,
}
)
)
return

if result.is_outdated:
console.print(f"[{WARN}]{line} · update available[/{WARN}]")
raise typer.Exit(code=1)
console.print(f"[{OK}]{line} · up to date[/{OK}]")
console.print(
f"[bold]autosentry[/bold] {result.current} "
f"[{DIM}](latest: {result.latest})[/{DIM}]"
)
console.print(
f"[{WARN}]→ update available — run [bold]{_recommended_command()}[/bold][/{WARN}]"
)
else:
console.print(f"[{OK}]autosentry {result.current} — up to date[/{OK}]")
return

chosen_str = method or detect_install_method()
valid_methods = {"uv", "pipx", "pip", "unknown"}
valid_methods = {"uv", "pipx", "pip", "brew", "unknown"}
if chosen_str not in valid_methods:
console.print(f"[{ERR}]invalid --method '{chosen_str}'[/{ERR}] (choose: uv, pipx, pip)")
console.print(
f"[{ERR}]invalid --method '{chosen_str}'[/{ERR}] (choose: uv, pipx, pip, brew)"
)
raise typer.Exit(code=2)
chosen: Method | None = None if chosen_str == "unknown" else cast(Method, chosen_str)
console.print(
Expand Down
10 changes: 10 additions & 0 deletions src/autosentry/templates/skills/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,23 @@ command -v autosentry >/dev/null && autosentry --version # phase ≥ 1
[ -f .autosentry/state.json ] && cat .autosentry/state.json # phase ≥ 3
# phase 4 = phase 3 + the pid in state.json is alive
ls -la .autosentry/recovery_request.md 2>/dev/null # phase 5?
autosentry update --check # newer release?
```

**Phase 5 is the highest-priority.** When the monitor escalates a
detection in interactive mode it writes
`.autosentry/recovery_request.md` and blocks waiting for
`.autosentry/recovery_response.md`. Handle that *before* anything else.

**Staying current.** `autosentry update --check` compares the installed
version against PyPI (cached for a day, so it's cheap to run every time)
and exits 0 either way. If it reports `→ update available`, mention it to
the user once and recommend the exact command it prints — `autosentry
update` for pip/uv/pipx installs, or `brew upgrade autosentry` for
Homebrew. Don't run the upgrade yourself unless the user asks; it can
restart the CLI mid-session. Use `--json` if you'd rather parse the
result (`{"current","latest","is_outdated"}`).

## Phase 5 — recovery request open

When the request file is newer than the response file:
Expand Down
6 changes: 6 additions & 0 deletions src/autosentry/templates/skills/claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,13 @@ Follow the full playbook in [`AGENTS.md`](../../AGENTS.md). Quick triage:
command -v autosentry && autosentry --version
[ -f autosentry.yaml ] && echo configured
[ -f .autosentry/state.json ] && jq -r '{pid, restarts, last_heartbeat}' .autosentry/state.json
autosentry update --check # newer release? (cached daily)
```

If `autosentry update --check` prints `→ update available`, tell the user
once and recommend the command it shows (`autosentry update`, or
`brew upgrade autosentry` for Homebrew). Don't upgrade unprompted.

Common entry points:

- Install: `curl -fsSL https://raw.githubusercontent.com/ulmentflam/autosentry/main/install.sh | sh`
Expand All @@ -22,6 +27,7 @@ Common entry points:
- Live TUI: `autosentry watch`
- Web viewer: `autosentry web`
- Ledger summary: `autosentry analyze --since 24h`
- Check for updates: `autosentry update --check` (then `autosentry update`)
- Last incident: `autosentry incidents show "$(ls -1t .autosentry/incidents | head -n1)"`

If `$ARGUMENTS` was passed, route to the matching subcommand. Otherwise
Expand Down
4 changes: 4 additions & 0 deletions src/autosentry/templates/skills/codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@ root — read it first.
command -v autosentry && autosentry --version
[ -f autosentry.yaml ] && echo configured
[ -f .autosentry/state.json ] && cat .autosentry/state.json
autosentry update --check # newer release? (cached daily)
```

- If `autosentry update --check` prints `→ update available` → mention it
once and recommend the command it shows (`autosentry update`, or
`brew upgrade autosentry` for Homebrew). Don't upgrade unprompted.
- Missing CLI → suggest the install one-liner from the README.
- Missing config → `autosentry init`, then configure
`process.command`, `config_snapshots`, detectors, and rules.
Expand Down
5 changes: 5 additions & 0 deletions src/autosentry/templates/skills/cursor.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@ playbook is in `AGENTS.md` at the repo root — load that first.
command -v autosentry && autosentry --version # installed?
[ -f autosentry.yaml ] # configured?
[ -f .autosentry/state.json ] # running?
autosentry update --check # newer release? (cached daily)
```

If `autosentry update --check` prints `→ update available`, mention it
once and recommend the command it shows (`autosentry update`, or
`brew upgrade autosentry` for Homebrew). Don't upgrade unprompted.

## Routes

- **Not installed.** Suggest:
Expand Down
4 changes: 4 additions & 0 deletions src/autosentry/templates/skills/gemini.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ at the repo root — load and follow it. Quick router:
- `autosentry web` — incident browser
- `autosentry incidents list` / `show <id>`
- `autosentry analyze --since 24h` — ledger summary
- `autosentry update --check` — cached daily; if it prints
`→ update available`, mention it once and recommend the command it
shows (`autosentry update`, or `brew upgrade autosentry` for
Homebrew). Don't upgrade unprompted.

Claude-driven fixes land on `autosentry/fix-<incident-id>` branches
and only stick if the same detector doesn't re-fire inside the verify
Expand Down
4 changes: 4 additions & 0 deletions src/autosentry/templates/skills/opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ Full playbook lives in [`AGENTS.md`](../../AGENTS.md). Quick router:
`.autosentry/logs/autosentry.log`.
4. Running → use `autosentry status`, `autosentry watch`,
`autosentry incidents list/show`, `autosentry analyze`.
5. `autosentry update --check` (cached daily) → if it prints
`→ update available`, mention it once and recommend the command it
shows (`autosentry update`, or `brew upgrade autosentry` for
Homebrew). Don't upgrade unprompted.

If three+ Claude-fixed incidents for the same detector all `kept`,
propose a YAML rule that codifies the pattern. If a detector has a
Expand Down
4 changes: 4 additions & 0 deletions src/autosentry/templates/skills/zed.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ root for the complete playbook. Quick router:
4. Running? Use `autosentry status`, `autosentry watch`,
`autosentry web`, `autosentry incidents list/show`,
`autosentry analyze --since 24h`.
5. `autosentry update --check` (cached daily) → if it prints
`→ update available`, mention it once and recommend the command it
shows (`autosentry update`, or `brew upgrade autosentry` for
Homebrew). Don't upgrade unprompted.

Claude-driven fixes land on `autosentry/fix-<incident-id>` branches
and only stick if the same detector doesn't re-fire inside the
Expand Down
85 changes: 78 additions & 7 deletions src/autosentry/updater.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"""Self-update logic for autosentry.

Detects how the CLI was installed (uv tool / pipx / pip --user / unknown)
and dispatches to the corresponding upgrade command. ``--check`` queries
PyPI for the latest version and reports current vs latest without
touching anything.
Detects how the CLI was installed (uv tool / pipx / pip --user / Homebrew /
unknown) and dispatches to the corresponding upgrade command. ``--check``
queries PyPI for the latest version and reports current vs latest without
touching anything; the result is cached on disk so repeated agent-driven
checks don't re-hit PyPI.

The detection is best-effort. If we can't tell how autosentry got here,
we shell out to ``install.sh`` (downloaded fresh from GitHub) as the
Expand All @@ -17,6 +18,7 @@
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
Expand All @@ -25,11 +27,16 @@

from autosentry import __version__

Method = Literal["uv", "pipx", "pip", "unknown"]
Method = Literal["uv", "pipx", "pip", "brew", "unknown"]

_PYPI_JSON = "https://pypi.org/pypi/autosentry/json"
_INSTALL_SH_URL = "https://raw.githubusercontent.com/ulmentflam/autosentry/main/install.sh"

# How long a cached PyPI "latest version" stays fresh. Agents call
# ``autosentry update --check`` on every /autosentry invocation, so we
# re-query PyPI at most once a day and serve the rest from disk.
_CHECK_TTL_SECONDS = 24 * 60 * 60


@dataclass(frozen=True)
class UpdateCheck:
Expand All @@ -48,6 +55,12 @@ def detect_install_method() -> Method:
py = Path(sys.executable).resolve()
home = Path.home()

# Homebrew installs the formula's virtualenv under
# <prefix>/Cellar/autosentry/<ver>/libexec; the bin/autosentry symlink
# resolves back into that Cellar path. Detect it so we recommend
# `brew upgrade` instead of clobbering the Cellar with install.sh.
if "/Cellar/autosentry/" in str(py):
return "brew"
# uv tool installs land under: $HOME/.local/share/uv/tools/<name>/...
if "uv/tools" in str(py) or "uv\\tools" in str(py):
return "uv"
Expand Down Expand Up @@ -103,8 +116,60 @@ def _version_key(v: str) -> tuple[int, ...]:
return tuple(parts)


def check(*, allow_pre: bool = False) -> UpdateCheck:
latest = fetch_latest_version(allow_pre=allow_pre)
def _cache_path() -> Path:
"""Where the last 'latest version' lookup is cached (XDG-aware)."""
base = os.environ.get("XDG_CACHE_HOME")
root = Path(base) if base else Path.home() / ".cache"
return root / "autosentry" / "update-check.json"


def _read_cache(*, allow_pre: bool, ttl: int) -> str | None:
"""Return the cached latest version if fresh and matching, else None.

Best-effort: any read/parse error or schema mismatch is a cache miss.
"""
try:
data = json.loads(_cache_path().read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(data, dict): # valid JSON but wrong shape (e.g. []) → miss
return None
latest = data.get("latest")
checked_at = data.get("checked_at")
if data.get("allow_pre") != allow_pre or not isinstance(latest, str):
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return None
if not isinstance(checked_at, (int, float)) or time.time() - checked_at > ttl:
return None
return latest


def _write_cache(*, latest: str, allow_pre: bool) -> None:
"""Persist the lookup. Never raises — the cache is an optimization."""
path = _cache_path()
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps({"checked_at": time.time(), "latest": latest, "allow_pre": allow_pre}),
encoding="utf-8",
)
except OSError:
pass


def check(
*, allow_pre: bool = False, use_cache: bool = True, ttl: int = _CHECK_TTL_SECONDS
) -> UpdateCheck:
"""Compare the running version against the latest on PyPI.

With ``use_cache`` (the default) the PyPI lookup is served from a
disk cache for ``ttl`` seconds, so an agent can call this on every
turn without hammering PyPI. Pass ``use_cache=False`` to force a
live query.
"""
latest = _read_cache(allow_pre=allow_pre, ttl=ttl) if use_cache else None
if latest is None:
latest = fetch_latest_version(allow_pre=allow_pre)
_write_cache(latest=latest, allow_pre=allow_pre)
return UpdateCheck(
current=__version__,
latest=latest,
Expand Down Expand Up @@ -139,6 +204,12 @@ def perform_update(
if chosen == "pip":
py = os.environ.get("AUTOSENTRY_PYTHON") or sys.executable
return _run([py, "-m", "pip", "install", "--user", "--upgrade", *pre, spec])
if chosen == "brew":
# brew can't honor --pre or a pinned --version against our tap, so
# those cases fall through to install.sh. The plain case is a tap bump.
if shutil.which("brew") and not allow_pre and not version:
return _run(["brew", "upgrade", "autosentry"])
return _fallback_to_install_sh(allow_pre=allow_pre, version=version)
return _fallback_to_install_sh(allow_pre=allow_pre, version=version)


Expand Down
Loading
Loading