diff --git a/.gitignore b/.gitignore
index 62b4617..6c5b848 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,4 @@ __pycache__/
/graph/graph
/graph/graph.exe
/dist/
+/.docket-managed
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bbe42b0..e67a340 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- `docket update` refreshes an installer-managed checkout, or prints the
+ plugin manager command for a plugin-only install. `installer/install.py
+ --update` now refreshes an installed Claude Code or Codex plugin as well,
+ instead of leaving harness configuration untouched.
+- `docket` checks for a newer release once a day, in a detached background
+ process, and prints a notice above the context briefing when one is
+ available. `DOCKET_NO_UPDATE_CHECK=1` disables the check and the notice.
+
## [0.10.0] - 2026-09-13
### Added
diff --git a/README.md b/README.md
index 4ba2ebe..4596781 100644
--- a/README.md
+++ b/README.md
@@ -10,22 +10,20 @@ stays available.
## Install
-Requires **Python 3.11+**, with no Python package dependencies.
+Requires **Python 3.11+** and Git. Go 1.26+ is needed only when building from a
+source checkout.
-```sh
-curl -fsSLO https://raw.githubusercontent.com/NovusEdge/docket/main/installer/install.py
-python3 install.py
-```
+Ask your agent to handle setup:
-In an interactive terminal, the installer guides you through setup and asks you
-to confirm its plan before applying it.
-It supports Claude Code, Codex, Gemini CLI, Cursor, GitHub Copilot CLI, and
-OpenCode.
+> Set up Docket for the agent I am using in this project. Follow
+> https://raw.githubusercontent.com/NovusEdge/docket/main/docs/agent-setup.md.
+> Check the installation and tell me how to start using it.
-Start a new agent session after installation. Check the installed version with
-`docket --version`.
+Or [choose your agent and install it yourself](docs/installation.md#configure-an-agent-harness).
+Claude Code and Codex have plugin install commands. The guided installer prepares
+the terminal command, graph viewer, and selected agent integrations.
-[Installation, updates, and uninstall](docs/installation.md) ·
+[Requirements and setup](docs/installation.md) ·
[Your first decision](docs/quickstart.md) ·
[Read the docs](https://novusedge0.gitbook.io/docket-docs/)
diff --git a/bin/docket b/bin/docket
index c1c925e..1b66883 100755
--- a/bin/docket
+++ b/bin/docket
@@ -12,6 +12,7 @@ import subprocess
import sys
import tempfile
import textwrap
+import time
from pathlib import Path
_LIB_DIR = Path(__file__).resolve().parent.parent / "lib"
@@ -1147,13 +1148,31 @@ def auto_scope_files(limit: int = _AUTO_SCOPE_LIMIT) -> tuple[str, ...]:
return tuple(paths[:limit])
-def _print_context(text: str, args: argparse.Namespace) -> int:
- if not text:
+def update_line() -> str | None:
+ """One notice line, or None. Never performs a network request."""
+ from docket_update import disabled, due, notice, read_state, spawn_fetch
+
+ try:
+ if disabled():
+ return None
+ root = Path(__file__).resolve().parent.parent
+ state = read_state()
+ if due(state, time.time()):
+ spawn_fetch(Path(__file__).resolve())
+ return notice(version(), str(state.get("latest", "")), root)
+ except Exception:
+ return None
+
+
+def _print_context(text: str, args: argparse.Namespace,
+ notice: str | None = None) -> int:
+ body = f"{notice}\n{text}" if notice else text
+ if not body:
return 0
if args.for_harness:
- print(json.dumps(CONTEXT_ENVELOPES[args.for_harness](text)))
+ print(json.dumps(CONTEXT_ENVELOPES[args.for_harness](body)))
else:
- print(text, end="")
+ print(body, end="")
return 0
@@ -1165,6 +1184,7 @@ def cmd_context(args: argparse.Namespace) -> int:
except ConfigError as exc:
print(f"docket: {exc}", file=sys.stderr)
return 1
+ line = update_line()
# Validate against the loaded minimum, not a literal. A config that raises
# budget.minimum would otherwise let a too-small value through and surface
# as an uncaught ValueError from the renderer.
@@ -1189,7 +1209,7 @@ def cmd_context(args: argparse.Namespace) -> int:
max_chars=args.max_chars, ledger=str(ledger_path()),
settings=settings)
if delta is not None:
- return _print_context(delta, args)
+ return _print_context(delta, args, line)
print(f"docket: baseline {args.since} is unknown or stale; "
"printing a full briefing", file=sys.stderr)
@@ -1213,7 +1233,7 @@ def cmd_context(args: argparse.Namespace) -> int:
except (LedgerError, OSError) as exc:
print(str(exc), file=sys.stderr)
return 1
- return _print_context(text, args)
+ return _print_context(text, args, line)
_COMPLETION_FLAGS = (
@@ -1223,9 +1243,9 @@ _COMPLETION_FLAGS = (
"--auto-scope", "--no-auto-scope", "--since", "--at",
"--all", "--find", "--superseded", "--oneline", "--json", "--plain", "--pretty",
"--style", "--interactive", "--no-interactive", "--for", "--version",
- "--dry-run",
+ "--dry-run", "--check",
)
-_COMPLETION_CMDS = ("claim", "decision", "question", "list", "show", "graph", "context", "where", "check", "rebase", "migrate", "init", "completion")
+_COMPLETION_CMDS = ("claim", "decision", "question", "list", "show", "graph", "context", "where", "check", "rebase", "migrate", "init", "completion", "update")
_BASH_COMPLETION = f"""\
_docket() {{
@@ -1294,6 +1314,79 @@ def cmd_completion(args: argparse.Namespace) -> int:
return 0
+LAUNCHER_URL_TEMPLATE = (
+ "https://raw.githubusercontent.com/NovusEdge/docket/refs/tags/{tag}/"
+ "installer/install.py"
+)
+MAIN_LAUNCHER_URL = (
+ "https://raw.githubusercontent.com/NovusEdge/docket/main/installer/install.py"
+)
+
+
+def cmd_update(args: argparse.Namespace, root: Path | None = None) -> int:
+ from docket_update import is_newer, parse_version, read_state, shape, update_command
+
+ root = root or Path(__file__).resolve().parent.parent
+ running = version()
+ latest = str(read_state().get("latest", ""))
+ if args.check:
+ if not latest or parse_version(latest) is None:
+ print("docket: no cached release information yet")
+ return 2
+ if is_newer(latest, running):
+ print(f"docket {latest.lstrip('v')} is available (running {running})")
+ return 1
+ print(f"docket {running} is up to date")
+ return 0
+
+ kind = shape(root)
+ if kind == "plugin":
+ print(f"docket: this copy is managed by your harness. "
+ f"Run: {update_command(root)}")
+ return 0
+ if kind == "unknown":
+ print(f"docket: this copy has no installer and no repository. "
+ f"Run: {update_command(root)}")
+ return 0
+ if kind == "source":
+ command = [sys.executable, str(root / "installer" / "install.py"),
+ "--checkout", str(root), "--update"]
+ print(" ".join(command))
+ return subprocess.call(command)
+ tag = latest if latest and parse_version(latest) is not None else None
+ return _run_downloaded_update(tag)
+
+
+def _run_downloaded_update(tag: str | None) -> int:
+ """Fetch the launcher and run it outside the checkout.
+
+ The bundled launcher takes its own checkout branch, which needs Go and
+ passes --checkout, and --checkout makes the planner skip the git update.
+ Without a cached release tag, fall back to the main branch so a fresh
+ install (no cache populated yet) can still update.
+ """
+ from urllib.request import urlopen
+
+ url = LAUNCHER_URL_TEMPLATE.format(tag=tag) if tag else MAIN_LAUNCHER_URL
+ with tempfile.TemporaryDirectory() as work:
+ launcher = Path(work) / "install.py"
+ try:
+ with urlopen(url, timeout=30) as response:
+ launcher.write_bytes(response.read())
+ except OSError as exc:
+ print(f"docket: could not download the installer: {exc}", file=sys.stderr)
+ return 1
+ command = [sys.executable, str(launcher), "--update"]
+ print(" ".join(command))
+ return subprocess.call(command, cwd=work)
+
+
+def cmd_update_fetch(args: argparse.Namespace) -> int:
+ from docket_update import run_fetch
+
+ return run_fetch(time.time())
+
+
def _add_shared_args(p: argparse.ArgumentParser) -> None:
p.add_argument("--scope", action="append", default=[])
p.add_argument("--rationale", default="")
@@ -1315,7 +1408,9 @@ def main(argv: list[str] | None = None) -> int:
)
p.add_argument("-h", "--help", action=HelpAction, nargs=0, help="show this help and exit")
p.add_argument("--version", action=VersionAction, nargs=0, help="print the release and exit")
- sub = p.add_subparsers(dest="cmd")
+ sub = p.add_subparsers(dest="cmd", metavar=(
+ "{claim,decision,question,list,show,graph,context,where,check,"
+ "rebase,migrate,init,completion,update}"))
cl = sub.add_parser("claim", help="record a proposition")
cl.add_argument("text")
@@ -1418,6 +1513,13 @@ def main(argv: list[str] | None = None) -> int:
co.add_argument("shell", choices=("bash", "zsh", "fish"))
co.set_defaults(func=cmd_completion)
+ ud = sub.add_parser("update", help="update this Docket installation")
+ ud.add_argument("--check", action="store_true",
+ help="report whether an update is available; change nothing")
+ ud.set_defaults(func=cmd_update)
+
+ sub.add_parser("_update-fetch").set_defaults(func=cmd_update_fetch)
+
args = p.parse_args(argv)
if args.cmd is None:
print(f"docket {version()}")
diff --git a/docs/README.md b/docs/README.md
index 2756238..89b10a9 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -15,7 +15,8 @@ record things for you.
| I want to… | Read |
|---|---|
-| Set up Docket | [Installation](installation.md) |
+| Ask my agent to set up Docket | [Copy the setup prompt](installation.md#let-your-agent-handle-setup) |
+| Install Docket myself | [Choose an install route](installation.md#configure-an-agent-harness) |
| Try it in a project | [Your first decision](quickstart.md) |
| Save a choice or leave a question for later | [Recording decisions](recording.md) |
| Find an earlier decision | [Reading your ledger](reading.md) |
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
index 42d0d3a..4e657f5 100644
--- a/docs/SUMMARY.md
+++ b/docs/SUMMARY.md
@@ -4,7 +4,7 @@
## Getting started
-* [Installation](installation.md)
+* [Set up Docket](installation.md)
* [Your first decision](quickstart.md)
## Everyday use
@@ -20,6 +20,7 @@
* [Ledger format and rules](ledger.md)
* [Installer options and behavior](installer-reference.md)
* [Manual agent setup](integrations.md)
+* [Setup instructions for agents](agent-setup.md)
## Design and research
diff --git a/docs/agent-setup.md b/docs/agent-setup.md
new file mode 100644
index 0000000..4aea6a5
--- /dev/null
+++ b/docs/agent-setup.md
@@ -0,0 +1,140 @@
+# Setup instructions for agents
+
+Use these instructions when a user asks you to set up Docket. The default setup
+installs the terminal command, native graph viewer, and integration for the agent
+the user is running.
+
+For a plugin-only request, use the relevant
+[Claude Code or Codex instructions](installation.md#configure-an-agent-harness)
+instead. Plugin installation does not provide a shell command or compiled viewer.
+
+## 1. Inspect the environment
+
+Identify the current agent from the session's tools and configuration. Do not
+select every installed agent. If the current agent is unclear, ask which one
+the user wants to configure.
+
+Check the operating system and architecture, Git, and Python. Docket requires
+Python 3.11 or later. The launcher supports Linux, macOS, and Windows on amd64
+and arm64. Go is not required for the downloaded installation.
+
+Check whether Docket is already installed. Inspect the command location and
+version before adding another copy. Keep any existing custom installation paths.
+
+Use the user's project as the working directory. Setup does not require
+`docket init`; create a shared project ledger only when the user asks for one.
+
+## 2. Download the launcher
+
+On Linux or macOS, download into a temporary folder:
+
+```sh
+docket_setup_dir="$(mktemp -d)" &&
+curl -fsSL https://raw.githubusercontent.com/NovusEdge/docket/main/installer/install.py \
+ -o "$docket_setup_dir/install.py"
+```
+
+On Windows, use PowerShell:
+
+```powershell
+$docketSetupDir = Join-Path ([System.IO.Path]::GetTempPath()) ("docket-setup-" + [guid]::NewGuid())
+New-Item -ItemType Directory -Path $docketSetupDir -ErrorAction Stop | Out-Null
+$docketSetupScript = Join-Path $docketSetupDir "install.py"
+Invoke-WebRequest https://raw.githubusercontent.com/NovusEdge/docket/main/installer/install.py -OutFile $docketSetupScript -ErrorAction Stop
+```
+
+Keep the resulting path for subsequent commands. If your execution tool starts
+a fresh shell for each call, use that absolute path instead of assuming the
+variable survives into the next call.
+
+Read the downloaded `install.py` before running it. It fetches a native installer
+and verifies its checksum against the release's `SHA256SUMS`. Checksum validation
+checks consistency with that release manifest; it is not a separate signature.
+
+Keep the launcher outside a Docket source checkout. The native installer creates
+the permanent checkout itself. Cloning the repository into a temporary folder
+and running its installer would select a source build, require Go, and leave
+installation paths pointing at a temporary checkout.
+
+## 3. Select the integration and review the plan
+
+Use the integration name for the current agent:
+
+| Agent | Installer value |
+|---|---|
+| Claude Code | `claude-code` |
+| Codex | `codex` |
+| Gemini CLI | `gemini` |
+| Cursor | `cursor` |
+| GitHub Copilot CLI | `copilot` |
+| OpenCode | `opencode` |
+
+For example, to inspect a Gemini CLI setup on Linux or macOS:
+
+```sh
+python3 "$docket_setup_dir/install.py" --harness gemini --dry-run
+```
+
+Substitute the current agent's value. On Windows, use
+`py -3 $docketSetupScript` in place of `python3 "$docket_setup_dir/install.py"`.
+
+Review the permanent checkout location, command location, PATH change, and agent
+configuration. Add `--dir` and `--prefix` when the user has chosen custom paths.
+
+The installer writes user-level integration by default. `--project` changes the
+Claude skill and Cursor rule location, but hook configuration remains user-level.
+Do not describe it as a fully project-local installation.
+
+Passing `--harness` skips the interactive setup screen. The subsequent apply
+command uses defaults without another prompt, so inspect the dry run first.
+Follow the user's existing authorization and the environment's approval rules.
+If the plan goes beyond what they asked for, explain the additional change.
+
+## 4. Apply the reviewed setup
+
+Run the same command without `--dry-run`:
+
+```sh
+python3 "$docket_setup_dir/install.py" --harness gemini
+```
+
+Repeat `--harness` only when the user requested several integrations.
+
+For OpenCode, also follow the
+[manual plugin file placement step](integrations.md#opencode). Check existing
+files before moving anything. Keep one active plugin and report that the manually
+placed file needs separate maintenance.
+
+If an installation already exists and the user only wants an update, use
+`--update --dry-run`, followed by `--update`. Do not combine `--update` with
+`--harness`. Use the downloaded launcher for an installer-managed checkout;
+source-checkout updates have different behavior.
+
+## 5. Verify and report
+
+Run the installed command by its full path if the current shell has not picked
+up the PATH change yet:
+
+```sh
+docket --version
+docket where
+docket check
+docket context
+```
+
+Run ledger checks from the user's project. An empty project can have no ledger
+yet and produce no context; do not add sample records to make a check pass.
+
+Confirm that the viewer binary exists at the path used by the installation.
+Check the integration files or plugin registration without overwriting unrelated
+settings. Do not treat a successful installation as proof that the current
+conversation has refreshed its context.
+
+Tell the user:
+
+- Where Docket was installed and which integration was configured.
+- Which checks passed and any step that still needs attention.
+- To start a new agent session in the project and ask it to read Docket context.
+
+The temporary launcher folder can be removed after setup. Keep the permanent
+checkout and the user's ledger files.
diff --git a/docs/agents.md b/docs/agents.md
index 0433ce1..14f9de6 100644
--- a/docs/agents.md
+++ b/docs/agents.md
@@ -6,11 +6,10 @@ across sessions.
## Connect your agent
-Run the [installer](installation.md) and select the agent tools you use. It
-supports Claude Code, Codex, Gemini CLI, GitHub Copilot CLI, Cursor, and OpenCode.
-
-For OpenCode, follow the [manual setup note](integrations.md#opencode) after
-installation to put the plugin where OpenCode can load it.
+Use the [setup prompt](installation.md#let-your-agent-handle-setup) to let your
+agent install Docket and configure its integration. If you prefer manual setup,
+the [installation guide](installation.md#configure-an-agent-harness) has a route
+for Claude Code, Codex, Gemini CLI, GitHub Copilot CLI, Cursor, and OpenCode.
After setup, start a new session in your project. Ask:
diff --git a/docs/commands.md b/docs/commands.md
index 5bce9c2..dae66ee 100644
--- a/docs/commands.md
+++ b/docs/commands.md
@@ -18,6 +18,7 @@ Ledger commands use the file that `docket where` reports. Run
| `docket init` | Create a project ledger and copy any existing private records into it |
| `docket migrate` | Convert a pre-0.8 ledger to the current schema |
| `docket rebase` | Renumber another branch's records onto this ledger |
+| `docket update` | Update this Docket installation; `--check` reports without changing anything |
| `docket completion SHELL` | Print a shell completion script |
## Recording
diff --git a/docs/installation.md b/docs/installation.md
index 93e5688..b1cba81 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -1,51 +1,213 @@
-# Installation
+# Set up Docket
-Install Docket, choose the agent tools you want to connect, and check that the
-command works.
+Docket works with Claude Code, Codex, Gemini CLI, GitHub Copilot CLI, Cursor, and
+OpenCode. You can ask your agent to set it up or follow the steps for your tool.
-You need **Python 3.11 or later**, Git, and an internet connection for the
-download. Docket has no Python package dependencies. A normal downloaded
-installation does not require Go.
+## Requirements
+
+| Install route | What you need |
+|---|---|
+| Ask your agent to set up Docket | Python 3.11+, Git, and an internet connection |
+| Install the Claude Code or Codex plugin | That agent tool, Python 3.11+ available as `python3`, and Git |
+| Run the downloaded installer | Python 3.11+, Git, and an internet connection |
+| Build from a Git checkout | Python 3.11+, Git, and Go 1.26+ |
+
+Docket has no Python package dependencies. The downloaded installer uses prebuilt
+binaries when Go is absent, so you do not need Go for that route. The Unix
+download example also uses `curl`; Windows uses PowerShell.
+
+## Let your agent handle setup
+
+Expand the prompt below, copy it, and paste it into your agent:
+
+
+
+Copy setup prompt
+
+```text
+Set up Docket for the agent I am using in this project. Install the
+terminal command, native graph viewer, and integration for this agent.
+
+1. Identify the current agent, operating system, and architecture.
+ Check for Python 3.11+ and Git. The downloaded installer does not
+ require Go. Building from a source checkout requires Go 1.26+.
+ If a requirement is missing, tell me what is needed.
+
+2. Check whether Docket is already installed. Reuse its existing paths
+ and preserve my configuration and ledger. Configure only the agent
+ I am using. Ask which agent to configure if you cannot identify it.
+
+3. Download the launcher below into a temporary directory outside any
+ Docket source checkout, then read it before running it:
+ https://raw.githubusercontent.com/NovusEdge/docket/main/installer/install.py
+
+ Let the installer create the permanent checkout. Do not install
+ from a temporary clone or leave installed paths pointing into a
+ temporary directory.
+
+4. Use the launcher with --harness and the value for my agent:
+ Claude Code: claude-code
+ Codex: codex
+ Gemini CLI: gemini
+ Cursor: cursor
+ GitHub Copilot CLI: copilot
+ OpenCode: opencode
+
+ Run it with Python 3.11+ from my project directory. On Linux or
+ macOS, use python3; on Windows, use a suitable Python command such
+ as py -3. Use the launcher's absolute path between shell calls.
+
+5. Run with --dry-run first. Review the checkout location, command
+ location, PATH changes, and agent configuration. Preserve custom
+ paths with --dir and --prefix where needed. The default integration
+ is user-level. Apply the reviewed setup by running the same command
+ without --dry-run, following the environment's approval rules.
+
+6. Check that the agent can discover its integration. For OpenCode,
+ check plugin placement against the installed OpenCode version and
+ keep one active copy. Preserve unrelated settings and report any
+ manual step that remains.
+
+7. From my project, run docket --version, docket where, docket check,
+ and docket context. Use the installed command's full path if PATH
+ has not refreshed. Confirm that the native viewer binary exists.
+ An empty project may have no ledger or context yet. Do not create
+ sample records or run docket init unless I ask for a shared ledger.
+
+8. Tell me where Docket was installed, which integration was configured,
+ and which checks passed. Explain anything that still needs attention.
+ Remind me to start a new agent session in this project and ask it
+ to read Docket context. Remove only the temporary launcher files
+ created for this setup.
+```
+
+
+
+The prompt covers environment checks, installation, and verification. The
+[setup reference for agents](agent-setup.md) has additional platform examples.
+
+After setup, start a new agent session in your project. You can then ask it to
+record decisions or follow [Your first decision](quickstart.md).
+
+## Configure an agent harness
+
+If you prefer to do the setup yourself, choose your tool:
+
+| Your tool | Setup |
+|---|---|
+| Claude Code | [Install the Claude Code plugin](#claude-code) |
+| Codex | [Install the Codex plugin](#codex) |
+| Gemini CLI | [Use the installer and select Gemini CLI](#gemini-cli-cursor-and-github-copilot-cli) |
+| Cursor | [Use the installer and select Cursor](#gemini-cli-cursor-and-github-copilot-cli) |
+| GitHub Copilot CLI | [Use the installer and select Copilot](#gemini-cli-cursor-and-github-copilot-cli) |
+| OpenCode | [Install and place the OpenCode plugin](#opencode) |
+
+### Claude Code
+
+Run these commands inside Claude Code:
+
+```text
+/plugin marketplace add NovusEdge/docket
+/plugin install docket@NovusEdge
+```
+
+The plugin includes the Docket skill and session hook. Its Python command must
+be available as `python3`. Start a new session, then ask Claude to read the
+Docket context.
+
+See [Claude Code's plugin guide](https://code.claude.com/docs/en/discover-plugins)
+for plugin management. If you also want `docket` in your terminal and the native
+graph viewer, use [the installer](#the-installer).
+
+### Codex
+
+Run these commands in a terminal with the Codex CLI installed:
+
+```sh
+codex plugin marketplace add NovusEdge/docket
+codex plugin add docket@NovusEdge
+```
+
+Start a new Codex task in your project, then ask it to read the Docket context.
+The plugin includes the skill and session hook. Its hook uses `python3`.
+
+The plugin alone does not add `docket` to your shell's PATH or install a compiled
+graph viewer. Use [the installer](#the-installer) if you want those as well.
+
+### Gemini CLI, Cursor, and GitHub Copilot CLI
+
+Run [the installer](#the-installer) and select your tool in the setup screen.
+You can select more than one if you use several agents.
+
+The installer adds the command, prepares the graph viewer, and writes the
+selected integrations. Start a new agent session after it finishes.
+
+Manual hook configuration is in the [agent setup reference](integrations.md).
+
+### OpenCode
+
+Run [the installer](#the-installer) and select OpenCode. Then complete the
+[plugin file placement step](integrations.md#opencode) so OpenCode can find it.
+
+Start a new session after setup and ask OpenCode to read your Docket context.
## The installer
+Use the installer for the terminal command, graph viewer, and agent integrations
+in one setup. It downloads the release for your platform and clones Docket into
+a permanent directory. You do not need to clone the repository yourself or
+install Go.
+
### Linux and macOS
-Run these commands in a folder outside an existing Docket source checkout:
+Download the launcher into a temporary folder:
```sh
-curl -fsSLO https://raw.githubusercontent.com/NovusEdge/docket/main/installer/install.py
-python3 install.py
+docket_setup_dir="$(mktemp -d)" &&
+curl -fsSL https://raw.githubusercontent.com/NovusEdge/docket/main/installer/install.py \
+ -o "$docket_setup_dir/install.py"
```
-This saves `install.py` in the current folder and runs it. The launcher downloads
-the installer for your platform and checks its release checksum.
+Then run it from the same terminal:
+
+```sh
+python3 "$docket_setup_dir/install.py"
+```
+
+The launcher checks the native installer's release checksum before running it.
+In an interactive terminal, setup lets you select agent tools, review any PATH
+change, and confirm the plan.
+
+The temporary folder holds only the launcher. The installation normally lives
+at `~/.local/share/docket`, with the command in `~/.local/bin`. If
+`XDG_DATA_HOME` is set, the checkout defaults to `$XDG_DATA_HOME/docket`.
+You can change either location during setup.
### Windows
-In PowerShell, download the same file and run it with Python:
+In PowerShell, download the launcher to a new temporary folder:
```powershell
-Invoke-WebRequest https://raw.githubusercontent.com/NovusEdge/docket/main/installer/install.py -OutFile install.py
-py -3 install.py
+$docketSetupDir = Join-Path ([System.IO.Path]::GetTempPath()) ("docket-setup-" + [guid]::NewGuid())
+New-Item -ItemType Directory -Path $docketSetupDir -ErrorAction Stop | Out-Null
+$docketSetupScript = Join-Path $docketSetupDir "install.py"
+Invoke-WebRequest https://raw.githubusercontent.com/NovusEdge/docket/main/installer/install.py -OutFile $docketSetupScript -ErrorAction Stop
```
-If you use `python` instead of the `py` launcher, run `python install.py`.
-Make sure it selects Python 3.11 or later.
+Then run it:
-### Follow the setup
-
-In an interactive terminal, the installer lets you choose the command location
-and agent tools. Review any proposed PATH change and the installation plan, then
-confirm it.
+```powershell
+py -3 $docketSetupScript
+```
-The installer adds the `docket` command and prepares the graph viewer. Keep the
-downloaded `install.py` file if you want to use it for updates or removal later.
+If you use `python` instead of the `py` launcher, run
+`python $docketSetupScript`. It must select Python 3.11 or later.
-Custom locations and unattended setup are covered in the
-[installer reference](installer-reference.md#installer-options).
+The checkout normally lives at `%LOCALAPPDATA%\docket` on Windows.
+`XDG_DATA_HOME`, when set, takes precedence. Review the destination and User
+PATH change in the setup screen.
-## Check that it works
+### Check that it works
Open a new terminal and run:
@@ -55,99 +217,97 @@ docket where
```
The first command prints the installed version. The second prints the active
-ledger's location. It can say `not created yet` before you record anything or
-run `docket init`.
+ledger's path. It can say `not created yet` before you record anything or run
+`docket init`.
-Start a new agent session after installation so it can load the integration.
-
-Continue with [Your first decision](quickstart.md) to create a project ledger.
-
-## Configure an agent harness
-
-During setup, select the tools you use. Docket supports Claude Code, Codex,
-Gemini CLI, GitHub Copilot CLI, Cursor, and OpenCode.
-
-For OpenCode, complete the [plugin file placement step](integrations.md#opencode)
-after installation so it can find the integration.
-
-For daily use, read [Working with your agent](agents.md). For plugin commands,
-hook configuration, and instruction files, see the
-[agent setup reference](integrations.md).
+You can remove the temporary download folder after setup. Your installed
+command and agent integrations use the permanent checkout.
## Update, uninstall, and cleanup
-### Update a downloaded installation
+`docket` checks for a newer release once a day and prints a notice above your
+agent's context briefing when one exists, naming the command for your install
+shape. The check runs in a detached background process so it never delays a
+session start; the result is cached at
+`$XDG_STATE_HOME/docket/update.json` (`~/.local/state/docket/update.json` by
+default). Set `DOCKET_NO_UPDATE_CHECK=1` to disable both the check and the
+notice. Run `docket update --check` to ask directly, or `docket update` to
+apply it.
+
+For a plugin-only installation, `docket update` prints the plugin manager
+command for your harness rather than changing anything itself; run that
+command, or update through the harness yourself.
-From the folder containing the downloaded `install.py`, run:
+For an installer-managed installation, `docket update` is the short form.
+Equivalently, download the launcher again using the steps above and run:
```sh
-python3 install.py --update --dry-run
-python3 install.py --update
+python3 "$docket_setup_dir/install.py" --update --dry-run
+python3 "$docket_setup_dir/install.py" --update
```
-On Windows, use `py -3` in place of `python3`.
+On Windows, use `py -3 $docketSetupScript` in place of
+`python3 "$docket_setup_dir/install.py"`.
-The first command previews the update. The second updates the managed checkout
-and refreshes the graph viewer. Agent configuration and PATH settings stay as
-they are.
+The preview shows the proposed changes. The update refreshes the managed
+checkout and graph viewer, refreshes the docket plugin in any harness where
+it is also installed, and keeps agent configuration and PATH settings.
-If you installed in custom locations, use the same `--dir` and `--prefix`
-values. There is no `docket update` command.
-
-### Remove Docket
-
-Use the downloaded launcher:
+To remove an installer-managed installation:
```sh
-python3 install.py --uninstall
+python3 "$docket_setup_dir/install.py" --uninstall
```
-Use `py -3 install.py --uninstall` on Windows. Pass any custom locations you
-used during installation.
+Use the same `--dir` and `--prefix` values if you installed in custom locations.
+Uninstall keeps your ledgers.
-Uninstall removes Docket's integration and command files. It keeps your ledgers.
-See the [installer reference](installer-reference.md#update-uninstall-and-cleanup)
-for source-checkout updates and build cleanup.
+Use the downloaded launcher for these operations. Running `installer/install.py`
+inside the permanent checkout selects the source-build path and requires Go.
## Manual installation
-If you already have a Docket source checkout, run this from its root:
+If you want to manage the source with Git, clone it into a directory you will
+keep. This path requires **Go 1.26 or later** as well as Python:
```sh
-python3 installer/install.py
+git clone https://github.com/NovusEdge/docket.git ~/Projects/docket
+python3 ~/Projects/docket/installer/install.py
```
-This path builds from your checkout and requires Go 1.26 or later as well as
-Python. See [Source and manual setup](installer-reference.md#manual-installation)
-for the full steps.
+The installed command points into this checkout, so keep it in place. A clone
+under `/tmp` would stop working when the temporary directory is removed.
-## Browse the decision graph
+Update the checkout with Git yourself before running its installer with
+`--update`. That command rebuilds from the source you have; it does not fetch
+updates for a source checkout.
-After you have recorded something, run:
+The [installer reference](installer-reference.md) covers custom locations,
+unattended setup, and build cleanup.
-```sh
-docket graph
-```
+## Browse the decision graph
-Select records in the tree to read their details. The
-[reading guide](reading.md#browse-connected-records) explains the controls.
+After recording something, run `docket graph`. With the native viewer installed,
+you can select records in a tree and read their details. See
+[Reading your ledger](reading.md#browse-connected-records) for the controls.
-If you installed only the agent plugin, the viewer may be absent. Docket then
-shows a text graph with setup guidance. Run the installer to prepare the viewer.
+A plugin-only installation uses a text graph unless you separately prepare the
+viewer. Your agent can run the bundled command even when `docket` is not on
+your shell's PATH.
## If setup fails
| What you see | What to check |
|---|---|
| Python version error | Run `python3 --version` or `py -3 --version` and confirm it is at least 3.11 |
-| Git is missing | Install Git and make sure `git --version` works in this terminal |
-| `docket` is not found after setup | Open a new terminal and check the command location and PATH change from the installer |
+| Git is missing | Install Git and check that `git --version` works |
+| `docket` is not found | A plugin-only install does not add a shell command. After using the installer, open a new terminal and check its PATH change |
| No download for your platform | Check the [release assets](https://github.com/NovusEdge/docket/releases/latest); downloads target Linux, macOS, and Windows on amd64 and arm64 |
-| An update cannot fast-forward | Keep any local work and reconcile the managed checkout before retrying; see [managed checkout behavior](installer-reference.md#managed-checkout-behavior) |
+| An update cannot fast-forward | Preserve local changes and reconcile the checkout before retrying; see [managed checkout behavior](installer-reference.md#managed-checkout-behavior) |
## Verification limits
-Release builds cover Linux, macOS, and Windows, but build success does not
-establish that every native installation flow has been tested. The
-[verification notes](installer-reference.md#verification-limits) describe the
-checks and their limits.
+The [installer verification notes](installer-reference.md#verification-limits)
+and [integration checks](integrations.md#verification-scope) describe what was
+tested. A successful build or setup command does not prove that a new agent
+session has loaded the briefing.
diff --git a/docs/installer-reference.md b/docs/installer-reference.md
index dbc9577..9c0d44f 100644
--- a/docs/installer-reference.md
+++ b/docs/installer-reference.md
@@ -1,23 +1,19 @@
# Installer reference
Use this page for custom install locations, unattended setup, source builds, and
-installer behavior. For the normal setup and update steps, see
-[Installation](installation.md). Manual agent configuration is in the
+installer behavior. For setup by an agent, use the
+[shared setup instructions](agent-setup.md). For the normal setup and update
+steps, see [Installation](installation.md). Manual agent configuration is in the
[agent setup reference](integrations.md).
## The installer
### Choose an installer path
-- Download and run the compatibility launcher:
+- Download the launcher into a temporary folder using the
+ [installation steps](installation.md#the-installer).
- ```sh
- curl -fsSLO https://raw.githubusercontent.com/NovusEdge/docket/main/installer/install.py
- python3 install.py
- ```
-
- `curl -O` saves the file as `install.py` in the current directory. Outside a
- checkout, the launcher downloads a native installer and verifies it against
+ Outside a checkout, the launcher downloads a native installer and verifies it against
the release's `SHA256SUMS` file before running it. Release recipes build
Linux, macOS, and Windows assets for amd64 and arm64. The matching asset must
be published for the launcher to use it. Set `DOCKET_INSTALLER_VERSION` to
@@ -106,7 +102,7 @@ during installation stops further actions; completed actions remain applied.
| `--checkout DIR` | Use an existing source checkout without replacing it |
| `--yes` | Take every default and do not prompt |
| `--no-tty` | Treat stdin as non-interactive and apply the same prompt defaults as `--yes` |
-| `--update` | Refresh the existing Docket checkout and native graph viewer; keep harness and PATH configuration unchanged |
+| `--update` | Refresh Docket, its graph viewer, and installed harness plugins; keep PATH and harness selection unchanged |
| `--uninstall` | Remove what the installer wrote |
| `--version` | Print the installer version |
@@ -122,12 +118,16 @@ only.
### Update, uninstall, and cleanup
-For a managed install, run the downloaded launcher with `--update` to
-fast-forward the managed checkout and refresh its viewer. The command must
-already be installed. This mode leaves `PATH` entries and harness configuration
-unchanged. Add `--dry-run` to review the checkout and viewer operations first.
+For a managed install, run `docket update`, or run the downloaded launcher with
+`--update`. Either one fast-forwards the managed checkout, refreshes its viewer,
+and refreshes the plugin each harness installed. The command must already be
+installed. `PATH` entries and the set of configured harnesses stay unchanged.
+Add `--dry-run` to review the checkout, viewer, and harness operations first.
It cannot be combined with `--harness`, `--project`, or `--uninstall`.
+A copy installed only as a harness plugin has no checkout to update. There,
+`docket update` prints the command that harness needs and changes nothing.
+
From a source checkout, `python3 installer/install.py --update` uses that
checkout and does not fetch Git. It refreshes the viewer when Go is available.
`just update` runs this source-checkout path. An explicit `--checkout DIR` also
diff --git a/docs/integrations.md b/docs/integrations.md
index a29bb7f..3f8b91d 100644
--- a/docs/integrations.md
+++ b/docs/integrations.md
@@ -2,7 +2,8 @@
Use this page to configure an agent manually or inspect the files that connect it
to Docket. For guided setup, start with [Installation](installation.md). For daily
-use, read [Working with your agent](agents.md).
+use, read [Working with your agent](agents.md). For an agent carrying out setup
+on the user's behalf, follow [Setup instructions for agents](agent-setup.md).
## Configure an agent harness
diff --git a/docs/quickstart.md b/docs/quickstart.md
index 3f612b2..c8da847 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -4,7 +4,8 @@ Create a project ledger, record a choice, and read it back. If Docket is not
installed yet, follow [Installation](installation.md) first.
The commands below use a terminal. You can also ask your agent to carry out the
-same steps.
+same steps. With a plugin-only installation, use the agent's bundled Docket
+command or run the installer to make `docket` available in your shell.
## 1. Open your project
diff --git a/installer/environment.go b/installer/environment.go
index 5f10a25..876a235 100644
--- a/installer/environment.go
+++ b/installer/environment.go
@@ -22,7 +22,7 @@ func DiscoverEnvironment(opts Options) (Environment, error) {
}
e := Environment{Home: home, Cwd: cwd, GOOS: runtime.GOOS, Shell: os.Getenv("SHELL"), Path: filepath.SplitList(os.Getenv("PATH")),
ReadFile: os.ReadFile, Readlink: os.Readlink, LookPath: exec.LookPath,
- Exists: func(p string) bool { _, err := os.Lstat(p); return err == nil }}
+ Exists: func(p string) bool { _, err := os.Lstat(p); return err == nil }, Getenv: os.Getenv}
e.CodexInstalled = installedCodexPlugin
if e.Shell == "" {
e.Shell = "/bin/sh"
diff --git a/installer/main.go b/installer/main.go
index 43fc1de..89b631c 100644
--- a/installer/main.go
+++ b/installer/main.go
@@ -32,7 +32,7 @@ func parseOptions(args []string, out io.Writer) (Options, error) {
f.BoolVar(&opts.Yes, "yes", false, "apply defaults without prompts")
f.BoolVar(&opts.NoTTY, "no-tty", false, "use plain unattended output")
f.BoolVar(&opts.DryRun, "dry-run", false, "show every planned operation without applying it")
- f.BoolVar(&opts.Update, "update", false, "refresh Docket and its graph viewer without changing harness configuration")
+ f.BoolVar(&opts.Update, "update", false, "refresh Docket, its graph viewer, and installed harness plugins; keep PATH and harness selection unchanged")
f.BoolVar(&opts.Uninstall, "uninstall", false, "remove Docket integration; keep decision ledgers")
f.BoolVar(&opts.Version, "version", false, "show installer version")
f.Usage = func() {
diff --git a/installer/planner.go b/installer/planner.go
index cb4e347..d7518eb 100644
--- a/installer/planner.go
+++ b/installer/planner.go
@@ -6,6 +6,7 @@ import (
"fmt"
"io/fs"
"path"
+ "sort"
"strings"
"syscall"
)
@@ -84,12 +85,15 @@ func buildPlan(env Environment, opts Options) (Plan, error) {
return buildUninstall(env, prefix, opts.Project)
}
if opts.Update {
- return Plan{}, nil
+ return buildUpdate(env, prefix, opts.Checkout)
}
plan := Plan{}
plan.Actions = append(plan.Actions, planCommand(env, prefix)...)
plan.Actions = append(plan.Actions, planPathAdd(env, prefix)...)
+ if opts.Checkout == "" {
+ plan.Actions = append(plan.Actions, planMarker(env, prefix)...)
+ }
if selected == nil {
selected = []string{"claude-code"}
for _, h := range DetectHarnesses(env) {
@@ -160,6 +164,19 @@ func planCommand(env Environment, prefix string) []Action {
return []Action{{Kind: "link", Path: target, Source: source, Label: "command"}}
}
+// The marker tells the Python CLI that this checkout is installer-owned. A
+// managed checkout is a git clone, so the presence of .git cannot distinguish
+// it from a contributor's own tree.
+func planMarker(env Environment, prefix string) []Action {
+ path := join(env, env.Checkout, ".docket-managed")
+ text, _ := json.MarshalIndent(map[string]string{"prefix": prefix, "version": version}, "", " ")
+ body := string(text) + "\n"
+ if sameFile(env, path, body) {
+ return nil
+ }
+ return []Action{{Kind: "write", Path: path, Text: body, Label: "marker"}}
+}
+
func planPathAdd(env Environment, prefix string) []Action {
if PathContains(env.Path, prefix, env.GOOS) {
return nil
@@ -331,6 +348,163 @@ func planOpenCode(env Environment) []Action {
return []Action{{Kind: "write", Path: p, Text: text, Label: "opencode"}}
}
+func buildUpdate(env Environment, prefix string, checkout string) (Plan, error) {
+ plan := Plan{}
+ actions, notes, err := updateClaude(env)
+ if err != nil {
+ return Plan{}, err
+ }
+ plan.Actions = append(plan.Actions, actions...)
+ plan.Notes = append(plan.Notes, notes...)
+ actions, notes = updateCodex(env, prefix)
+ plan.Actions = append(plan.Actions, actions...)
+ plan.Notes = append(plan.Notes, notes...)
+ if checkout == "" {
+ plan.Actions = append(plan.Actions, planMarker(env, prefix)...)
+ }
+ return plan, nil
+}
+
+func updateClaude(env Environment) ([]Action, []string, error) {
+ installed := join(env, env.Home, ".claude", "plugins", "installed_plugins.json")
+ text, ok := readText(env, installed)
+ if !ok {
+ return nil, nil, nil
+ }
+ data, err := parseObject(installed, text)
+ if err != nil {
+ return nil, nil, err
+ }
+ entries, _ := data["plugins"].(map[string]any)
+ // Go randomizes map iteration, so a user registered from two marketplaces
+ // would get a different plan on every run. Sort and refresh all of them.
+ var marketplaces []string
+ for key := range entries {
+ name, marketplace, found := strings.Cut(key, "@")
+ if name == "docket" && found && marketplace != "" {
+ marketplaces = append(marketplaces, marketplace)
+ }
+ }
+ if len(marketplaces) == 0 {
+ return nil, nil, nil
+ }
+ sort.Strings(marketplaces)
+ if !commandPresent(env, "claude") {
+ return nil, []string{"claude is not on PATH; its plugin was left unchanged."}, nil
+ }
+ var actions []Action
+ for _, marketplace := range marketplaces {
+ if claudeMarketplaceSource(env, marketplace) == "github" {
+ actions = append(actions, Action{Kind: "command", Args: []string{"claude", "plugin", "marketplace", "update", marketplace}, Label: "claude-code"})
+ }
+ // -y because the installer runs commands through CombinedOutput, which
+ // is never a TTY, and claude plugin update requires it there.
+ actions = append(actions, Action{Kind: "command", Args: []string{"claude", "plugin", "update", "docket@" + marketplace, "-y"}, Label: "claude-code"})
+ }
+ return actions, nil, nil
+}
+
+// A marketplace registered from a local path has nothing upstream for
+// `claude plugin marketplace update` to fetch; only a GitHub-sourced
+// marketplace gets that command.
+func claudeMarketplaceSource(env Environment, marketplace string) string {
+ p := join(env, env.Home, ".claude", "plugins", "known_marketplaces.json")
+ text, ok := readText(env, p)
+ if !ok {
+ return ""
+ }
+ var known map[string]struct {
+ Source struct {
+ Source string `json:"source"`
+ } `json:"source"`
+ }
+ if err := json.Unmarshal([]byte(text), &known); err != nil {
+ return ""
+ }
+ return known[marketplace].Source.Source
+}
+
+func updateCodex(env Environment, prefix string) ([]Action, []string) {
+ receiptPath := join(env, prefix, ".docket-codex.json")
+ receiptText, ok := readText(env, receiptPath)
+ if !ok {
+ return nil, nil
+ }
+ var receipt struct{ Checkout string }
+ if err := json.Unmarshal([]byte(receiptText), &receipt); err != nil || receipt.Checkout == "" {
+ return nil, nil
+ }
+ // What Codex has installed wins over what the installer registered. A user
+ // can install docket from their own marketplace, and refreshing the
+ // registered one would add a second copy and leave the stale one enabled.
+ marketplaces := codexInstalledMarketplaces(env)
+ if len(marketplaces) == 0 {
+ marketplace := codexMarketplaceName(env, receipt.Checkout)
+ if marketplace == "" {
+ return nil, []string{"Codex marketplace manifest at " + receipt.Checkout + " has no name; its plugin was left unchanged."}
+ }
+ marketplaces = []string{marketplace}
+ }
+ if !commandPresent(env, "codex") {
+ return nil, []string{"Codex is not on PATH; its plugin was left unchanged."}
+ }
+ // Codex caches every plugin into a version-stamped directory, including
+ // one from a local-path marketplace, so an upgrade of the marketplace
+ // alone leaves the cached copy at its old version.
+ var actions []Action
+ for _, marketplace := range marketplaces {
+ actions = append(actions,
+ Action{Kind: "command", Args: []string{"codex", "plugin", "remove", "docket@" + marketplace}, Label: "codex"},
+ Action{Kind: "command", Args: []string{"codex", "plugin", "add", "docket@" + marketplace}, Label: "codex"})
+ }
+ return actions, nil
+}
+
+// codexInstalledMarketplaces reads the docket entries out of Codex's own
+// config. Codex writes one [plugins."@"] table per
+// installed plugin, and a line scan reads it without a TOML parser or a
+// codex subprocess.
+func codexInstalledMarketplaces(env Environment) []string {
+ text, ok := readText(env, join(env, env.Home, ".codex", "config.toml"))
+ if !ok {
+ return nil
+ }
+ seen := map[string]bool{}
+ var out []string
+ for _, line := range strings.Split(text, "\n") {
+ rest, found := strings.CutPrefix(strings.TrimSpace(line), `[plugins."docket@`)
+ if !found {
+ continue
+ }
+ name, found := strings.CutSuffix(rest, `"]`)
+ if !found || name == "" || seen[name] {
+ continue
+ }
+ seen[name] = true
+ out = append(out, name)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// The name comes from the marketplace's own manifest rather than being
+// assumed, so a checkout registered under a different marketplace name (for
+// example a hand-registered "local-personal") still gets the right plugin id.
+func codexMarketplaceName(env Environment, checkout string) string {
+ p := join(env, checkout, ".agents", "plugins", "marketplace.json")
+ text, ok := readText(env, p)
+ if !ok {
+ return ""
+ }
+ var manifest struct {
+ Name string `json:"name"`
+ }
+ if err := json.Unmarshal([]byte(text), &manifest); err != nil {
+ return ""
+ }
+ return manifest.Name
+}
+
func buildUninstall(env Environment, prefix string, project bool) (Plan, error) {
plan := Plan{Notes: []string{"Decision ledgers are preserved."}}
source := join(env, env.Checkout, "bin", "docket")
@@ -380,6 +554,11 @@ func buildUninstall(env Environment, prefix string, project bool) (Plan, error)
plan.Actions = append(plan.Actions, Action{Kind: "remove", Path: rule, Label: "cursor"})
}
}
+ marker := join(env, env.Checkout, ".docket-managed")
+ if _, ok := readText(env, marker); ok {
+ plan.Actions = append(plan.Actions, Action{Kind: "remove", Path: marker, Label: "marker"})
+ }
+ plan.Actions = append(plan.Actions, Action{Kind: "remove-tree", Path: updateStateDir(env), Label: "state"})
codexReceiptPath := join(env, prefix, ".docket-codex.json")
receiptText, hasReceipt := readText(env, codexReceiptPath)
hasReceipt = hasReceipt && receiptText == codexReceipt(env)
@@ -549,6 +728,22 @@ func cursorRuleText() string {
return "---\nalwaysApply: true\n---\n\nSee docket's skill for when and how to record a decision.\n"
}
+func updateStateDir(env Environment) string {
+ getenv := env.Getenv
+ if getenv == nil {
+ getenv = func(string) string { return "" }
+ }
+ if env.GOOS == "windows" {
+ if local := getenv("LOCALAPPDATA"); local != "" {
+ return join(env, local, "docket-state")
+ }
+ }
+ if xdg := getenv("XDG_STATE_HOME"); xdg != "" {
+ return join(env, xdg, "docket")
+ }
+ return join(env, env.Home, ".local", "state", "docket")
+}
+
func codexReceipt(env Environment) string {
raw, _ := json.MarshalIndent(map[string]string{"checkout": env.Checkout}, "", " ")
return string(raw) + "\n"
@@ -649,6 +844,8 @@ func DescribeAction(a Action) string {
return "link " + a.Path + " -> " + a.Source
case "remove":
return "remove " + a.Path
+ case "remove-tree":
+ return "remove " + a.Path + " (recursively)"
case "command":
return "run " + strings.Join(a.Args, " ")
case "path-add":
diff --git a/installer/planner_test.go b/installer/planner_test.go
index 2897f3b..0b1cc06 100644
--- a/installer/planner_test.go
+++ b/installer/planner_test.go
@@ -25,6 +25,75 @@ func testEnv(files map[string]string, existing ...string) Environment {
return nil, fs.ErrNotExist
},
Readlink: func(p string) (string, error) { return "", fs.ErrNotExist },
+ Getenv: func(string) string { return "" },
+ }
+}
+
+func TestInstallWritesTheManagedMarker(t *testing.T) {
+ env := testEnv(nil)
+ plan, err := BuildPlan(env, Options{Harness: []string{}, Prefix: "/home/a/.local/bin"})
+ if err != nil {
+ t.Fatalf("BuildPlan: %v", err)
+ }
+ var found bool
+ for _, action := range plan.Actions {
+ if action.Path == "/src/docket/.docket-managed" {
+ found = true
+ if !strings.Contains(action.Text, "/home/a/.local/bin") {
+ t.Fatalf("marker omits the prefix: %q", action.Text)
+ }
+ }
+ }
+ if !found {
+ t.Fatalf("no marker action in plan: %#v", plan.Actions)
+ }
+}
+
+func TestUninstallRemovesTheManagedMarker(t *testing.T) {
+ env := testEnv(map[string]string{
+ "/src/docket/.docket-managed": `{"prefix":"/home/a/.local/bin"}`,
+ }, "/src/docket/.docket-managed")
+ plan, err := BuildPlan(env, Options{Uninstall: true, Prefix: "/home/a/.local/bin"})
+ if err != nil {
+ t.Fatalf("BuildPlan: %v", err)
+ }
+ var found bool
+ for _, action := range plan.Actions {
+ if action.Kind == "remove" && action.Path == "/src/docket/.docket-managed" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("marker not removed: %#v", plan.Actions)
+ }
+}
+
+func TestUninstallRemovesTheStateDirectoryAsATree(t *testing.T) {
+ env := testEnv(nil)
+ plan, err := BuildPlan(env, Options{Uninstall: true, Prefix: "/home/a/.local/bin"})
+ if err != nil {
+ t.Fatalf("BuildPlan: %v", err)
+ }
+ action, ok := findAction(plan.Actions, "remove-tree", "/home/a/.local/state/docket")
+ if !ok {
+ t.Fatalf("no state removal in plan: %#v", plan.Actions)
+ }
+ if action.Kind != "remove-tree" {
+ t.Fatalf("state removal uses %q, which fails on a non-empty directory", action.Kind)
+ }
+}
+
+func TestSourceModeInstallWritesNoManagedMarker(t *testing.T) {
+ env := testEnv(nil)
+ env.Checkout = ""
+ plan, err := BuildPlan(env, Options{Harness: []string{}, Prefix: "/home/a/.local/bin", Checkout: "/src/docket"})
+ if err != nil {
+ t.Fatalf("BuildPlan: %v", err)
+ }
+ for _, action := range plan.Actions {
+ if strings.HasSuffix(action.Path, ".docket-managed") {
+ t.Fatalf("source-mode install wrote a managed marker: %#v", action)
+ }
}
}
@@ -399,3 +468,202 @@ func TestShellHelpersQuoteSpacesAndComparePathEntries(t *testing.T) {
t.Fatal("Windows comparison should fold case")
}
}
+
+func TestUpdateRefreshesAClaudePluginInstall(t *testing.T) {
+ env := testEnv(map[string]string{
+ "/home/a/.claude/plugins/installed_plugins.json": `{"plugins":{"docket@NovusEdge":[{"version":"0.8.0"}]}}`,
+ "/home/a/.claude/plugins/known_marketplaces.json": `{"NovusEdge":{"source":{"source":"github","repo":"NovusEdge/docket"}}}`,
+ })
+ env.Path = []string{"/usr/bin"}
+ env.Exists = func(p string) bool { return p == "/usr/bin/claude" }
+ plan, err := BuildPlan(env, Options{Update: true, Prefix: "/home/a/.local/bin"})
+ if err != nil {
+ t.Fatalf("BuildPlan: %v", err)
+ }
+ got := commandArgs(plan)
+ want := [][]string{
+ {"claude", "plugin", "marketplace", "update", "NovusEdge"},
+ {"claude", "plugin", "update", "docket@NovusEdge", "-y"},
+ }
+ if len(got) != len(want) {
+ t.Fatalf("actions = %#v", got)
+ }
+ for i := range want {
+ if strings.Join(got[i], " ") != strings.Join(want[i], " ") {
+ t.Fatalf("action %d = %v, want %v", i, got[i], want[i])
+ }
+ }
+}
+
+func TestUpdateRefreshesEveryClaudeMarketplaceInOrder(t *testing.T) {
+ env := testEnv(map[string]string{
+ "/home/a/.claude/plugins/installed_plugins.json": `{"plugins":{"docket@NovusEdge":[{"version":"0.8.0"}],"docket@dev":[{"version":"0.9.0"}],"docket@":[{"version":"0.1.0"}]}}`,
+ "/home/a/.claude/plugins/known_marketplaces.json": `{"NovusEdge":{"source":{"source":"github","repo":"NovusEdge/docket"}}}`,
+ })
+ env.Path = []string{"/usr/bin"}
+ env.Exists = func(p string) bool { return p == "/usr/bin/claude" }
+ // Map iteration is randomized, so the same input must plan the same way
+ // every run. A degenerate "docket@" key names no marketplace and is dropped.
+ for i := 0; i < 20; i++ {
+ plan, err := BuildPlan(env, Options{Update: true, Prefix: "/home/a/.local/bin"})
+ if err != nil {
+ t.Fatalf("BuildPlan: %v", err)
+ }
+ want := []string{
+ "claude plugin marketplace update NovusEdge",
+ "claude plugin update docket@NovusEdge -y",
+ "claude plugin update docket@dev -y",
+ }
+ got := commandArgs(plan)
+ if len(got) != len(want) {
+ t.Fatalf("actions = %#v", got)
+ }
+ for j := range want {
+ if strings.Join(got[j], " ") != want[j] {
+ t.Fatalf("action %d = %v, want %q", j, got[j], want[j])
+ }
+ }
+ }
+}
+
+func TestUpdateWritesTheManagedMarker(t *testing.T) {
+ env := testEnv(nil)
+ plan, err := BuildPlan(env, Options{Update: true, Prefix: "/home/a/.local/bin"})
+ if err != nil {
+ t.Fatalf("BuildPlan: %v", err)
+ }
+ if _, ok := findAction(plan.Actions, "write", "/src/docket/.docket-managed"); !ok {
+ t.Fatalf("no marker action in update plan: %#v", plan.Actions)
+ }
+}
+
+func TestUpdateInSourceModeWritesNoManagedMarker(t *testing.T) {
+ env := testEnv(nil)
+ plan, err := BuildPlan(env, Options{Update: true, Prefix: "/home/a/.local/bin", Checkout: "/src/docket"})
+ if err != nil {
+ t.Fatalf("BuildPlan: %v", err)
+ }
+ for _, action := range plan.Actions {
+ if strings.HasSuffix(action.Path, ".docket-managed") {
+ t.Fatalf("source-mode update wrote a managed marker: %#v", action)
+ }
+ }
+}
+
+func TestUpdateReinstallsTheCodexPlugin(t *testing.T) {
+ env := testEnv(map[string]string{
+ "/home/a/.local/bin/.docket-codex.json": codexReceipt(testEnv(nil)),
+ "/src/docket/.agents/plugins/marketplace.json": `{"name":"NovusEdge"}`,
+ }, "/home/a/.local/bin/.docket-codex.json", "/usr/bin/codex")
+ plan, err := BuildPlan(env, Options{Update: true, Prefix: "/home/a/.local/bin"})
+ if err != nil {
+ t.Fatalf("BuildPlan: %v", err)
+ }
+ got := commandArgs(plan)
+ if len(got) != 2 ||
+ strings.Join(got[0], " ") != "codex plugin remove docket@NovusEdge" ||
+ strings.Join(got[1], " ") != "codex plugin add docket@NovusEdge" {
+ t.Fatalf("actions = %#v", got)
+ }
+}
+
+func TestUpdateReinstallsTheCodexPluginUnderItsOwnMarketplaceName(t *testing.T) {
+ env := testEnv(map[string]string{
+ "/home/a/.local/bin/.docket-codex.json": `{"checkout":"/opt/local-personal"}`,
+ "/opt/local-personal/.agents/plugins/marketplace.json": `{"name":"local-personal"}`,
+ }, "/home/a/.local/bin/.docket-codex.json", "/usr/bin/codex")
+ plan, err := BuildPlan(env, Options{Update: true, Prefix: "/home/a/.local/bin"})
+ if err != nil {
+ t.Fatalf("BuildPlan: %v", err)
+ }
+ got := commandArgs(plan)
+ if len(got) != 2 ||
+ strings.Join(got[0], " ") != "codex plugin remove docket@local-personal" ||
+ strings.Join(got[1], " ") != "codex plugin add docket@local-personal" {
+ t.Fatalf("actions = %#v", got)
+ }
+}
+
+func TestUpdatePrefersTheCodexPluginActuallyInstalled(t *testing.T) {
+ env := testEnv(map[string]string{
+ "/home/a/.local/bin/.docket-codex.json": codexReceipt(testEnv(nil)),
+ "/src/docket/.agents/plugins/marketplace.json": `{"name":"NovusEdge"}`,
+ "/home/a/.codex/config.toml": "[plugins.\"codex-rg-guard@local-personal\"]\nenabled = true\n\n" +
+ "[plugins.\"docket@local-personal\"]\nenabled = true\n",
+ }, "/home/a/.local/bin/.docket-codex.json", "/usr/bin/codex")
+ plan, err := BuildPlan(env, Options{Update: true, Prefix: "/home/a/.local/bin"})
+ if err != nil {
+ t.Fatalf("BuildPlan: %v", err)
+ }
+ got := commandArgs(plan)
+ if len(got) != 2 ||
+ strings.Join(got[0], " ") != "codex plugin remove docket@local-personal" ||
+ strings.Join(got[1], " ") != "codex plugin add docket@local-personal" {
+ t.Fatalf("actions = %#v", got)
+ }
+}
+
+func TestUpdateSkipsMarketplaceUpdateForLocalSource(t *testing.T) {
+ env := testEnv(map[string]string{
+ "/home/a/.claude/plugins/installed_plugins.json": `{"plugins":{"docket@local-personal":[{"version":"0.8.0"}]}}`,
+ "/home/a/.claude/plugins/known_marketplaces.json": `{"local-personal":{"source":{"source":"local","path":"/opt/local-personal"}}}`,
+ })
+ env.Path = []string{"/usr/bin"}
+ env.Exists = func(p string) bool { return p == "/usr/bin/claude" }
+ plan, err := BuildPlan(env, Options{Update: true, Prefix: "/home/a/.local/bin"})
+ if err != nil {
+ t.Fatalf("BuildPlan: %v", err)
+ }
+ got := commandArgs(plan)
+ if len(got) != 1 || strings.Join(got[0], " ") != "claude plugin update docket@local-personal -y" {
+ t.Fatalf("actions = %#v", got)
+ }
+}
+
+func TestUpdateLeavesANoRegistrationInstallAlone(t *testing.T) {
+ env := testEnv(map[string]string{
+ "/home/a/.claude/plugins/installed_plugins.json": `{"plugins":{"other@Someone":[{"version":"1.0.0"}]}}`,
+ })
+ env.Readlink = func(p string) (string, error) {
+ if p == "/home/a/.claude/skills/docket" {
+ return "/src/docket", nil
+ }
+ return "", fs.ErrNotExist
+ }
+ env.Path = []string{"/usr/bin"}
+ env.Exists = func(p string) bool { return p == "/usr/bin/claude" }
+ plan, err := BuildPlan(env, Options{Update: true, Prefix: "/home/a/.local/bin"})
+ if err != nil {
+ t.Fatalf("BuildPlan: %v", err)
+ }
+ if got := commandArgs(plan); len(got) != 0 {
+ t.Fatalf("non-docket registration produced harness commands: %#v", got)
+ }
+}
+
+func TestUpdateNotesAnAbsentHarnessCommand(t *testing.T) {
+ env := testEnv(map[string]string{
+ "/home/a/.claude/plugins/installed_plugins.json": `{"plugins":{"docket@NovusEdge":[{"version":"0.8.0"}]}}`,
+ "/home/a/.claude/plugins/known_marketplaces.json": `{"NovusEdge":{"source":{"source":"github","repo":"NovusEdge/docket"}}}`,
+ })
+ plan, err := BuildPlan(env, Options{Update: true, Prefix: "/home/a/.local/bin"})
+ if err != nil {
+ t.Fatalf("BuildPlan: %v", err)
+ }
+ if got := commandArgs(plan); len(got) != 0 {
+ t.Fatalf("harness commands without claude on PATH: %#v", got)
+ }
+ if len(plan.Notes) != 1 || !strings.Contains(plan.Notes[0], "claude") {
+ t.Fatalf("notes = %#v", plan.Notes)
+ }
+}
+
+func commandArgs(p Plan) [][]string {
+ var out [][]string
+ for _, action := range p.Actions {
+ if action.Kind == "command" {
+ out = append(out, action.Args)
+ }
+ }
+ return out
+}
diff --git a/installer/runtime.go b/installer/runtime.go
index 4287251..f0b879f 100644
--- a/installer/runtime.go
+++ b/installer/runtime.go
@@ -71,6 +71,8 @@ func executeAction(ctx context.Context, a Action) error {
return nil
}
return err
+ case "remove-tree":
+ return os.RemoveAll(a.Path)
case "command":
if len(a.Args) == 0 {
return errors.New("empty command")
diff --git a/installer/runtime_test.go b/installer/runtime_test.go
index 45fedb5..1e97bb4 100644
--- a/installer/runtime_test.go
+++ b/installer/runtime_test.go
@@ -58,6 +58,23 @@ func TestExecutePlanWritesThenReportsAndStopsOnFailure(t *testing.T) {
}
}
+func TestRemoveTreeDeletesANonEmptyStateDirectory(t *testing.T) {
+ dir := t.TempDir()
+ state := filepath.Join(dir, "docket")
+ if err := os.MkdirAll(state, 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(state, "update.json"), []byte("{}"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := executeAction(context.Background(), Action{Kind: "remove-tree", Path: state, Label: "state"}); err != nil {
+ t.Fatalf("executeAction: %v", err)
+ }
+ if _, err := os.Stat(state); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("state directory still exists: %v", err)
+ }
+}
+
func TestCancelledPlanDoesNotWrite(t *testing.T) {
dir := t.TempDir()
ctx, cancel := context.WithCancel(context.Background())
diff --git a/installer/types.go b/installer/types.go
index 3cef849..defec57 100644
--- a/installer/types.go
+++ b/installer/types.go
@@ -17,6 +17,7 @@ type Environment struct {
Readlink func(string) (string, error)
Exists func(string) bool
CodexInstalled func() (bool, error)
+ Getenv func(string) string
}
type Harness struct {
@@ -27,7 +28,7 @@ type Harness struct {
// Every persistent operation appears in the reviewed plan, including commands.
type Action struct {
- Kind string // write, link, remove, command, path-add, path-remove, checkout, checkout-update, viewer
+ Kind string // write, link, remove, remove-tree, command, path-add, path-remove, checkout, checkout-update, viewer
Path, Source, Text, Label string
Args []string
}
diff --git a/justfile b/justfile
index 8d66f91..37ca90d 100644
--- a/justfile
+++ b/justfile
@@ -27,7 +27,7 @@ verify:
install:
python3 installer/install.py
-# refresh only the installed Docket checkout and graph viewer
+# refresh the checkout, graph viewer, and installed harness plugins
[group('install')]
update:
python3 installer/install.py --update
diff --git a/lib/docket_update.py b/lib/docket_update.py
new file mode 100644
index 0000000..30d049a
--- /dev/null
+++ b/lib/docket_update.py
@@ -0,0 +1,212 @@
+"""Update state and version comparison. Standard library only, because the
+SessionStart hook imports this on every session."""
+
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+LEASE_SECONDS = 300
+SUCCESS_SECONDS = 86400
+FAILURE_SECONDS = 3600
+FAILURE_CAP = 86400
+
+
+def state_dir(platform: str | None = None) -> Path:
+ platform = platform or sys.platform
+ if platform == "win32":
+ local = os.environ.get("LOCALAPPDATA")
+ if local:
+ # Not %LOCALAPPDATA%\docket: that is the default managed checkout,
+ # and state inside a git work tree the installer updates is lost.
+ return Path(local) / "docket-state"
+ if xdg := os.environ.get("XDG_STATE_HOME"):
+ return Path(xdg) / "docket"
+ return Path.home() / ".local" / "state" / "docket"
+
+
+def state_path() -> Path:
+ return state_dir() / "update.json"
+
+
+def read_state() -> dict:
+ try:
+ data = json.loads(state_path().read_text())
+ except (OSError, ValueError):
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+def write_state(data: dict) -> None:
+ directory = state_dir()
+ try:
+ directory.mkdir(parents=True, exist_ok=True)
+ handle, temporary = tempfile.mkstemp(dir=directory, suffix=".tmp")
+ except OSError:
+ return
+ try:
+ with os.fdopen(handle, "w") as out:
+ json.dump(data, out)
+ # os.replace is atomic and overwrites an existing file on Windows,
+ # where a plain rename onto one fails.
+ os.replace(temporary, state_path())
+ except OSError:
+ pass
+ finally:
+ Path(temporary).unlink(missing_ok=True)
+
+
+def parse_version(text: str) -> tuple[int, int, int] | None:
+ parts = text.strip().lstrip("v").split(".")
+ if len(parts) < 3:
+ return None
+ try:
+ return int(parts[0]), int(parts[1]), int(parts[2].split("-")[0])
+ except ValueError:
+ return None
+
+
+def is_newer(latest: str, running: str) -> bool:
+ left, right = parse_version(latest), parse_version(running)
+ if left is None or right is None:
+ return False
+ return left > right
+
+
+REPOSITORY = "https://github.com/NovusEdge/docket"
+
+_HARNESS_ANCHORS = ((".claude", "claude"), (".codex", "codex"))
+
+
+def plugin_origin(root: Path) -> tuple[str, str] | None:
+ """(harness, marketplace) when root is a harness plugin cache copy.
+
+ Both harnesses lay the cache out as .../plugins/cache//
+ /, so the anchor directory is what tells them apart.
+ """
+ parts = root.parts
+ index = None
+ for i, part in enumerate(parts):
+ if part == "plugins" and i + 1 < len(parts) and parts[i + 1] == "cache":
+ index = i
+ if index is None or index == 0:
+ return None
+ tail = parts[index + 1:]
+ if len(tail) < 2:
+ return None
+ marketplace = tail[1]
+ for anchor, harness in _HARNESS_ANCHORS:
+ if parts[index - 1] == anchor:
+ return harness, marketplace
+ return None
+
+
+def shape(root: Path) -> str:
+ # The marker is tested first because a managed checkout is a git clone,
+ # so a .git test would classify every managed install as a source tree.
+ if (root / ".docket-managed").exists():
+ return "managed"
+ if plugin_origin(root):
+ return "plugin"
+ if (root / ".git").exists():
+ return "source"
+ return "unknown"
+
+
+def update_command(root: Path) -> str:
+ origin = plugin_origin(root)
+ if origin:
+ harness, marketplace = origin
+ if harness == "claude":
+ return (f"claude plugin update docket@{marketplace} -y, "
+ "then restart Claude Code")
+ return (f"codex plugin remove docket@{marketplace} && "
+ f"codex plugin add docket@{marketplace}")
+ if shape(root) == "unknown":
+ return f"reinstall from {REPOSITORY}"
+ return "docket update"
+
+
+def notice(running: str, latest: str, root: Path) -> str | None:
+ if not latest or not is_newer(latest, running):
+ return None
+ tag = latest.lstrip("v")
+ return (f"# docket: {tag} is available (running {running}). "
+ f"Run: {update_command(root)}")
+
+
+RELEASES_URL = "https://api.github.com/repos/NovusEdge/docket/releases/latest"
+FETCH_TIMEOUT = 5.0
+DISABLE_ENV = "DOCKET_NO_UPDATE_CHECK"
+
+
+def disabled() -> bool:
+ return os.environ.get(DISABLE_ENV, "") not in ("", "0")
+
+
+def due(state: dict, now: float) -> bool:
+ try:
+ return now >= float(state.get("next_check_at", 0))
+ except (TypeError, ValueError):
+ return True
+
+
+def fetch_latest(url: str = RELEASES_URL) -> str:
+ from urllib.request import urlopen
+
+ with urlopen(url, timeout=FETCH_TIMEOUT) as response:
+ payload = json.loads(response.read().decode("utf-8"))
+ tag = payload.get("tag_name", "")
+ if not isinstance(tag, str) or not tag:
+ raise ValueError("release payload carries no tag_name")
+ return tag
+
+
+def run_fetch(now: float) -> int:
+ state = read_state()
+ # The lease lands before the request, so a second session starting while
+ # this one waits on the network sees a future next_check_at and does not
+ # fork a second fetcher.
+ write_state({**state, "next_check_at": now + LEASE_SECONDS})
+ try:
+ latest = fetch_latest()
+ except Exception:
+ failures = int(state.get("failures", 0) or 0) + 1
+ delay = min(FAILURE_SECONDS * (2 ** (failures - 1)), FAILURE_CAP)
+ write_state({**state, "failures": failures, "next_check_at": now + delay})
+ return 1
+ write_state({"latest": latest, "checked_at": now, "failures": 0,
+ "next_check_at": now + SUCCESS_SECONDS})
+ return 0
+
+
+def spawn_fetch(script: Path) -> None:
+ """Start the refresh and return. The parent never waits.
+
+ Every stream goes to devnull. A child that inherits the hook's stdout
+ holds the pipe open after the hook exits, so the harness reads to EOF and
+ stalls for the whole hook timeout, and anything the child prints lands in
+ the session context outside the JSON envelope.
+ """
+ kwargs = {
+ "stdin": subprocess.DEVNULL,
+ "stdout": subprocess.DEVNULL,
+ "stderr": subprocess.DEVNULL,
+ "close_fds": True,
+ }
+ if sys.platform == "win32":
+ kwargs["creationflags"] = (
+ subprocess.DETACHED_PROCESS
+ | subprocess.CREATE_NEW_PROCESS_GROUP
+ | subprocess.CREATE_NO_WINDOW
+ )
+ else:
+ kwargs["start_new_session"] = True
+ try:
+ subprocess.Popen([sys.executable, str(script), "_update-fetch"], **kwargs)
+ except OSError:
+ return
diff --git a/tests/test_docket.py b/tests/test_docket.py
index 5ca80ee..ce3c5e6 100644
--- a/tests/test_docket.py
+++ b/tests/test_docket.py
@@ -21,6 +21,8 @@ def run(cwd, *args):
env = dict(os.environ)
env["DOCKET_HOME"] = str(Path(cwd) / "global")
env["DOCKET_AUTHOR"] = "test"
+ env["DOCKET_NO_UPDATE_CHECK"] = "1"
+ env["XDG_STATE_HOME"] = str(Path(cwd) / "state")
return subprocess.run([sys.executable, DOCKET, *args], cwd=cwd, env=env,
capture_output=True, text=True)
diff --git a/tests/test_update.py b/tests/test_update.py
new file mode 100644
index 0000000..c2ebdbe
--- /dev/null
+++ b/tests/test_update.py
@@ -0,0 +1,497 @@
+import argparse
+import contextlib
+import importlib.util
+import io
+import json
+import os
+import sys
+import tempfile
+import unittest
+import unittest.mock
+from importlib.machinery import SourceFileLoader
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
+
+import docket_update as up
+
+
+class VersionCompare(unittest.TestCase):
+ def test_minor_number_orders_numerically(self):
+ self.assertTrue(up.is_newer("0.10.0", "0.9.0"))
+ self.assertFalse(up.is_newer("0.9.0", "0.10.0"))
+
+ def test_tag_prefix_is_stripped(self):
+ self.assertTrue(up.is_newer("v0.11.0", "0.10.0"))
+
+ def test_equal_versions_are_not_newer(self):
+ self.assertFalse(up.is_newer("0.10.0", "v0.10.0"))
+
+ def test_suffix_after_patch_is_ignored(self):
+ self.assertFalse(up.is_newer("0.10.0-rc1", "0.10.0"))
+
+ def test_unparseable_version_is_never_newer(self):
+ self.assertFalse(up.is_newer("unknown", "0.10.0"))
+ self.assertFalse(up.is_newer("0.11.0", "unknown"))
+
+
+class StateFile(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ os.environ["XDG_STATE_HOME"] = self.tmp.name
+ self.addCleanup(os.environ.pop, "XDG_STATE_HOME", None)
+
+ def test_absent_file_reads_as_empty(self):
+ self.assertEqual(up.read_state(), {})
+
+ def test_write_then_read_round_trips(self):
+ up.write_state({"latest": "0.11.0", "next_check_at": 12.0})
+ self.assertEqual(up.read_state()["latest"], "0.11.0")
+
+ def test_malformed_file_reads_as_empty(self):
+ up.state_path().parent.mkdir(parents=True, exist_ok=True)
+ up.state_path().write_text("{not json")
+ self.assertEqual(up.read_state(), {})
+
+ def test_write_leaves_no_temporary_file_behind(self):
+ up.write_state({"latest": "0.11.0"})
+ self.assertEqual([p.name for p in up.state_dir().iterdir()], ["update.json"])
+
+ def test_non_serializable_value_leaves_no_temporary_file_behind(self):
+ with self.assertRaises(TypeError):
+ up.write_state({"latest": object()})
+ self.assertEqual(list(up.state_dir().iterdir()), [])
+
+ def test_concurrent_writers_leave_one_intact_payload(self):
+ import threading
+
+ payloads = [{"latest": f"v0.{i}.0"} for i in range(8)]
+ threads = [threading.Thread(target=up.write_state, args=(p,)) for p in payloads]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+ result = up.read_state()
+ self.assertIn(result, payloads)
+ self.assertEqual([p.name for p in up.state_dir().iterdir()], ["update.json"])
+
+ def test_windows_state_dir_avoids_the_checkout_directory(self):
+ os.environ.pop("XDG_STATE_HOME")
+ os.environ["LOCALAPPDATA"] = r"C:\Users\a\AppData\Local"
+ self.addCleanup(os.environ.pop, "LOCALAPPDATA", None)
+ self.assertEqual(up.state_dir("win32").name, "docket-state")
+
+
+class Shape(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.root = Path(self.tmp.name)
+
+ def test_marker_wins_over_git_directory(self):
+ (self.root / ".git").mkdir()
+ (self.root / ".docket-managed").write_text("{}")
+ self.assertEqual(up.shape(self.root), "managed")
+
+ def test_git_directory_alone_is_a_source_tree(self):
+ (self.root / ".git").mkdir()
+ self.assertEqual(up.shape(self.root), "source")
+
+ def test_claude_cache_copy_is_plugin_only(self):
+ root = Path("/home/a/.claude/plugins/cache/NovusEdge/docket/0.8.0")
+ self.assertEqual(up.plugin_origin(root), ("claude", "NovusEdge"))
+ self.assertEqual(up.shape(root), "plugin")
+
+ def test_codex_cache_copy_is_plugin_only(self):
+ root = Path("/home/a/.codex/plugins/cache/local-personal/docket/0.8.0")
+ self.assertEqual(up.plugin_origin(root), ("codex", "local-personal"))
+
+ def test_bare_directory_is_unknown(self):
+ self.assertEqual(up.shape(self.root), "unknown")
+
+ def test_earlier_plugins_segment_does_not_shadow_the_real_cache(self):
+ root = Path("/home/a/plugins/work/.claude/plugins/cache/NovusEdge/docket/0.8.0")
+ self.assertEqual(up.plugin_origin(root), ("claude", "NovusEdge"))
+
+
+class Notice(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.root = Path(self.tmp.name)
+ (self.root / ".git").mkdir()
+
+ def test_current_version_produces_no_notice(self):
+ self.assertIsNone(up.notice("0.11.0", "0.11.0", self.root))
+
+ def test_absent_latest_produces_no_notice(self):
+ self.assertIsNone(up.notice("0.10.0", "", self.root))
+
+ def test_checkout_notice_names_the_subcommand(self):
+ line = up.notice("0.10.0", "0.11.0", self.root)
+ self.assertIn("0.11.0 is available (running 0.10.0)", line)
+ self.assertIn("Run: docket update", line)
+
+ def test_claude_notice_names_the_harness_command_and_restart(self):
+ root = Path("/home/a/.claude/plugins/cache/NovusEdge/docket/0.8.0")
+ line = up.notice("0.8.0", "0.11.0", root)
+ self.assertIn("claude plugin update docket@NovusEdge -y", line)
+ self.assertIn("restart", line)
+ self.assertNotIn("docket update", line.replace("docket@NovusEdge", ""))
+
+ def test_codex_notice_uses_remove_then_add(self):
+ root = Path("/home/a/.codex/plugins/cache/local-personal/docket/0.8.0")
+ line = up.notice("0.8.0", "0.11.0", root)
+ self.assertIn("codex plugin remove docket@local-personal", line)
+ self.assertIn("codex plugin add docket@local-personal", line)
+
+ def test_unknown_shape_names_the_repository(self):
+ bare = Path(self.tmp.name) / "bare"
+ bare.mkdir()
+ self.assertIn("github.com/NovusEdge/docket", up.notice("0.8.0", "0.11.0", bare))
+
+
+class Due(unittest.TestCase):
+ def test_absent_state_is_due(self):
+ self.assertTrue(up.due({}, now=1000.0))
+
+ def test_future_next_check_is_not_due(self):
+ self.assertFalse(up.due({"next_check_at": 2000.0}, now=1000.0))
+
+ def test_past_next_check_is_due(self):
+ self.assertTrue(up.due({"next_check_at": 500.0}, now=1000.0))
+
+ def test_malformed_next_check_is_due(self):
+ self.assertTrue(up.due({"next_check_at": "soon"}, now=1000.0))
+
+
+class RunFetch(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ os.environ["XDG_STATE_HOME"] = self.tmp.name
+ self.addCleanup(os.environ.pop, "XDG_STATE_HOME", None)
+ self.real = up.fetch_latest
+ self.addCleanup(setattr, up, "fetch_latest", self.real)
+
+ def test_lease_is_written_before_the_request(self):
+ seen = {}
+
+ def fetch(url=up.RELEASES_URL):
+ seen["at_request"] = up.read_state().get("next_check_at")
+ return "v0.11.0"
+
+ up.fetch_latest = fetch
+ up.run_fetch(now=1000.0)
+ self.assertEqual(seen["at_request"], 1000.0 + up.LEASE_SECONDS)
+
+ def test_success_records_latest_and_a_day_of_quiet(self):
+ up.fetch_latest = lambda url=up.RELEASES_URL: "v0.11.0"
+ self.assertEqual(up.run_fetch(now=1000.0), 0)
+ state = up.read_state()
+ self.assertEqual(state["latest"], "v0.11.0")
+ self.assertEqual(state["checked_at"], 1000.0)
+ self.assertEqual(state["next_check_at"], 1000.0 + up.SUCCESS_SECONDS)
+ self.assertEqual(state["failures"], 0)
+
+ def test_failure_backs_off_and_doubles(self):
+ def boom(url=up.RELEASES_URL):
+ raise OSError("no network")
+
+ up.fetch_latest = boom
+ up.run_fetch(now=1000.0)
+ self.assertEqual(up.read_state()["next_check_at"], 1000.0 + up.FAILURE_SECONDS)
+ up.run_fetch(now=2000.0)
+ self.assertEqual(up.read_state()["next_check_at"], 2000.0 + 2 * up.FAILURE_SECONDS)
+
+ def test_backoff_is_capped(self):
+ up.write_state({"failures": 20})
+ up.fetch_latest = lambda url=up.RELEASES_URL: (_ for _ in ()).throw(OSError())
+ up.run_fetch(now=1000.0)
+ self.assertEqual(up.read_state()["next_check_at"], 1000.0 + up.FAILURE_CAP)
+
+ def test_failure_keeps_the_previous_latest(self):
+ up.write_state({"latest": "v0.11.0"})
+ up.fetch_latest = lambda url=up.RELEASES_URL: (_ for _ in ()).throw(OSError())
+ up.run_fetch(now=1000.0)
+ self.assertEqual(up.read_state()["latest"], "v0.11.0")
+
+
+class SpawnFetch(unittest.TestCase):
+ def test_child_gets_no_inherited_streams(self):
+ recorded = {}
+
+ class FakePopen:
+ def __init__(self, argv, **kwargs):
+ recorded["argv"] = argv
+ recorded["kwargs"] = kwargs
+
+ original = up.subprocess.Popen
+ up.subprocess.Popen = FakePopen
+ self.addCleanup(setattr, up.subprocess, "Popen", original)
+ up.spawn_fetch(Path("/src/docket/bin/docket"))
+ self.assertIn("_update-fetch", recorded["argv"])
+ devnull = up.subprocess.DEVNULL
+ self.assertEqual(recorded["kwargs"]["stdin"], devnull)
+ self.assertEqual(recorded["kwargs"]["stdout"], devnull)
+ self.assertEqual(recorded["kwargs"]["stderr"], devnull)
+ self.assertTrue(recorded["kwargs"]["close_fds"])
+ self.assertTrue(recorded["kwargs"]["start_new_session"])
+
+ def test_child_is_detached_on_windows(self):
+ recorded = {}
+
+ class FakePopen:
+ def __init__(self, argv, **kwargs):
+ recorded["kwargs"] = kwargs
+
+ original_popen = up.subprocess.Popen
+ original_platform = up.sys.platform
+ up.subprocess.Popen = FakePopen
+ up.sys.platform = "win32"
+ self.addCleanup(setattr, up.subprocess, "Popen", original_popen)
+ self.addCleanup(setattr, up.sys, "platform", original_platform)
+ # These flags exist only on the Windows build of subprocess; supply
+ # stand-ins so the win32 branch can run under test on any platform.
+ for name in ("DETACHED_PROCESS", "CREATE_NEW_PROCESS_GROUP", "CREATE_NO_WINDOW"):
+ if not hasattr(up.subprocess, name):
+ setattr(up.subprocess, name, 1 << len(name))
+ self.addCleanup(delattr, up.subprocess, name)
+ up.spawn_fetch(Path("/src/docket/bin/docket"))
+ self.assertTrue(
+ recorded["kwargs"]["creationflags"] & up.subprocess.DETACHED_PROCESS)
+
+ def test_spawn_failure_is_swallowed(self):
+ def boom(*a, **k):
+ raise OSError("fork failed")
+
+ original = up.subprocess.Popen
+ up.subprocess.Popen = boom
+ self.addCleanup(setattr, up.subprocess, "Popen", original)
+ up.spawn_fetch(Path("/src/docket/bin/docket"))
+
+
+import subprocess as sp
+
+DOCKET = Path(__file__).resolve().parent.parent / "bin" / "docket"
+
+
+class UpdateLine(unittest.TestCase):
+ """update_line() is called from the SessionStart hook and must never block on the network."""
+
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ os.environ["XDG_STATE_HOME"] = self.tmp.name
+ self.addCleanup(os.environ.pop, "XDG_STATE_HOME", None)
+ loader = SourceFileLoader("docket_cli_update_line", str(DOCKET))
+ spec = importlib.util.spec_from_loader("docket_cli_update_line", loader)
+ self.docket_cli = importlib.util.module_from_spec(spec)
+ loader.exec_module(self.docket_cli)
+
+ def test_due_check_forks_instead_of_fetching_inline(self):
+ def boom(url=up.RELEASES_URL):
+ raise AssertionError("update_line must not fetch inline")
+
+ spawned = []
+ self.addCleanup(setattr, up, "fetch_latest", up.fetch_latest)
+ self.addCleanup(setattr, up, "spawn_fetch", up.spawn_fetch)
+ up.fetch_latest = boom
+ up.spawn_fetch = lambda script: spawned.append(script)
+
+ self.docket_cli.update_line()
+
+ self.assertEqual(len(spawned), 1)
+
+
+class ContextNotice(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.state = Path(self.tmp.name) / "state"
+ self.project = Path(self.tmp.name) / "project"
+ (self.project / ".docket").mkdir(parents=True)
+ (self.project / ".docket" / "ledger.jsonl").write_text("")
+
+ def run_context(self, *args, **env):
+ environment = {
+ **os.environ,
+ "XDG_STATE_HOME": str(self.state),
+ "DOCKET_HOME": str(self.project / ".docket"),
+ **env,
+ }
+ return sp.run([sys.executable, str(DOCKET), "context", *args],
+ cwd=self.project, env=environment,
+ capture_output=True, text=True)
+
+ def seed(self, latest):
+ self.state.mkdir(parents=True, exist_ok=True)
+ (self.state / "docket").mkdir(parents=True, exist_ok=True)
+ (self.state / "docket" / "update.json").write_text(json.dumps(
+ {"latest": latest, "checked_at": 0, "failures": 0,
+ "next_check_at": 9_999_999_999}))
+
+ def test_notice_prints_with_an_empty_ledger(self):
+ self.seed("v99.0.0")
+ done = self.run_context()
+ self.assertIn("99.0.0 is available", done.stdout)
+
+ def test_opt_out_suppresses_the_notice(self):
+ self.seed("v99.0.0")
+ done = self.run_context(DOCKET_NO_UPDATE_CHECK="1")
+ self.assertNotIn("is available", done.stdout)
+
+ def test_current_version_prints_nothing_extra(self):
+ self.seed("v0.0.1")
+ done = self.run_context()
+ self.assertNotIn("is available", done.stdout)
+
+ def test_notice_stays_inside_each_harness_envelope(self):
+ self.seed("v99.0.0")
+ envelope_keys = {
+ "gemini": lambda payload: payload["hookSpecificOutput"]["additionalContext"],
+ "copilot": lambda payload: payload["additionalContext"],
+ "cursor": lambda payload: payload["additional_context"],
+ }
+ for harness, extract in envelope_keys.items():
+ with self.subTest(harness=harness):
+ done = self.run_context("--for", harness)
+ payload = json.loads(done.stdout)
+ self.assertIn("99.0.0 is available", extract(payload))
+
+
+class UpdateCommand(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.state = Path(self.tmp.name) / "state"
+
+ def run_update(self, *args):
+ environment = {**os.environ, "XDG_STATE_HOME": str(self.state)}
+ return sp.run([sys.executable, str(DOCKET), "update", *args],
+ env=environment, capture_output=True, text=True)
+
+ def seed(self, latest):
+ (self.state / "docket").mkdir(parents=True, exist_ok=True)
+ (self.state / "docket" / "update.json").write_text(
+ json.dumps({"latest": latest, "next_check_at": 9_999_999_999}))
+
+ def test_check_reports_current(self):
+ self.seed("v0.0.1")
+ done = self.run_update("--check")
+ self.assertEqual(done.returncode, 0)
+ self.assertIn("up to date", done.stdout)
+
+ def test_check_reports_available(self):
+ self.seed("v99.0.0")
+ done = self.run_update("--check")
+ self.assertEqual(done.returncode, 1)
+ self.assertIn("99.0.0", done.stdout)
+
+ def test_check_reports_unknown(self):
+ done = self.run_update("--check")
+ self.assertEqual(done.returncode, 2)
+
+ def test_check_changes_nothing(self):
+ self.seed("v99.0.0")
+ before = (self.state / "docket" / "update.json").read_text()
+ self.run_update("--check")
+ self.assertEqual((self.state / "docket" / "update.json").read_text(), before)
+
+ def test_check_reports_unparseable_cache_as_unknown(self):
+ self.seed("garbage")
+ done = self.run_update("--check")
+ self.assertEqual(done.returncode, 2)
+
+
+class UpdateCommandBranches(unittest.TestCase):
+ """Exercises cmd_update's side-effecting branches via the loaded module."""
+
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ os.environ["XDG_STATE_HOME"] = self.tmp.name
+ self.addCleanup(os.environ.pop, "XDG_STATE_HOME", None)
+ loader = SourceFileLoader("docket_cli_update", str(DOCKET))
+ spec = importlib.util.spec_from_loader("docket_cli_update", loader)
+ self.docket_cli = importlib.util.module_from_spec(spec)
+ loader.exec_module(self.docket_cli)
+ original_call = self.docket_cli.subprocess.call
+ self.addCleanup(setattr, self.docket_cli.subprocess, "call", original_call)
+
+ def args(self):
+ return argparse.Namespace(check=False)
+
+ def test_plugin_shape_invokes_no_harness_call(self):
+ recorded = []
+ root = Path("/home/a/.claude/plugins/cache/NovusEdge/docket/0.8.0")
+ self.docket_cli.subprocess.call = lambda *a, **k: recorded.append((a, k)) or 0
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ rc = self.docket_cli.cmd_update(self.args(), root=root)
+ self.assertEqual(rc, 0)
+ self.assertEqual(recorded, [])
+ printed = buf.getvalue()
+ self.assertIn("claude plugin update docket@NovusEdge -y", printed)
+ self.assertNotIn("docket update", printed)
+
+ def test_unknown_shape_invokes_no_harness_call(self):
+ recorded = []
+ self.docket_cli.subprocess.call = lambda *a, **k: recorded.append((a, k)) or 0
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ rc = self.docket_cli.cmd_update(self.args(), root=self.docket_cli.Path("/home/a/nowhere"))
+ self.assertEqual(rc, 0)
+ self.assertEqual(recorded, [])
+ printed = buf.getvalue()
+ self.assertIn(up.REPOSITORY, printed)
+ self.assertNotIn("docket update", printed)
+
+ def test_source_shape_runs_the_installer_with_checkout(self):
+ recorded = []
+ self.addCleanup(setattr, up, "shape", up.shape)
+ up.shape = lambda root: "source"
+ self.docket_cli.subprocess.call = lambda command, **k: recorded.append(command) or 0
+ rc = self.docket_cli.cmd_update(self.args())
+ self.assertEqual(rc, 0)
+ root = self.docket_cli.Path(self.docket_cli.__file__).resolve().parent.parent
+ self.assertEqual(recorded, [[
+ self.docket_cli.sys.executable, str(root / "installer" / "install.py"),
+ "--checkout", str(root), "--update",
+ ]])
+
+ def test_managed_shape_downloads_the_cached_tag_launcher(self):
+ self.addCleanup(setattr, up, "shape", up.shape)
+ up.shape = lambda root: "managed"
+ up.write_state({"latest": "v0.9.0"})
+ urls = []
+
+ def fake_urlopen(url, timeout=30):
+ urls.append(url)
+ return io.BytesIO(b"# launcher")
+
+ self.docket_cli.subprocess.call = lambda *a, **k: 0
+ with unittest.mock.patch("urllib.request.urlopen", fake_urlopen):
+ rc = self.docket_cli.cmd_update(self.args())
+ self.assertEqual(rc, 0)
+ self.assertEqual(urls, [self.docket_cli.LAUNCHER_URL_TEMPLATE.format(tag="v0.9.0")])
+
+ def test_managed_shape_falls_back_to_main_branch_without_a_cached_tag(self):
+ self.addCleanup(setattr, up, "shape", up.shape)
+ up.shape = lambda root: "managed"
+ urls = []
+
+ def fake_urlopen(url, timeout=30):
+ urls.append(url)
+ return io.BytesIO(b"# launcher")
+
+ self.docket_cli.subprocess.call = lambda *a, **k: 0
+ with unittest.mock.patch("urllib.request.urlopen", fake_urlopen):
+ rc = self.docket_cli.cmd_update(self.args())
+ self.assertEqual(rc, 0)
+ self.assertEqual(urls, [self.docket_cli.MAIN_LAUNCHER_URL])
+
+
+if __name__ == "__main__":
+ unittest.main()