Repository documentation
Prefix-installed CLI self-update
- Status: completed
- Owning queue item: HOST-SELF-UPDATE-001
- Completion / archival evidence: HOST-SELF-UPDATE-001 is closed.
make python-check3444 tests (32 skipped) on 2026-09-05;make install-checkon clean03175d8dreturned isolated smokeok: truewithLITAI_NO_SELF_UPDATE=1and no GitHub Releases API contact.
Prefix-installed CLI self-update implementation plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: A prefix-installed litai launcher stages a newer GitHub Release wheel in the background and, on the next enrolled invocation, installs it into the same prefix and re-execs the original argv.
Architecture: Keep GitHub I/O and venv mutation in src/literate_ai/adapters/host_self_update.py. dispatch.main calls maybe_host_self_update(argv) before argparse. The installer writes literate-ai/host-install-manifest@2 and both launchers export LITAI_HOST_INSTALL=1 plus LITAI_PREFIX. Checkouts, CI, @1 manifests, and LITAI_NO_SELF_UPDATE=1 stay inert.
Tech Stack: Python 3.11+, urllib.request, packaging.version, existing private venv python -m pip, POSIX/Windows launchers, unittest.
Spec: ADR 0037
Global Constraints
- Only enrolled prefix installs (
LITAI_HOST_INSTALL=1, matching@2manifest withself_update: true, interpreter inside that venv). - Update source is
https://api.github.com/repos/jordanhubbard/literate-ai/releases/latestand its wheel assetliterate_ai-<PEP440>-py3-none-any.whl. - Never downgrade; never use PyPI or git; never replace the live venv from the background worker.
- Fail open: warn on stderr, run the current command. GitHub timeout 10s; pip timeout 120s.
- Success cache 24h; failed-check retry 15 minutes. Foreground never waits on GitHub.
- Opt out:
LITAI_NO_SELF_UPDATE=1,CI,GITHUB_ACTIONS,LITAI_EVIDENCE_RUN,LITAI_SELF_UPDATE_REEXEC=1. litai updateremains project-file reconciliation (ADR 0007). No new subcommand.- Tests must not hit the network. Run focused tests with
PYTHONPATH=srcthenmake python-check. - Do not commit unless the operator asks.
File map
- Create:
src/literate_ai/adapters/host_self_update.py— enrollment, cache, GitHub selection, staging, apply, re-exec, worker. - Create:
tests/critical/test_host_self_update.py— hermetic coverage for the adapter. - Modify:
src/literate_ai/adapters/user_paths.py— add@2schema constant; keep@1for uninstall compatibility. - Modify:
scripts/install_litai.py— write@2withself_update: true. - Modify:
scripts/litai-launcher,scripts/litai-launcher.cmd— export host-install env. - Modify:
src/literate_ai/cli/dispatch.py— callmaybe_host_self_updateat the start ofmain. - Modify:
src/literate_ai/adapters/host_uninstall.py— accept@1and@2; deleteself-update/staging. - Modify:
scripts/installed_project_smoke.py(and any sibling install-check driver) —LITAI_NO_SELF_UPDATE=1. - Modify:
docs/user/installation.md,CHANGELOG.md, this file's checkboxes as tasks land.
Task 1: Manifest schema @2, launcher env, enrollment
Files:
- Modify:
src/literate_ai/adapters/user_paths.py - Create:
src/literate_ai/adapters/host_self_update.py - Create:
tests/critical/test_host_self_update.py - Modify:
scripts/install_litai.py - Modify:
scripts/litai-launcher - Modify:
scripts/litai-launcher.cmd - Modify:
tests/smoke/test_install_litai.pyif it asserts exact@1JSON
Interfaces:
Consumes:
HostInstallLayout.for_prefixProduces:
HOST_INSTALL_MANIFEST_SCHEMA_V1 = "literate-ai/host-install-manifest@1"HOST_INSTALL_MANIFEST_SCHEMA = "literate-ai/host-install-manifest@2"(written by new installs)HOST_INSTALL_ENVIRONMENT = "LITAI_HOST_INSTALL"PREFIX_ENVIRONMENT = "LITAI_PREFIX"OPT_OUT_ENVIRONMENT = "LITAI_NO_SELF_UPDATE"REEXEC_ENVIRONMENT = "LITAI_SELF_UPDATE_REEXEC"@dataclass(frozen=True, slots=True) class HostSelfUpdateEnrollment: prefix, environment, launcher, manifest, staging_root: Pathdef resolve_host_self_update_enrollment(*, environ: Mapping[str, str], executable: Path) -> HostSelfUpdateEnrollment | None
Step 1: Write failing enrollment tests
# tests/critical/test_host_self_update.py
from __future__ import annotations
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
from literate_ai.adapters.host_self_update import resolve_host_self_update_enrollment
from literate_ai.adapters.user_paths import (
HOST_INSTALL_MANIFEST_SCHEMA,
HostInstallLayout,
)
def _write_manifest(layout: HostInstallLayout, *, schema: str, self_update: bool = True) -> None:
Path(layout.environment).mkdir(parents=True, exist_ok=True)
Path(layout.launcher).parent.mkdir(parents=True, exist_ok=True)
Path(layout.launcher).write_text("launcher\n", encoding="utf-8")
document = {
"schema": schema,
"prefix": str(layout.prefix),
"environment": str(layout.environment),
"launcher": str(layout.launcher),
"self_update": self_update,
}
if schema.endswith("@1"):
document.pop("self_update")
Path(layout.manifest).write_text(
json.dumps(document, sort_keys=True, separators=(",", ":")) + "\n",
encoding="utf-8",
)
class HostSelfUpdateEnrollmentTests(unittest.TestCase):
def test_enrolls_only_when_flag_manifest_and_venv_python_match(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
prefix = Path(temporary, "prefix").resolve()
layout = HostInstallLayout.for_prefix(prefix)
_write_manifest(layout, schema=HOST_INSTALL_MANIFEST_SCHEMA)
python = Path(layout.environment) / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
python.parent.mkdir(parents=True, exist_ok=True)
python.write_text("python\n", encoding="utf-8")
enrolled = resolve_host_self_update_enrollment(
environ={
"LITAI_HOST_INSTALL": "1",
"LITAI_PREFIX": str(prefix),
},
executable=python,
)
self.assertIsNotNone(enrolled)
self.assertEqual(enrolled.staging_root, Path(layout.application_root) / "self-update")
def test_skips_v1_manifest_checkout_ci_and_opt_out(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
prefix = Path(temporary, "prefix").resolve()
layout = HostInstallLayout.for_prefix(prefix)
_write_manifest(layout, schema="literate-ai/host-install-manifest@1")
python = Path(layout.environment) / "bin/python"
python.parent.mkdir(parents=True, exist_ok=True)
python.write_text("python\n", encoding="utf-8")
self.assertIsNone(
resolve_host_self_update_enrollment(
environ={"LITAI_HOST_INSTALL": "1", "LITAI_PREFIX": str(prefix)},
executable=python,
)
)
_write_manifest(layout, schema=HOST_INSTALL_MANIFEST_SCHEMA)
self.assertIsNone(
resolve_host_self_update_enrollment(
environ={
"LITAI_HOST_INSTALL": "1",
"LITAI_PREFIX": str(prefix),
"LITAI_NO_SELF_UPDATE": "1",
},
executable=python,
)
)
self.assertIsNone(
resolve_host_self_update_enrollment(
environ={
"LITAI_HOST_INSTALL": "1",
"LITAI_PREFIX": str(prefix),
"CI": "true",
},
executable=python,
)
)
self.assertIsNone(
resolve_host_self_update_enrollment(
environ={"LITAI_PREFIX": str(prefix)},
executable=python,
)
)
self.assertIsNone(
resolve_host_self_update_enrollment(
environ={"LITAI_HOST_INSTALL": "1", "LITAI_PREFIX": str(prefix)},
executable=Path(sys.executable),
)
)
- Step 2: Run the test to verify it fails
Run: PYTHONPATH=src python3 -m unittest tests.critical.test_host_self_update -v
Expected: FAIL because host_self_update is missing.
- Step 3: Implement enrollment and installer/launcher writes
resolve_host_self_update_enrollment returns None when any of: missing LITAI_HOST_INSTALL=1; empty/relative LITAI_PREFIX; LITAI_NO_SELF_UPDATE truthy (1/true/yes casefold); CI or GITHUB_ACTIONS truthy; LITAI_EVIDENCE_RUN or LITAI_SELF_UPDATE_REEXEC truthy; manifest missing, symlink in custody, not @2, self_update not true, prefix/environment/launcher mismatch; executable.resolve() not equal to or under Path(layout.environment). Staging root is application_root / "self-update".
POSIX launcher after computing prefix:
LITAI_PREFIX=$prefix
LITAI_HOST_INSTALL=1
export LITAI_PREFIX LITAI_HOST_INSTALL
Windows launcher: set "LITAI_HOST_INSTALL=1" next to the existing LITAI_PREFIX assignment.
install_litai.py writes schema @2 plus "self_update": true.
- Step 4: Re-run tests
Expected: PASS.
- Step 5: Do not commit unless the operator asked
Task 2: GitHub latest-release selection, cache throttle, no-downgrade
Files:
- Modify:
src/literate_ai/adapters/host_self_update.py - Modify:
tests/critical/test_host_self_update.py
Interfaces:
Consumes:
HostSelfUpdateEnrollment,literate_ai.version.DISTRIBUTION_VERSIONProduces:
@dataclass(frozen=True, slots=True) class HostSelfUpdatePlan: tag, version, wheel_name, wheel_url: strdef select_github_release_wheel(payload: Mapping[str, object], *, installed_version: str) -> HostSelfUpdatePlan | Nonedef cache_allows_github_check(cache: Mapping[str, object], *, now: float) -> boolSUCCESS_CACHE_SECONDS = 86400FAILURE_RETRY_SECONDS = 900- Cache document keys:
checked_at(unix float),ok(bool),tag,version
Step 1: Write failing selection/throttle tests
Use a fixture payload shaped like GitHub's latest-release JSON: tag_name, draft, prerelease, assets: [{name, browser_download_url}]. Cases:
v0.12.0wheel selected when installed is0.11.0.- Returns
Nonewhen tag is0.11.0or older (no downgrade / no equal). - Returns
Nonewhendraftorprereleaseis true (defense in depth;/releases/latestalready skips them). - Returns
Nonewhen noliterate_ai-*-py3-none-any.whlasset exists. cache_allows_github_checkis false whenokand age < 24h, or when a staged wheel for a still-newer version exists (callers pass that asokcache or a separatestaged_version); true when last check failed and age >= 15 minutes; false when last check failed and age < 15 minutes.
Compare versions with packaging.version.Version. Strip a leading v from tag_name. Invalid versions return None.
Step 2: Run tests; expect FAIL on missing functions
Step 3: Implement
select_github_release_wheelandcache_allows_github_check
Do not perform I/O in these functions.
- Step 4: Re-run tests; expect PASS
Task 3: Background worker stages a wheel without touching the live venv
Files:
- Modify:
src/literate_ai/adapters/host_self_update.py - Modify:
tests/critical/test_host_self_update.py
Interfaces:
Consumes: Task 2 types,
urllib.requestinjected as a fetch callableProduces:
def stage_github_wheel(enrollment, *, fetch, now: float, installed_version: str, environ: Mapping[str, str]) -> Nonedef spawn_self_update_worker(enrollment, argv_executable: Path) -> None- Fetch callable:
(url: str, headers: Mapping[str, str], timeout: int) -> bytesfor JSON; a second write-to-path helper for the wheel - GitHub timeout 10 seconds
- User-Agent
literate-ai - If
GH_TOKENorGITHUB_TOKENis set, sendAuthorization: Bearer <token>; never include the token in logs or cache files - Worker log:
enrollment.staging_root / "worker.log" - Staged wheel:
enrollment.staging_root / wheel_name - Cache file:
enrollment.staging_root / "cache.json" - Worker lock file:
enrollment.staging_root / "worker.lock"
Step 1: Write failing staging tests
- Given a newer fixture release,
stage_github_wheelwritescache.jsonand the wheel bytes, and does not modifylayout.environmentorlayout.launcher. - If
cache_allows_github_checkis false, fetch is not called. - Fetch errors write
ok: falsecache and do not raise. spawn_self_update_workerusessubprocess.Popenwithstart_new_session=Trueon POSIX (andCREATE_NEW_PROCESS_GROUPon Windows), stdout/stderr redirected toworker.log, and does notwait(). Ifworker.lockis already held, it does not start a second process (inject lock).
Worker argv (so the child does not need the operator command):[sys.executable, "-m", "literate_ai.adapters.host_self_update", "--stage", str(enrollment.prefix)]
The module if __name__ == "__main__" path only stages; it never applies.
Step 2: Run tests; expect FAIL
Step 3: Implement staging + spawn
Use urllib.request.Request like host_install._download_artifact, timeout 10. Catch OSError and urllib.error.URLError. Atomic replace for cache and wheel (*.tmp then os.replace).
- Step 4: Re-run tests; expect PASS
Task 4: Apply staged wheel, non-blocking lock, re-exec, fail open
Files:
- Modify:
src/literate_ai/adapters/host_self_update.py - Modify:
tests/critical/test_host_self_update.py
Interfaces:
Consumes: enrollment, staged wheel,
DISTRIBUTION_VERSIONProduces:
def maybe_host_self_update(argv: Sequence[str], *, environ: Mapping[str, str] | None = None, executable: Path | None = None, stderr: TextIO | None = None) -> Nonedef apply_staged_wheel(enrollment, *, argv: Sequence[str], installed_version: str, runner, exec_fn, stderr) -> None- Non-blocking exclusive lock on
enrollment.staging_root / "apply.lock". Busy → return without applying. - Pip:
[str(venv_python), "-m", "pip", "--disable-pip-version-check", "install", "--upgrade", "--force-reinstall", str(wheel)]withtimeout=120, env fromscripts/install_litai.py:_install_environment(or a shared helper — do not copy PYTHON* leakage).--no-indexplus--find-linkson the staging dir is acceptable if a lone wheel path is insufficient on that pip. - After pip: refresh the stable launcher from templates kept in
host_self_update.pyasPOSIX_LAUNCHERandWINDOWS_LAUNCHERstring constants. A unit test asserts those constants equalscripts/litai-launcherandscripts/litai-launcher.cmdbytes (the scripts remain the edited source; constants are updated in the same change). Write the launcher, chmod 0755 on POSIX, then rewrite the@2manifest. - Rewrite
@2manifest for the same prefix. - Print
litai: updated PREFIX from INSTALLED to NEWon stderr. exec_fn([str(launcher), *argv], environ | {REEXEC_ENVIRONMENT: "1"})usingos.execvpeon POSIX. On Windows,subprocess.callthe.cmdwith the same env andraise SystemExit(returncode)becauseexecvof.cmdis unreliable.- Failures: write a warning to stderr, return, do not exec.
Step 1: Write failing apply tests
- Staged newer wheel → runner called with local wheel path;
exec_fncalled with originalargvandLITAI_SELF_UPDATE_REEXEC=1. - Equal/older staged version → no pip, no exec.
- Lock busy → no pip, no exec.
- Runner non-zero → warning, no exec, current files otherwise left as pip left them (do not invent rollback beyond "do not exec").
maybe_host_self_updatewith no enrollment → no spawn, no apply.
Inject runner and exec_fn; do not call real pip or exec in unit tests.
Step 2: Run tests; expect FAIL
Step 3: Implement apply +
maybe_host_self_updateorchestration
Order in maybe_host_self_update:
- Resolve enrollment (None → return).
- Try apply if a staged wheel is newer (non-blocking lock).
- If still in this process (no exec), spawn worker unless worker lock is held.
- Step 4: Re-run tests; expect PASS
Task 5: Hook dispatch.main before argparse
Files:
- Modify:
src/literate_ai/cli/dispatch.py(mainat line 2697) - Modify:
tests/critical/test_host_self_update.pyor a smalltests/unit/test_cli_self_update_hook.py
Interfaces:
Consumes:
maybe_host_self_updateProduces: every CLI invocation, including
help/--help, goes through the hookStep 1: Write a test that patches
maybe_host_self_updateand invokesmain(["help"])
Assert the hook was called with ["help"] (or the normalized argv main actually receives). help must still return 0.
Step 2: Run test; expect FAIL (hook missing)
Step 3: At the top of
main, before_normalize_help_arguments:
from literate_ai.adapters.host_self_update import maybe_host_self_update
raw = sys.argv[1:] if argv is None else list(argv)
maybe_host_self_update(raw)
Pass the same argv the caller provided so tests that inject argv= still work. Do not pass sys.argv when argv is provided.
- Step 4: Re-run; expect PASS. Smoke
PYTHONPATH=src python3 -m literate_ai.cli helpstill works from a checkout (inert).
Task 6: Uninstall @2 and remove staging
Files:
- Modify:
src/literate_ai/adapters/host_uninstall.py - Modify:
tests/critical/test_host_uninstall.py
Interfaces:
Consumes: both manifest schemas
Produces: uninstall of
@1(legacy four fields) and@2(self_update: true); staging dirapplication_root/self-updateis removed with the application rootStep 1: Extend tests
Keep the existing @1 fixture working. Add an @2 fixture with a self-update/cache.json file; after uninstall the staging dir and venv are gone. A @2 document missing self_update or with extra unknown keys still fails closed.
Replace document != expected with a field-wise check: required keys match layout; schema is @1 or @2; if @2, self_update is True.
Step 2: Run
tests.critical.test_host_uninstall; expect FAIL on@2Step 3: Implement dual-schema planning; rmtree staging as part of application_root cleanup (already happens if the whole application_root is removed — ensure staging does not prevent
rmdir. Today uninstall removes launcher, rmtree environment, unlink manifest, then rmdir application_root if empty. Staging would block that rmdir. Explicitlyshutil.rmtree(application_root / "self-update")before the empty-root rmdir, or rmtree application_root only if it contains solely owned children. Prefer: remove known owned children (venv, manifest, self-update) then rmdir if empty. Do not recursively delete unknown files in application_root.Step 4: Re-run uninstall tests; expect PASS
Task 7: install-check stays GitHub-silent
Files:
- Modify:
scripts/installed_project_smoke.py - Modify:
scripts/installed_e2e_gate.pyif it invokes a prefix-installed launcher - Modify:
scripts/installed_roundtrip.pyif applicable
Interfaces:
Produces: those drivers set
environment["LITAI_NO_SELF_UPDATE"] = "1"before invoking the installed launcherStep 1: Add a unit or script-level assertion that the smoke environment includes
LITAI_NO_SELF_UPDATE=1(extract the env-building helper if needed so a test can call it). If extracting is too large, set the variable in the existingenvironment = dict(os.environ)block around line 597 ofinstalled_project_smoke.pyand add a comment that ADR 0037 requires it. A focused test can read the file or test a tiny helper_installed_launcher_environment(...).Step 2: Implement the env assignment in every driver that execs a prefix-installed
litai.Step 3:
make install-checkis evidence later; do not run it until Tasks 1–6 pass unit tests.
Task 8: Operator docs and changelog
Files:
- Modify:
docs/user/installation.md - Modify:
CHANGELOG.md(## Unreleased) - Modify: this file's owning queue item checkboxes when closing
Interfaces: none
Step 1: In Installation and first run, after the default prefix layout paragraph, add that a successful
make installenrolls that prefix for GitHub Release self-update (ADR 0037): checks run in the background; the next invocation may replace the private venv and re-exec the same argv; opt out withLITAI_NO_SELF_UPDATE=1; CI is skipped;litai updatestill means project files.Step 2: Soften "Agents must obtain user authorization before installing or upgrading any host tool" so it still applies to compilers and package managers, while prefix self-update is standing authorization for that
make installprefix only.Step 3: Add one Unreleased changelog bullet naming prefix-installed GitHub self-update and ADR 0037.
Step 4: Run
make python-check(andmake documentation-checkif docs-index validation requires it).
Spec coverage
| ADR 0037 rule | Task |
|---|---|
Prefix-only identity (launcher flag + @2 + venv python) |
1 |
@1 / checkout / CI / opt-out / evidence / re-exec inert |
1, 5 |
| GitHub latest non-prerelease wheel; any newer version; no downgrade | 2 |
| Background check; 24h / 15m throttle; never delay this command for GitHub | 3, 5 |
| Stage only; apply next invoke; re-exec argv | 3, 4 |
| Non-blocking lock; fail open | 4 |
Uninstall @2 + staging |
6 |
| install-check silent | 7 |
Docs vs litai update; standing prefix authorization |
8 |
| No new subcommand | (none added) |
| Tokens never logged | 3 |
Out of scope
PyPI, git rebuilds, pipx/Homebrew/apt/WinGet copies, a litai self-update verb, applying under a running command, 0.10.x backport.