Install as openagents, coder, and oa

c5c3173a05bb · AtlantisPleb · · parent 778a69d0c70c

Install as openagents, coder, and oa

The `oa`-only rule existed because the Rust binary answered a strict subset of
the CLI's commands, so taking the `openagents` name would have swapped a
fraction of the CLI in underneath every script that called it. That is no
longer true: since the binary became coder-lite it dispatches the whole command
set and is the coder session besides. It earns the name.

So the installer writes three names for one binary. `openagents` and `coder`
are two doors onto the same thing -- bare it is the session, given a command it
runs that command -- and `oa` is the short one.

The closing output is one line. It had grown a shadow warning, a PATH
comparison, a note about a different CLI and an npm pointer: four paragraphs of
apology on a successful install. The tests now refute that copy rather than
assert it, so it cannot come back quietly.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 448 · 2026-08-26T14:49:25.654221Z

Changed files

  • modified priv/static/install.sh
  • modified test/openagents_web/install_script_test.exs

Diff

2 files changed, +37 -175

priv/static/install.sh modified +13 -89

@@ -258,7 +258,6 @@ if ! download_file_parallel "$artifact_base" "$binary_tmp" 2>/dev/null; then

258 258
    echo "No local fallback is used: an installer must install what it says it did." >&2
259 259
    exit 1
260 260
fi
261
echo "  Downloaded openagents ${version}." >&2
262 261
263 262
# Verify before the bytes are ever made executable. A checksum fetched over the
264 263
# same connection as the artifact proves only that they arrived together, which

@@ -303,39 +302,17 @@ fi

303 302
rm -f "$sums_tmp"
304 303
echo "  Verified sha256 ${actual}." >&2
305 304
306
# This binary installs as `oa` and never as `openagents`.
305
# Installs three names for one binary: `openagents`, `coder`, and `oa`.
307 306
#
308
# `openagents` is the TypeScript CLI, and it is the one scripts, agent tool
309
# calls, and the coder's own `openagents` tool invoke by name. This binary
310
# implements a strict subset of its commands, so taking that name silently
311
# swaps a fraction of the CLI in underneath everything that calls it, and the
312
# failure surfaces far from the cause -- `unrecognized subcommand 'reopen'`
313
# from a script that has always worked. Taking the name is a parity claim, and
314
# it is not one this binary can make yet. See openagents#90.
315
#
316
# A previous version of this installer did take it. Undo that here, and only
317
# where the link points into our own install -- never touch an `openagents`
318
# that resolves to something we did not put there.
319
reclaim_openagents_name() {
320
    for dir in "$BIN_DIR" "$HOME/.local/bin" "/usr/local/bin"; do
321
        link="$dir/openagents"
322
        [ -L "$link" ] || continue
323
        target="$(readlink "$link" 2>/dev/null || true)"
324
        case "$target" in
325
            *"$(basename "$DOWNLOAD_DIR")"/openagents-*|"$BIN_DIR"/oa|"$BIN_DIR"/openagents|../*/openagents-*)
326
                rm -f "$link" 2>/dev/null || true
327
                echo "  Removed $link, which shadowed the openagents CLI." >&2
328
                ;;
329
        esac
330
    done
331
}
332
307
# The binary answers both surfaces -- bare it is the coder session, and given a
308
# command it dispatches to that command -- so `coder` and `openagents` are two
309
# doors onto the same thing, and `oa` is the short one.
333 310
if [ "$os" = "windows" ]; then
334 311
    mv -f "$binary_tmp" "$binary_path"
335
    rm -f "$BIN_DIR/oa.exe.old" 2>/dev/null || true
336
    cp -f "$binary_path" "$BIN_DIR/oa.exe" 2>/dev/null || true
337
    rm -f "$BIN_DIR/openagents.exe" 2>/dev/null || true
338
    echo "  Binary installed to $BIN_DIR/oa.exe." >&2
312
    for name in openagents coder oa; do
313
        rm -f "$BIN_DIR/$name.exe.old" 2>/dev/null || true
314
        cp -f "$binary_path" "$BIN_DIR/$name.exe" 2>/dev/null || true
315
    done
339 316
else
340 317
    chmod +x "$binary_tmp"
341 318
    mv -f "$binary_tmp" "$binary_path"

@@ -345,9 +322,9 @@ else

345 322
    else
346 323
        link_target="$binary_path"
347 324
    fi
325
    ln -sf "$link_target" "$BIN_DIR/openagents"
326
    ln -sf "$link_target" "$BIN_DIR/coder"
348 327
    ln -sf "$link_target" "$BIN_DIR/oa"
349
    reclaim_openagents_name
350
    echo "  Binary linked to $BIN_DIR/oa." >&2
351 328
fi
352 329
353 330
path_has_dir() {

@@ -358,9 +335,10 @@ SYMLINK_CREATED=""

358 335
if [ "$os" != "windows" ] && ! path_has_dir "$BIN_DIR"; then
359 336
    for candidate in "$HOME/.local/bin" "/usr/local/bin"; do
360 337
        if path_has_dir "$candidate" && [ -d "$candidate" ] && [ -w "$candidate" ]; then
338
            ln -sf "$BIN_DIR/openagents" "$candidate/openagents"
339
            ln -sf "$BIN_DIR/coder" "$candidate/coder"
361 340
            ln -sf "$BIN_DIR/oa" "$candidate/oa"
362 341
            SYMLINK_CREATED="$candidate"
363
            echo "  Symlinked $candidate/oa -> $BIN_DIR/oa" >&2
364 342
            break
365 343
        fi
366 344
    done

@@ -403,59 +381,5 @@ export PATH="$HOME/.openagents/bin:$PATH"

403 381
    echo "  Updated $BIN_DIR in PATH in $config_file." >&2
404 382
fi
405 383
406
# Tell people when `oa` will not run what we just installed.
407
#
408
# Two ways that happens, and the installer can see both:
409
#
410
#   1. Another `oa` sits earlier on PATH.
411
#   2. A shell alias or function named `oa` is defined in the rc file. This one
412
#      is nastier: aliases and functions beat PATH in an interactive shell, the
413
#      installer runs non-interactively so `command -v oa` cannot see them, and
414
#      nothing about the running binary says which one answered. Grepping the
415
#      rc file is the only way to catch it from here.
416
#
417
# Report, never edit. The alias may be deliberate, and silently rewriting
418
# someone's shell config is worse than the shadowing.
419
CONFLICTS=""
420
421
installed_oa="$BIN_DIR/oa"
422
[ "$os" = "windows" ] && installed_oa="$BIN_DIR/oa.exe"
423
424
resolved_oa="$(command -v oa 2>/dev/null || true)"
425
if [ -n "$resolved_oa" ]; then
426
    resolved_real="$(cd "$(dirname "$resolved_oa")" 2>/dev/null && pwd -P)/$(basename "$resolved_oa")"
427
    installed_real="$(cd "$(dirname "$installed_oa")" 2>/dev/null && pwd -P)/$(basename "$installed_oa")"
428
    if [ -n "$SYMLINK_CREATED" ] && [ "$resolved_real" = "$SYMLINK_CREATED/oa" ]; then
429
        : # our own symlink, which points at the binary we just installed
430
    elif [ "$resolved_real" != "$installed_real" ]; then
431
        CONFLICTS="yes"
432
        echo "" >&2
433
        echo "WARNING: 'oa' on your PATH is not what this installer just wrote." >&2
434
        echo "  PATH resolves oa to: $resolved_oa" >&2
435
        echo "  Installed here:      $installed_oa" >&2
436
    fi
437
fi
438
439
if [ -n "$config_file" ] && [ -f "$config_file" ]; then
440
    if grep -Eqs '^[[:space:]]*(alias[[:space:]]+oa=|oa[[:space:]]*\(\)|function[[:space:]]+oa\b)' "$config_file"; then
441
        CONFLICTS="yes"
442
        echo "" >&2
443
        echo "WARNING: $config_file defines a shell alias or function named 'oa'." >&2
444
        echo "  It takes precedence over PATH, so typing 'oa' will not run the" >&2
445
        echo "  binary this installer wrote, and nothing in the output will say so." >&2
446
        echo "  Remove it, rename it, or run $installed_oa by its full path." >&2
447
        grep -Ens '^[[:space:]]*(alias[[:space:]]+oa=|oa[[:space:]]*\(\)|function[[:space:]]+oa\b)' "$config_file" \
448
            | sed 's/^/    /' >&2
449
    fi
450
fi
451
452
echo "" >&2
453
echo "OpenAgents CLI $version installation complete!" >&2
454
if [ -n "$CONFLICTS" ]; then
455
    echo "Run '$installed_oa --version' to confirm which build answers." >&2
456
else
457
    echo "Run 'oa' to get started." >&2
458
fi
459 384
echo "" >&2
460
echo "Note: this installs 'oa' only. The 'openagents' command is the separate" >&2
461
echo "TypeScript CLI (npm i -g @openagentsinc/cli) and is left alone." >&2
385
echo "OpenAgents $version installed. Run 'openagents' to start." >&2
test/openagents_web/install_script_test.exs modified +24 -86

@@ -198,102 +198,40 @@ defmodule OpenAgentsWeb.InstallScriptTest do

198 198
           "the installer never reads the channel pointer"
199 199
  end
200 200
201
  # The Rust binary implements a strict subset of the TypeScript CLI's
202
  # commands, so taking the `openagents` name swaps a fraction of the CLI in
203
  # underneath every script, agent tool call, and `openagents` tool that
204
  # invokes it by name — and the failure surfaces far from the cause, as
205
  # `unrecognized subcommand` from something that always worked. See
206
  # openagents#90.
207
  #
208
  # These run the installer's own shell, rather than grepping it. A test that
209
  # only greps passes the moment someone writes the same bug a different way.
210
  describe "the `openagents` name" do
211
    test "is never written by an install" do
201
  describe "the name it installs" do
202
    # This reverses the `oa`-only rule. That rule existed because the Rust
203
    # binary answered a strict subset of the CLI's commands, so taking the
204
    # `openagents` name swapped a fraction of the CLI in underneath every
205
    # script that called it. That is no longer true: the shipped binary
206
    # dispatches the whole command set and is the coder session besides.
207
    test "installs openagents, coder, and oa for the same binary" do
212 208
      script = File.read!(@script)
213 209
214
      refute script =~ ~s(ln -sf "$link_target" "$BIN_DIR/openagents"),
215
             "the installer links the openagents name at the Rust binary"
210
      assert script =~ ~s(ln -sf "$link_target" "$BIN_DIR/openagents"),
211
             "the installer does not create the openagents name"
216 212
217
      refute script =~ ~s($candidate/openagents),
218
             "the installer puts the openagents name on PATH"
219
    end
220
221
    test "is reclaimed when an earlier install took it, and only then" do
222
      tmp = Path.join(System.tmp_dir!(), "oa-reclaim-#{System.unique_integer([:positive])}")
223
      bin = Path.join(tmp, "bin")
224
      downloads = Path.join(tmp, "downloads")
225
      File.mkdir_p!(bin)
226
      File.mkdir_p!(downloads)
227
228
      binary = Path.join(downloads, "openagents-macos-aarch64")
229
      File.write!(binary, "#!/bin/sh\n")
230
231
      # What a previous install left behind, and what must go.
232
      File.ln_s!("../downloads/openagents-macos-aarch64", Path.join(bin, "openagents"))
233
234
      # Someone else's `openagents`, which must survive untouched.
235
      other = Path.join(tmp, "real-openagents")
236
      File.write!(other, "#!/bin/sh\n")
237
      File.ln_s!(other, Path.join(bin, "openagents-real"))
238
239
      script = File.read!(@script)
240
241
      # The function working proves nothing if the install path never calls it.
242
      assert script =~ ~r/^\s+reclaim_openagents_name$/m,
243
             "reclaim_openagents_name is defined and never called"
244
245
      body = extract_function(script, "reclaim_openagents_name")
213
      assert script =~ ~s(ln -sf "$link_target" "$BIN_DIR/coder"),
214
             "the installer does not create the coder name"
246 215
247
      runner = Path.join(tmp, "run.sh")
248
249
      File.write!(runner, """
250
      BIN_DIR="#{bin}"
251
      DOWNLOAD_DIR="#{downloads}"
252
      HOME="#{tmp}"
253
      #{body}
254
      reclaim_openagents_name
255
      """)
256
257
      assert {_out, 0} = System.cmd("sh", [runner], stderr_to_stdout: true)
258
259
      refute File.exists?(Path.join(bin, "openagents")),
260
             "the link an earlier install left is still shadowing the CLI"
261
262
      assert File.exists?(Path.join(bin, "openagents-real")),
263
             "an openagents we did not install was removed"
264
265
      assert File.exists?(binary), "the binary itself was removed with the link"
266
267
      File.rm_rf!(tmp)
216
      assert script =~ ~s(ln -sf "$link_target" "$BIN_DIR/oa"),
217
             "the installer does not create the oa alias"
268 218
    end
269 219
270
    test "a shell alias named oa is reported, since it beats PATH and is invisible to `command -v`" do
271
      pattern = shadow_pattern(File.read!(@script))
272
273
      tmp = Path.join(System.tmp_dir!(), "oa-rc-#{System.unique_integer([:positive])}")
274
      File.mkdir_p!(tmp)
275
      rc = Path.join(tmp, "rc")
276
277
      File.write!(rc, """
278
      export PATH="/usr/bin:$PATH"
279
      alias oa="node ~/old/cli.js"
280
      alias oat="a different command"
281
      oa() { echo hi; }
282
      function oa { echo hi; }
283
      alias openagents="not this one"
284
      # alias oa=commented out
285
      """)
220
    test "says one line about what it installed, and names openagents" do
221
      script = File.read!(@script)
286 222
287
      {out, _} = System.cmd("sh", ["-c", "grep -Ens '#{pattern}' '#{rc}' || true"])
223
      assert script =~ ~s(OpenAgents $version installed. Run 'openagents' to start.)
288 224
289
      lines =
290
        out |> String.split("\n", trim: true) |> Enum.map(&List.first(String.split(&1, ":")))
225
      # The closing block used to carry a shadow warning, a PATH comparison and
226
      # a note about a different CLI. It was noise on a successful install.
227
      refute script =~ "TypeScript CLI",
228
             "the installer talks about a different CLI"
291 229
292
      assert lines == ["2", "4", "5"],
293
             "the shadow check found #{inspect(lines)}; it must catch the three ways `oa` is " <>
294
               "defined and leave `oat`, `openagents`, and a comment alone"
230
      refute script =~ "npm i -g",
231
             "the installer sends the reader somewhere else"
295 232
296
      File.rm_rf!(tmp)
233
      refute script =~ "installs 'oa' only",
234
             "the installer still claims it installs oa only"
297 235
    end
298 236
  end
299 237

This page updates live while a promote is in flight · changelog