diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c628c1..8a2f0c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ Hardens the destructive surface: `prune` and `sync --force`/`--branch` become ma - **BREAKING:** pool fetch config is now orbit-maintained state. Pools converge to the full wildcard map `+refs/heads/*:refs/remotes/origin/*` plus `fetch.prune=true` — written at clone, re-asserted at every `sync`/`info`/session-start/`prune` touchpoint, **removing any other `remote.origin.fetch` mapping** (per-branch entries from older orbit versions, hand edits, emptied configs all converge; each convergence is reported on stderr as it happens). To keep a custom refspec layout, set `orbit config git.fetchAllBranches once` (write the baseline at birth, never correct it) or `never` (fully self-managed); `git.fetchPrune` takes the same three modes. Visible consequences: `@{u}` / `git status` upstream lines work for every branch with upstream config — scoped or raw, no registration step — and a push materializes the tracking ref on the spot; tracking refs self-clean as branches are deleted upstream (the cleaning runs at the fetching touchpoints — since narrowed to `sync` / `prune`); and a bare `git fetch` or `git pull` in any worktree now pulls every branch's objects (a one-time step onto full-clone footing on huge repos — orbit's own commands still fetch named branches only, so agent/headless paths never trigger it). - **BREAKING:** the `removed stale fetch refspec` / `added fetch refspec` / `would remove` / `would add` output lines are gone, and prune's `pool maintenance:` section no longer carries refspec content — the per-branch registration/reconciliation machinery was deleted outright. In their place, config convergence reports fixed per-key steering lines (`orbit: : fetch config converged: …` / `orbit: : push routing converged: …`) — contract in [`docs/spec-warnings.md`](docs/spec-warnings.md) → Config convergence lines. - **BREAKING:** `push.default=upstream` joins the maintained set — re-asserted at the same touchpoints (scoped local names differ from remote names, so git's default `simple` would refuse a bare `git push`); escape with `orbit config git.pushUpstreamByDefault once` or `never`. And `push.autoSetupRemote` is gone: its only beneficiary was raw-mode bare push, and raw mode's contract is plain git — a fresh raw branch now gets git's native "no upstream" error naming `git push -u`, while the documented explicit `git push origin ` needs no config at all. Scoped mode is unaffected (its upstream is wired by `switch` up front), and the git ≥ 2.37 soft gate drops with the key. +- **BREAKING:** `install.sh` plain install now always refreshes — the marketplace snapshot is updated and the plugin reinstalled from it on every run ("install is latest"), instead of a no-op once present. OpenCode's plain install likewise no longer skips when the plugin file exists. +- **BREAKING:** `install.sh --force` is now a full reset — it removes the plugin **and** the marketplace, then re-adds and reinstalls from the current source (previously: refresh/update in place). Use it to repair a broken plugin state or to switch the marketplace source. The reset first probes source reachability (`git ls-remote` through the retry chain): unreachable ⇒ teardown skipped and the existing install preserved, so an offline `--force` degrades to a plain refresh instead of destroying the last working snapshot. +- **BREAKING:** OpenCode plugin/skill removal is now directory-level: `--force` wipes `~/.config/opencode/skills/orbit/` entirely (so files dropped from older payloads can't linger), and `--uninstall --opencode` removes that directory instead of just `SKILL.md` — anything you placed inside it is deleted. - The prune recovery narrative is stated precisely everywhere (spec-lifecycle Recoverability, USAGE, spec-warnings): recovery is the report's `(was )` plus object survival until gc (`gc.pruneExpire`, two weeks by default) — a deleted branch's own reflog is deleted with it, so the 90-day reflog window never applied post-prune. `core.logAllRefUpdates` / `gc.*` are declared premise-only in the dependency closure — user policy orbit deliberately does not manage. ### Changes by Kind @@ -38,7 +41,6 @@ Hardens the destructive surface: `prune` and `sync --force`/`--branch` become ma #### Feature -- `install.sh --replace-marketplace` — switch plugin marketplace source. ([#16](https://github.com/orbcli/orbit/pull/16)) - `orbit context` redesigned: `--startup` = session-start block, bare = cruise block; key `status` → `state`. ([#17](https://github.com/orbcli/orbit/pull/17)) - Session hooks are thin wrappers; new `session-resume.sh` injects the cruise block. ([#17](https://github.com/orbcli/orbit/pull/17), [#19](https://github.com/orbcli/orbit/pull/19)) - Scoped branch mode is now the default; raw→scoped conversion via `orbit switch -c `. ([#18](https://github.com/orbcli/orbit/pull/18)) @@ -65,9 +67,11 @@ Hardens the destructive surface: `prune` and `sync --force`/`--branch` become ma - `orbit info` and the `orbit context --startup` reignite block no longer fetch — read paths are purely local again (zero network): #29's touchpoint fetch made every `info` and every session start with worktrees pay N serial remote round-trips (the default branch plus each tracked branch, one fetch each), multiplying with pool residue. Ruling: without an async daemon, auto-fetch on a main path taxes a synchronous caller for advisory freshness, and low friction outranks it — auto-fetch may return only off the main path. Layer-1 staleness (`remoteAhead`) now reads last-fetched refs, refreshed by the remaining fetching touchpoints (`orbit sync` / `orbit prune`) or the user's own fetch/pull; fetch-config maintenance (a local write) stays. - Bare `orbit prune` no longer reaps an empty repo's default-branch config: pool maintenance's orphan-config sweep treats the pool HEAD's target branch as always alive (possibly unborn) — its `branch..*` section is first-push routing, not residue. The protection tracks HEAD and self-releases once the branch gains a ref or the pool switches defaults; non-empty repos are unchanged (the ref check already keeps such sections). ([#36](https://github.com/orbcli/orbit/pull/36)) - Session-injection hooks anchor their working directory to the host-injected project dir before workspace detection: hook CWD is not a cross-host contract, so a host running hooks from outside the project silently disabled `` injection for the entire session ("not in a workspace" is a designed silent no-op, so nothing ever surfaced). The shared `session-start.sh` / `session-resume.sh` now `cd` to `CLAUDE_PROJECT_DIR` (Claude Code's documented contract, also injected by Qoder) with `QODER_PROJECT_DIR` as fallback — guarded so empty/unset/invalid values and env-less hosts (codex sets hook CWD correctly by contract) pass through unchanged — and the OpenCode plugin anchors its shell to the SDK's `PluginInput.directory` instead of inheriting the opencode process cwd. ([#37](https://github.com/orbcli/orbit/pull/37)) +- Plugin installs now actually refresh content: every agent CLI exits 0 on `marketplace add` of an already-registered marketplace **without refreshing its snapshot**, so install.sh's `add && return; update` fallback never ran — `--force` reinstalls were green yet installed stale content (one CLI's snapshot lagged six PRs). The marketplace step is now add-then-unconditionally-update (codex gates `upgrade` to git sources; path-backed marketplaces read live), and plugin (re)install re-copies from the refreshed snapshot. #### Removal +- `install.sh --replace-marketplace` — removed before its first release (it entered after v0.1.0 and never shipped): a colliding `marketplace add` already re-points on most agent CLIs, and the one refused direction (codex git→path) is covered by the new `--force` reset. ([#16](https://github.com/orbcli/orbit/pull/16)) - `[seed]` jot sentinel and the gap model — memo state computed inline. ([#17](https://github.com/orbcli/orbit/pull/17)) - Stop hooks and `[nudge]`/`[overlong]` markers — covered by stderr + cruise block + done gate. ([#17](https://github.com/orbcli/orbit/pull/17)) - Fetch-refspec reconciliation (register/remove directions, gating, default-branch exemption) — the wildcard map + `fetch.prune` are maintained instead, and tracked refs converge through native `git remote prune origin`; `orbit switch -c` no longer registers anything. diff --git a/README.md b/README.md index 386e6d9..5208a82 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ git clone https://github.com/orbcli/orbit.git && cd orbit `install.sh` installs the runtime to `~/.local/bin` and puts it on your PATH. To uninstall: `./install.sh --uninstall --all` (or pick targets — `./install.sh --help`). -- **[Reinstall or switch the plugin source](USAGE.md#2-prerequisites)** — `--force` refreshes from the configured source; `--replace-marketplace` re-points it. +- **[Reinstall or switch the plugin source](USAGE.md#2-prerequisites)** — every install refreshes content; `--force` resets plugin and marketplace (also how you switch sources). - **[Flaky or blocked network](USAGE.md#network-resilient-installs)** — retries, source-chain rotation, no silent failures, no hangs. - **[Codex notes](USAGE.md#12-codex-sandbox-escalation)** — plugin hooks need a one-time trust review (`/hooks` in the CLI); Orbit writes to `.repos/` outside the workspace root, which Codex's sandbox may block. - **OpenCode via npm** (alternative) — add `"opencode-orbit"` to the `plugin` array in `opencode.json`; the plugin self-registers its skill, no manual setup needed. diff --git a/USAGE.md b/USAGE.md index 793054c..ca3899d 100644 --- a/USAGE.md +++ b/USAGE.md @@ -39,7 +39,7 @@ The following assumes `orbit` as the command name. For global installation: ./install.sh --zsh ``` -If you use bash, change to `--bash`. To overwrite an existing installation, add `--force`. To switch where a plugin's marketplace points (for example from a local checkout to the public git repo), add `--replace-marketplace` — e.g. `ORBIT_SOURCE=orbcli/orbit ./install.sh --codex --replace-marketplace`. Plain `--force` only refreshes content from the already-configured source; it does not change where the source points. To uninstall: `./install.sh --uninstall --all`, or pick individual targets (`--cli`, `--claude`, `--codex`, …) — `./install.sh --help` lists them. +If you use bash, change to `--bash`. Every install refreshes plugin content — the marketplace snapshot is updated and the plugin reinstalled from it, so re-running the installer is how you pull the latest. To repair a broken plugin state or switch where a plugin's marketplace points (for example from a local checkout to the public git repo), add `--force`: it removes the plugin and marketplace first, then re-adds from the current source — e.g. `ORBIT_SOURCE=orbcli/orbit ./install.sh --codex --force`. (If the source is unreachable, `--force` skips the reset and keeps the existing install instead.) The marketplace snapshot and plugin caches are per-user global state and the agent CLIs take no locks on them — avoid running install.sh in parallel (e.g. CI plus a local run). To uninstall: `./install.sh --uninstall --all`, or pick individual targets (`--cli`, `--claude`, `--codex`, …) — `./install.sh --help` lists them. ### Network-resilient installs diff --git a/install.sh b/install.sh index 4167b0d..7b77cc1 100755 --- a/install.sh +++ b/install.sh @@ -62,7 +62,6 @@ ORBIT_TIMEOUT_SECONDS="${ORBIT_TIMEOUT_SECONDS:-60}" export GIT_TERMINAL_PROMPT=0 FORCE=0 -REPLACE_MP=0 UNINSTALL=0 UNINSTALL_CLI=0 UNINSTALL_ALL=0 @@ -89,7 +88,7 @@ hint_https_source() { # The typical victim here piped install.sh from the network and has no # local checkout — print the curl form (same precedent as completion_hint). printf '%s\n' " ORBIT_SOURCE=https://github.com/$SOURCE.git \\" >&2 - printf '%s\n' " /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/$SOURCE/$ORBIT_REF/install.sh)\" _ $flag --replace-marketplace" >&2 + printf '%s\n' " /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/$SOURCE/$ORBIT_REF/install.sh)\" _ $flag --force" >&2 fi } @@ -221,7 +220,6 @@ chain_advance() { usage() { cat <<'EOF' usage: ./install.sh [--claude] [--codex] [--opencode] [--qoder|--qodercli] [--zsh] [--bash] [--force] - [--replace-marketplace] [--uninstall [--cli] [--all] [--claude] [--codex] [--opencode] [--qoder] [--zsh] [--bash]] Always installs the global `orbit` command to ~/.local/bin and ensures it is on @@ -235,18 +233,12 @@ options: --qodercli alias of --qoder --zsh install zsh tab-completion --bash install bash tab-completion - --force refresh an already-installed plugin: update it in place where the - agent supports it, otherwise remove and reinstall. Without --force, - install only adds/refreshes the marketplace and installs — it never - removes an existing plugin. - --replace-marketplace - re-point the orbit marketplace at the current source before installing. - Removes the plugin and the existing 'orbcli' marketplace, then re-adds - the marketplace from $SOURCE (honoring $ORBIT_REF) and reinstalls the - plugin. Use this to switch an install from a local path to a git repo - (or vice versa) — plain --force only refreshes content within the - already-configured source and does not change where it points. - Implies the remove-then-reinstall behavior of --force. + --force reset an already-installed plugin and its marketplace: remove both, + then re-add and reinstall from the current source. Use it to repair + a broken plugin state or to switch the marketplace source (e.g. + from a git repo to a local path, which some CLIs refuse via a plain + add). Without --force, install always adds/refreshes the marketplace + and (re)installs the plugin — it never removes anything. --help show this message uninstall: @@ -272,7 +264,7 @@ environment: examples: ./install.sh ./install.sh --claude --zsh - ORBIT_SOURCE=orbcli/orbit ./install.sh --codex --replace-marketplace + ORBIT_SOURCE=orbcli/orbit ./install.sh --codex --force ./install.sh --uninstall --claude --codex ./install.sh --uninstall --all /bin/bash -c "$(curl -fsSL REMOTE/install.sh)" @@ -295,7 +287,6 @@ while [ "$#" -gt 0 ]; do --zsh) INSTALL_ZSH=1; shift ;; --bash) INSTALL_BASH=1; shift ;; --force) FORCE=1; shift ;; - --replace-marketplace) REPLACE_MP=1; shift ;; --uninstall) UNINSTALL=1; shift ;; --cli) UNINSTALL_CLI=1; shift ;; --all) UNINSTALL_ALL=1; shift ;; @@ -398,58 +389,80 @@ install_cli() { printf '%s\n' "Installed orbit command to: $TARGET_HELPER" } -# Point the marketplace at $SOURCE (fresh add) or refresh an existing snapshot. +# Point the marketplace at $SOURCE (fresh add) and refresh the snapshot. +# `add` exits 0 without refreshing when the marketplace already exists — a +# successful add says nothing about freshness — so `update` runs +# unconditionally; it is the step that actually pulls new content. claude_marketplace_ensure() { - claude plugin marketplace add "$SOURCE" && return 0 + claude plugin marketplace add "$SOURCE" || true claude plugin marketplace update orbcli } -# After marketplace add/update fails through every retry: warn and let the +# After the marketplace step fails through every retry: warn and let the # plugin install be the decider. We deliberately do NOT parse `marketplace # list` output (three CLIs, three unstable formats — not worth the # maintenance): an existing local snapshot installs fine offline, and if # 'orbcli' was never installed the plugin install below fails on its own with -# a clear error — the root cause is the add failure, not the install. +# a clear error — the root cause is the marketplace step, not the install. +# $1 names the operation pair ("add/update" or "add/upgrade") per CLI. marketplace_warn() { - printf '%s\n' "warning: marketplace add/update failed (see the error above). If 'orbcli' was never" >&2 + local op="${1:-add/update}" + printf '%s\n' "warning: marketplace $op failed (see the error above). If 'orbcli' was never" >&2 printf '%s\n' " installed on this machine, the plugin install below will fail too — the root" >&2 - printf '%s\n' " cause is the add error, not the install." >&2 + printf '%s\n' " cause is the marketplace step, not the install. To reset both plugin and" >&2 + printf '%s\n' " marketplace, re-run with --force." >&2 +} + +# --force removes state before re-adding, so for a network source prove the +# source is reachable FIRST — with the same git transport the CLIs' adds use, +# through net_run's retry/rotation and output surfacing. On failure the +# teardown is skipped and the existing plugin/marketplace are preserved +# (offline --force must not destroy the last working snapshot). Path sources +# are local and need no probe. +force_source_reachable() { + [ "$SOURCE_TYPE" = "path" ] && return 0 + local url="$SOURCE" + [ "$SOURCE_TYPE" = "repo" ] && url="https://github.com/$SOURCE.git" + net_run "probe: git ls-remote $url" git ls-remote "$url" HEAD } install_claude_plugin() { command -v claude >/dev/null 2>&1 || fail "claude CLI not found; install Claude Code first" [ "$SOURCE_TYPE" != "path" ] || [ -f "$SOURCE/.claude-plugin/marketplace.json" ] \ || fail "marketplace manifest not found: $SOURCE/.claude-plugin/marketplace.json" - # --replace-marketplace: tear down plugin + existing marketplace, then re-point - # 'orbcli' at $SOURCE. This is the only way to switch the source (e.g. local - # path -> git repo): a plain `marketplace add` collides with the existing name - # and falls back to `update`, which refreshes the OLD source, never re-points it. - if [ "$REPLACE_MP" -eq 1 ]; then - claude plugin uninstall claude-orbit -y >/dev/null 2>&1 || true - claude plugin marketplace remove orbcli >/dev/null 2>&1 || true + # --force: full reset — remove plugin and marketplace, then the normal flow + # re-adds fresh. The teardown runs only after the source proves reachable + # (see force_source_reachable): an offline --force skips it and keeps the + # existing install intact. Removal output stays visible on purpose — only + # the exit code is tolerated (a missing target is expected on first reset). + # No -y on the uninstall: that flag only skips the --prune confirmation + # prompt, which a bare uninstall never triggers (and some CLIs reject it). + if [ "$FORCE" -eq 1 ]; then + if force_source_reachable; then + claude plugin uninstall claude-orbit &2 + fi fi - # `add` fails on a name collision (expected when the marketplace is already - # present) — the `update` fallback covers that. If BOTH fail through every - # retry it's a real error (network, bad source) and net_run has printed the - # CLI's own message; warn and let the install decide. + # If add/update both fail through every retry it's a real error (network, + # bad source) and net_run has printed the CLI's own message; warn and let + # the install decide. if ! net_run "claude plugin marketplace add/update (source: $SOURCE)" claude_marketplace_ensure; then marketplace_warn fi - # Claude has no `plugin update`, so --force is a remove-then-install; a refreshed - # marketplace snapshot (above) is what actually carries new content. Plain install - # never removes — it just (re)installs, which is a no-op if already present. - # --replace-marketplace implies the same remove-then-install (source changed). - if [ "$FORCE" -eq 1 ] || [ "$REPLACE_MP" -eq 1 ]; then - claude plugin uninstall claude-orbit -y >/dev/null 2>&1 || true - fi + # A plugin (re)install re-copies content from the current marketplace + # snapshot, so a plain install already refreshes content — no remove + # needed outside --force. net_run "claude plugin install claude-orbit@orbcli" claude plugin install "claude-orbit@orbcli" \ || { hint_https_source --claude; hint_local_source --claude; fail "claude plugin install failed"; } printf '%s\n' "Installed Orbit plugin into Claude Code" } -# Point the marketplace at $SOURCE (fresh add) or refresh an existing snapshot. +# Point the marketplace at $SOURCE (fresh add) and refresh the snapshot — +# same add-then-always-update flow as claude_marketplace_ensure. qoder_marketplace_ensure() { - qodercli plugins marketplace add "$SOURCE" && return 0 + qodercli plugins marketplace add "$SOURCE" || true qodercli plugins marketplace update orbcli } @@ -457,43 +470,31 @@ install_qoder_plugin() { command -v qodercli >/dev/null 2>&1 || fail "qodercli not found; install the Qoder CLI first" [ "$SOURCE_TYPE" != "path" ] || [ -f "$SOURCE/.qoder-plugin/plugin.json" ] \ || fail "plugin manifest not found: $SOURCE/.qoder-plugin/plugin.json" - # --replace-marketplace: tear down plugin + existing marketplace before re-adding - # from $SOURCE (see install_claude_plugin for why plain add can't switch source). - if [ "$REPLACE_MP" -eq 1 ]; then - qodercli plugins uninstall "qoder-orbit@orbcli" -s user >/dev/null 2>&1 || true - qodercli plugins marketplace remove orbcli >/dev/null 2>&1 || true + # --force: full reset — remove plugin and marketplace, then re-add fresh. + # Teardown is gated on source reachability (see force_source_reachable). + if [ "$FORCE" -eq 1 ]; then + if force_source_reachable; then + qodercli plugins uninstall "qoder-orbit@orbcli" -s user &2 + fi fi # Same add/update flow as install_claude_plugin: on total failure print the # causal warning and let the plugin install be the decider. if ! net_run "qodercli plugins marketplace add/update (source: $SOURCE)" qoder_marketplace_ensure; then marketplace_warn fi - if [ "$FORCE" -eq 1 ] || [ "$REPLACE_MP" -eq 1 ]; then - # qodercli has a real `plugins update`, so --force updates in place first; - # only fall back to remove-then-install if the update path does not apply - # (e.g. the plugin is not installed yet). For --replace-marketplace we skip - # the in-place update (the source changed) and force a clean reinstall. - if [ "$REPLACE_MP" -eq 0 ] && qodercli plugins update "qoder-orbit@orbcli" -s user >/dev/null 2>&1; then - printf '%s\n' "Updated Orbit plugin via qodercli" - return 0 - fi - qodercli plugins uninstall "qoder-orbit@orbcli" -s user >/dev/null 2>&1 || true - fi - # Plain install never removes; it just installs (no-op if already present). + # A plugin (re)install re-copies from the marketplace cache, so plain + # install already refreshes content — no separate update step needed. net_run "qodercli plugins install qoder-orbit@orbcli" qodercli plugins install "qoder-orbit@orbcli" -s user \ || { hint_https_source --qoder; hint_local_source --qoder; fail "qodercli plugin install failed"; } printf '%s\n' "Installed Orbit plugin via qodercli" } -# Point the marketplace at $SOURCE (fresh add) or refresh an existing snapshot. -codex_marketplace_ensure() { - codex plugin marketplace add "$SOURCE" && return 0 - codex plugin marketplace upgrade orbcli -} - -# Re-add after a --replace-marketplace teardown; honors $ORBIT_REF for -# git/repo sources via --ref (verified codex flag). -codex_marketplace_readd() { +# Add the marketplace, honoring $ORBIT_REF for git/repo sources via --ref +# (path sources take no ref). +codex_marketplace_add() { if [ "$SOURCE_TYPE" = "path" ]; then codex plugin marketplace add "$SOURCE" else @@ -501,6 +502,23 @@ codex_marketplace_readd() { fi } +# Point the marketplace at $SOURCE and refresh the snapshot. `add` exits 0 +# without refreshing when the marketplace already exists, while `upgrade` +# errors on path-backed marketplaces ("not configured as a Git marketplace") +# — those read live and need no refresh. So gate upgrade on our own +# SOURCE_TYPE: after a successful add the marketplace's type always matches +# it (a colliding add no-ops on the same source or re-points). Propagate +# add's failure when skipping upgrade, so a refused add (e.g. a git->path +# switch, which needs --force) stays visible. +codex_marketplace_ensure() { + add_failed=0; codex_marketplace_add || add_failed=1 + if [ "$SOURCE_TYPE" != "path" ]; then + codex plugin marketplace upgrade orbcli + else + [ "$add_failed" -eq 0 ] + fi +} + install_codex_plugin() { command -v codex >/dev/null 2>&1 || fail "codex CLI not found; install Codex first" # Codex reads a repo marketplace from .agents/plugins/marketplace.json (its @@ -510,31 +528,27 @@ install_codex_plugin() { # collide. [ "$SOURCE_TYPE" != "path" ] || [ -f "$SOURCE/.agents/plugins/marketplace.json" ] \ || fail "marketplace manifest not found: $SOURCE/.agents/plugins/marketplace.json" - # --replace-marketplace: tear down plugin + existing marketplace, then re-add - # 'orbcli' from $SOURCE. Needed to switch source (e.g. local path -> git repo): - # a plain `marketplace add` collides on the existing name and falls back to - # `upgrade`, which refreshes the OLD source rather than re-pointing it. - if [ "$REPLACE_MP" -eq 1 ]; then - codex plugin remove "codex-orbit@orbcli" >/dev/null 2>&1 || true - codex plugin marketplace remove orbcli >/dev/null 2>&1 || true - # The marketplace was just removed, so a re-add that fails through every - # retry is certainly fatal — fail now with the real error already printed. - net_run "codex plugin marketplace add (source: $SOURCE)" codex_marketplace_readd \ - || { hint_https_source --codex; hint_local_source --codex; fail "codex plugin marketplace add failed (source: $SOURCE)"; } - else - # Same add/upgrade flow as install_claude_plugin: on total failure print - # the causal warning and let the plugin add be the decider. - if ! net_run "codex plugin marketplace add/upgrade (source: $SOURCE)" codex_marketplace_ensure; then - marketplace_warn + # --force: full reset — remove plugin and marketplace, then re-add fresh. + # This is also the way to switch sources (e.g. git repo -> local path): + # codex refuses a colliding add from a different source in that direction. + # Teardown is gated on source reachability (see force_source_reachable). + if [ "$FORCE" -eq 1 ]; then + if force_source_reachable; then + codex plugin remove "codex-orbit@orbcli" &2 fi fi - # Codex has no `plugin update`, so --force is a remove-then-install; the refreshed - # marketplace snapshot (above) is what carries new content. Plain install never - # removes — `plugin add` is a no-op if already present. - # --replace-marketplace implies the same clean reinstall (source changed). - if [ "$FORCE" -eq 1 ] || [ "$REPLACE_MP" -eq 1 ]; then - codex plugin remove "codex-orbit@orbcli" >/dev/null 2>&1 || true + # Same add/upgrade flow as install_claude_plugin, except `upgrade` only + # applies to git sources (see codex_marketplace_ensure). On total failure + # print the causal warning and let the plugin add be the decider. + if ! net_run "codex plugin marketplace add/upgrade (source: $SOURCE)" codex_marketplace_ensure; then + marketplace_warn "add/upgrade" fi + # `plugin add` re-materializes from the current snapshot and `marketplace + # upgrade` already cascades to installed plugins — no remove needed + # outside --force. net_run "codex plugin add codex-orbit@orbcli" codex plugin add "codex-orbit@orbcli" \ || { hint_https_source --codex; hint_local_source --codex; fail "codex plugin add failed"; } printf '%s\n' "Installed Orbit plugin into Codex" @@ -586,24 +600,27 @@ install_opencode_plugin() { local plugin_dir="$HOME/.config/opencode/plugins" local skill_dir="$HOME/.config/opencode/skills/orbit" - mkdir -p "$plugin_dir" "$skill_dir" - - # OpenCode has no marketplace/CLI — the plugin is a copied file. Mirror the - # install vs --force policy of the CLI agents: - # plain install : skip if already present (never delete), else copy in. - # --force : remove the old file, then copy the current one (reinstall). - # OpenCode has no marketplace, so --replace-marketplace has nothing to re-point; - # treat it like --force (reinstall the copied file) rather than erroring out. - if [ -f "$plugin_dir/orbit.ts" ] && [ "$FORCE" -eq 0 ] && [ "$REPLACE_MP" -eq 0 ]; then - printf '%s\n' "OpenCode plugin already installed at $plugin_dir/orbit.ts — skipping (use --force to reinstall)" - return 0 - fi - if [ "$FORCE" -eq 1 ] || [ "$REPLACE_MP" -eq 1 ]; then + # OpenCode has no marketplace/CLI — the plugin is a copied file, so a plain + # install always refreshes (overwrite in place). --force additionally wipes + # the skill directory first, so files dropped from older payloads can't + # linger. The skill dir is ours alone (plugins/ is shared with other + # plugins). The case guard is a fail-closed invariant: it only ever matches + # the literal assignment above — its job is to turn a future bad edit of + # that assignment into a refusal instead of a stray rm -rf (literal path, + # no trailing slash: a symlink is unlinked, never followed). + if [ "$FORCE" -eq 1 ]; then rm -f "$plugin_dir/orbit.ts" + case "$skill_dir" in + "$HOME"/.config/opencode/skills/orbit) rm -rf "$skill_dir" ;; + *) fail "refusing to remove unexpected skill dir: $skill_dir" ;; + esac fi + mkdir -p "$plugin_dir" "$skill_dir" - cp "$plugin_src" "$plugin_dir/orbit.ts" - cp "$skill_src" "$skill_dir/SKILL.md" + # Copy atomically: a plain cp is truncate-then-write, so a mid-copy failure + # would leave a broken plugin/skill behind on the next host start. + cp "$plugin_src" "$plugin_dir/orbit.ts.tmp" && mv "$plugin_dir/orbit.ts.tmp" "$plugin_dir/orbit.ts" + cp "$skill_src" "$skill_dir/SKILL.md.tmp" && mv "$skill_dir/SKILL.md.tmp" "$skill_dir/SKILL.md" printf '%s\n' "Installed Orbit plugin into OpenCode ($plugin_dir/orbit.ts)" printf '%s\n' "Installed Orbit skill into OpenCode ($skill_dir/SKILL.md)" @@ -701,28 +718,28 @@ uninstall_cli() { uninstall_claude_plugin() { command -v claude >/dev/null 2>&1 || { printf '%s\n' "claude CLI not found — skipping"; return 0; } - claude plugin uninstall claude-orbit -y >/dev/null 2>&1 || true - claude plugin marketplace remove orbcli >/dev/null 2>&1 || true + claude plugin uninstall claude-orbit /dev/null 2>&1 || { printf '%s\n' "codex CLI not found — skipping"; return 0; } - codex plugin remove "codex-orbit@orbcli" >/dev/null 2>&1 || true - codex plugin marketplace remove orbcli >/dev/null 2>&1 || true + codex plugin remove "codex-orbit@orbcli" /dev/null 2>&1 || { printf '%s\n' "qodercli not found — skipping"; return 0; } - qodercli plugins uninstall "qoder-orbit@orbcli" -s user >/dev/null 2>&1 || true - qodercli plugins marketplace remove orbcli >/dev/null 2>&1 || true + qodercli plugins uninstall "qoder-orbit@orbcli" -s user "$MOCK_BIN/claude" <<'EOF' +#!/usr/bin/env bash +case "$1 $2 $3" in + "plugin marketplace add") echo "mp-add" >> "$MOCK_STATE/calls"; exit 0 ;; # already exists: exit 0, no refresh + "plugin marketplace update") echo "mp-update" >> "$MOCK_STATE/calls"; exit 0 ;; + "plugin install"*) exit 0 ;; + "plugin uninstall"*) exit 0 ;; +esac +exit 0 +EOF + chmod +x "$MOCK_BIN/claude" + run_install_mocked ORBIT_SOURCE="${BATS_TEST_DIRNAME}/.." \ + ORBIT_RETRY=1 bash "$INSTALL" --claude + [ "$status" -eq 0 ] + [ "$(grep -c 'mp-update' "$MOCK_STATE/calls")" -eq 1 ] + assert_contains "$output" "Installed Orbit plugin into Claude Code" +} + +@test "marketplace: an existing marketplace is always refreshed (qoder)" { + cat > "$MOCK_BIN/qodercli" <<'EOF' +#!/usr/bin/env bash +case "$1 $2 $3" in + "plugins marketplace add") echo "mp-add" >> "$MOCK_STATE/calls"; exit 0 ;; + "plugins marketplace update") echo "mp-update" >> "$MOCK_STATE/calls"; exit 0 ;; + "plugins install"*) exit 0 ;; + "plugins uninstall"*) exit 0 ;; +esac +exit 0 +EOF + chmod +x "$MOCK_BIN/qodercli" + run_install_mocked ORBIT_SOURCE="${BATS_TEST_DIRNAME}/.." \ + ORBIT_RETRY=1 bash "$INSTALL" --qoder + [ "$status" -eq 0 ] + [ "$(grep -c 'mp-update' "$MOCK_STATE/calls")" -eq 1 ] # qoder's path cache only refreshes via update + assert_contains "$output" "Installed Orbit plugin via qodercli" +} + +@test "marketplace: codex skips upgrade for a path source (upgrade is git-only)" { + cat > "$MOCK_BIN/codex" <<'EOF' +#!/usr/bin/env bash +case "$1 $2 $3" in + "plugin marketplace add") echo "mp-add" >> "$MOCK_STATE/calls"; exit 0 ;; + "plugin marketplace upgrade") echo "mp-upgrade" >> "$MOCK_STATE/calls"; exit 0 ;; + "plugin add"*) echo "plugin-add" >> "$MOCK_STATE/calls"; exit 0 ;; + "plugin remove"*) exit 0 ;; +esac +exit 0 +EOF + chmod +x "$MOCK_BIN/codex" + run_install_mocked ORBIT_SOURCE="${BATS_TEST_DIRNAME}/.." \ + ORBIT_RETRY=1 bash "$INSTALL" --codex + [ "$status" -eq 0 ] + grep -q 'mp-add' "$MOCK_STATE/calls" + ! grep -q 'mp-upgrade' "$MOCK_STATE/calls" # path-backed marketplaces read live + grep -q 'plugin-add' "$MOCK_STATE/calls" +} + +@test "marketplace: codex surfaces a refused path-source add (git->path switch needs --force)" { + cat > "$MOCK_BIN/codex" <<'EOF' +#!/usr/bin/env bash +case "$1 $2 $3" in + "plugin marketplace add") + echo "mp-add" >> "$MOCK_STATE/calls" + echo "Error: marketplace 'orbcli' is already added from a different source; remove it before adding this source (fake)" >&2 + exit 1 ;; + "plugin marketplace upgrade") echo "mp-upgrade" >> "$MOCK_STATE/calls"; exit 0 ;; + "plugin add"*) exit 0 ;; +esac +exit 0 +EOF + chmod +x "$MOCK_BIN/codex" + run_install_mocked ORBIT_SOURCE="${BATS_TEST_DIRNAME}/.." \ + ORBIT_RETRY=2 ORBIT_RETRY_DELAY_SECONDS=0 bash "$INSTALL" --codex + [ "$status" -eq 0 ] # warn-and-continue: the plugin add proceeds from the old source + assert_contains "$output" "already added from a different source" # the CLI's own message survives + assert_contains "$output" "marketplace add/upgrade failed" # codex's own op pair in the warning + assert_contains "$output" "--force" # the warning names the reset path + ! grep -q 'mp-upgrade' "$MOCK_STATE/calls" # gate held: no upgrade for a path source +} + +@test "--force: claude resets plugin and marketplace before re-adding (path source, no probe)" { + cat > "$MOCK_BIN/claude" <<'EOF' +#!/usr/bin/env bash +case "$1 $2 $3" in + "plugin uninstall"*) echo "plugin-uninstall" >> "$MOCK_STATE/calls"; exit 0 ;; + "plugin marketplace remove") echo "mp-remove" >> "$MOCK_STATE/calls"; exit 0 ;; + "plugin marketplace add") echo "mp-add" >> "$MOCK_STATE/calls"; exit 0 ;; + "plugin marketplace update") echo "mp-update" >> "$MOCK_STATE/calls"; exit 0 ;; + "plugin install"*) echo "plugin-install" >> "$MOCK_STATE/calls"; exit 0 ;; +esac +exit 0 +EOF + chmod +x "$MOCK_BIN/claude" + run_install_mocked ORBIT_SOURCE="${BATS_TEST_DIRNAME}/.." \ + ORBIT_RETRY=1 bash "$INSTALL" --claude --force + [ "$status" -eq 0 ] + # reset precedes the re-add, and the refresh still runs: + [ "$(sed -n '1p' "$MOCK_STATE/calls")" = "plugin-uninstall" ] + [ "$(sed -n '2p' "$MOCK_STATE/calls")" = "mp-remove" ] + grep -q 'mp-add' "$MOCK_STATE/calls" + grep -q 'mp-update' "$MOCK_STATE/calls" + grep -q 'plugin-install' "$MOCK_STATE/calls" +} + +@test "--force: codex resets plugin and marketplace before re-adding (path source)" { + cat > "$MOCK_BIN/codex" <<'EOF' +#!/usr/bin/env bash +case "$1 $2 $3" in + "plugin remove"*) echo "plugin-remove" >> "$MOCK_STATE/calls"; exit 0 ;; + "plugin marketplace remove") echo "mp-remove" >> "$MOCK_STATE/calls"; exit 0 ;; + "plugin marketplace add") echo "mp-add" >> "$MOCK_STATE/calls"; exit 0 ;; + "plugin marketplace upgrade") echo "mp-upgrade" >> "$MOCK_STATE/calls"; exit 0 ;; + "plugin add"*) echo "plugin-add" >> "$MOCK_STATE/calls"; exit 0 ;; +esac +exit 0 +EOF + chmod +x "$MOCK_BIN/codex" + run_install_mocked ORBIT_SOURCE="${BATS_TEST_DIRNAME}/.." \ + ORBIT_RETRY=1 bash "$INSTALL" --codex --force + [ "$status" -eq 0 ] + [ "$(sed -n '1p' "$MOCK_STATE/calls")" = "plugin-remove" ] + [ "$(sed -n '2p' "$MOCK_STATE/calls")" = "mp-remove" ] + grep -q 'mp-add' "$MOCK_STATE/calls" + ! grep -q 'mp-upgrade' "$MOCK_STATE/calls" # gate held: path source skips upgrade + grep -q 'plugin-add' "$MOCK_STATE/calls" +} + +@test "--force: skips the reset when the source is unreachable (offline)" { + # git fails ls-remote (offline); claude behaves like a missing/stale setup + cat > "$MOCK_BIN/git" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "ls-remote" ]; then echo "fatal: unable to connect (fake)" >&2; exit 128; fi +exec /usr/bin/git "$@" +EOF + chmod +x "$MOCK_BIN/git" + write_fake_claude + write_fake_curl + touch "$MOCK_STATE/calls" # the no-removal assertions below must not be vacuous + run_install_mocked ORBIT_SOURCE=orbcli/orbit ORBIT_RETRY=2 ORBIT_RETRY_DELAY_SECONDS=0 \ + CLAUDE_INSTALL_OK=1 bash "$INSTALL" --claude --force + [ "$status" -eq 0 ] + assert_contains "$output" "--force reset skipped (source unreachable)" + ! grep -q 'plugin-uninstall' "$MOCK_STATE/calls" + ! grep -q 'mp-remove' "$MOCK_STATE/calls" + assert_contains "$output" "Installed Orbit plugin into Claude Code" # offline install from the existing snapshot +} + +@test "--force: probes reachability and resets when the source answers (git source)" { + cat > "$MOCK_BIN/git" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "ls-remote" ]; then echo "ls-remote" >> "$MOCK_STATE/calls"; exit 0; fi +exec /usr/bin/git "$@" +EOF + chmod +x "$MOCK_BIN/git" + cat > "$MOCK_BIN/claude" <<'EOF' +#!/usr/bin/env bash +case "$1 $2 $3" in + "plugin uninstall"*) echo "plugin-uninstall" >> "$MOCK_STATE/calls"; exit 0 ;; + "plugin marketplace remove") echo "mp-remove" >> "$MOCK_STATE/calls"; exit 0 ;; + "plugin marketplace add") echo "mp-add" >> "$MOCK_STATE/calls"; exit 0 ;; + "plugin marketplace update") echo "mp-update" >> "$MOCK_STATE/calls"; exit 0 ;; + "plugin install"*) echo "plugin-install" >> "$MOCK_STATE/calls"; exit 0 ;; +esac +exit 0 +EOF + chmod +x "$MOCK_BIN/claude" + write_fake_curl + run_install_mocked ORBIT_SOURCE=orbcli/orbit ORBIT_RETRY=1 bash "$INSTALL" --claude --force + [ "$status" -eq 0 ] + [ "$(sed -n '1p' "$MOCK_STATE/calls")" = "ls-remote" ] + [ "$(sed -n '2p' "$MOCK_STATE/calls")" = "plugin-uninstall" ] + [ "$(sed -n '3p' "$MOCK_STATE/calls")" = "mp-remove" ] + grep -q 'mp-add' "$MOCK_STATE/calls" + grep -q 'plugin-install' "$MOCK_STATE/calls" +} + +@test "opencode: plain install always refreshes (no skip when files exist)" { + mkdir -p "$FAKE_HOME/.config/opencode/plugins" "$FAKE_HOME/.config/opencode/skills/orbit" + printf 'stale-plugin\n' > "$FAKE_HOME/.config/opencode/plugins/orbit.ts" + printf 'stale-skill\n' > "$FAKE_HOME/.config/opencode/skills/orbit/SKILL.md" + run_install_mocked ORBIT_SOURCE="${BATS_TEST_DIRNAME}/.." \ + ORBIT_RETRY=1 bash "$INSTALL" --opencode + [ "$status" -eq 0 ] + refute_contains "$output" "skipping" + ! grep -q 'stale-plugin' "$FAKE_HOME/.config/opencode/plugins/orbit.ts" + ! grep -q 'stale-skill' "$FAKE_HOME/.config/opencode/skills/orbit/SKILL.md" +} + +@test "opencode: --force wipes files dropped from older payloads (dir-level reset)" { + mkdir -p "$FAKE_HOME/.config/opencode/skills/orbit/references" + printf 'orphan\n' > "$FAKE_HOME/.config/opencode/skills/orbit/references/old.md" + run_install_mocked ORBIT_SOURCE="${BATS_TEST_DIRNAME}/.." \ + ORBIT_RETRY=1 bash "$INSTALL" --opencode --force + [ "$status" -eq 0 ] + [ ! -e "$FAKE_HOME/.config/opencode/skills/orbit/references/old.md" ] + [ -f "$FAKE_HOME/.config/opencode/skills/orbit/SKILL.md" ] +} + # --- source chain (ORBIT_SOURCES; single source never rotates) ------------- # # These run a COPY of install.sh from the sandbox: with no orbit.sh next to @@ -317,8 +518,13 @@ case "$1 $2 $3" in n=$(( $(cat "$MOCK_STATE/add-count" 2>/dev/null || echo 0) + 1 )) echo "$n" > "$MOCK_STATE/add-count" if [ "$n" -eq 1 ]; then echo "fatal: unable to connect (fake)" >&2; exit 128; fi + touch "$MOCK_STATE/add-ok" # marketplace now exists exit 0 ;; - "plugin marketplace update") exit 1 ;; + "plugin marketplace update") + # state-aware: update can only refresh a marketplace that exists — i.e. + # after a successful add (this is what forces a retry when add fails). + if [ -f "$MOCK_STATE/add-ok" ]; then echo "mp-update" >> "$MOCK_STATE/calls"; exit 0; fi + echo "error: no such marketplace (fake)" >&2; exit 1 ;; "plugin install"*) exit 0 ;; "plugin uninstall"*) exit 0 ;; esac @@ -332,6 +538,8 @@ EOF [ "$(grep -c 'mp-add' "$MOCK_STATE/calls")" -eq 2 ] [ "$(grep 'mp-add' "$MOCK_STATE/calls" | sed -n '1p')" = "mp-add orbcli/orbit" ] [ "$(grep 'mp-add' "$MOCK_STATE/calls" | sed -n '2p')" = "mp-add https://github.com/orbcli/orbit.git" ] + # update runs exactly once — after the add that succeeded: + [ "$(grep -c 'mp-update' "$MOCK_STATE/calls")" -eq 1 ] } @test "default: no ORBIT_SOURCES means a single source and no rotation" { diff --git a/try.sh b/try.sh index 3a649ff..31eddf7 100755 --- a/try.sh +++ b/try.sh @@ -453,7 +453,7 @@ if [ "$AGENT" = claude ]; then elif [ "$AGENT" = codex ]; then PLUGIN_UNINSTALL=" codex plugin remove orbit@orbcli # Orbit plugin (lives in Codex)" elif [ "$AGENT" = opencode ]; then - PLUGIN_UNINSTALL=" rm ~/.config/opencode/plugins/orbit.ts ~/.config/opencode/skills/orbit/SKILL.md # Orbit plugin (lives in OpenCode)" + PLUGIN_UNINSTALL=" rm -rf ~/.config/opencode/skills/orbit && rm -f ~/.config/opencode/plugins/orbit.ts # Orbit plugin (lives in OpenCode)" elif [ "$AGENT" = qodercli ]; then PLUGIN_UNINSTALL=" qodercli plugins uninstall orbit@orbcli -s user # Orbit plugin (lives in Qoder)" else