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()