diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 8dee6753f0..ad58d56bc5 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -116,18 +116,26 @@ curl -fsSL https://agentfield.ai/get/codeaf | VERSION= bash | `--version TAG` or `VERSION=` | Pin one release tag. | | `--name WORD` or `CODEAF_INSTALL_NAME=WORD` | Choose the installed binary's file name. | | `--dir PATH` | Install somewhere other than `~/.codeaf/bin`. | -| `--no-modify-path` | Print the PATH line without editing a shell file. | +| `--no-modify-path` | Print the PATH line without editing a shell file or linking into a folder on PATH. | +| `--no-start` or `CODEAF_NO_START=1` | Do not ask to start codeaf when the install ends. | | `--verbose` | Print each GET. | | `GITHUB_TOKEN` or `GH_TOKEN` | Raise GitHub's anonymous API limit. | The script needs `curl` or `wget`, plus `sha256sum` or `shasum`. It downloads `checksums.txt` and refuses a sha256 mismatch. Unless `--no-modify-path` is set, it -appends one `export PATH=… # codeaf installer` line to the applicable shell file. On a -normal run it prints two things and nothing else: `installed codeaf v… built … · -go… os/arch` (the installed file naming itself; an install under another name puts -that name first, `installed devaf · codeaf dev-… built …`), and, -when the folder is not yet on `PATH`, the bare `export PATH=…` line to paste into the -current shell, bold green on a terminal, last, with a blank line above and below. +appends one `export PATH=… # codeaf installer` line to the applicable shell file, and +when `~/.local/bin`, `~/bin` or `/usr/local/bin` is on `PATH` and writable, links the +command there so it works in the current terminal (nothing there but its own link is +ever replaced). A normal run prints checked steps, `Downloaded`, `Installed codeaf v…` +(the installed file naming itself, split so it fits 80 columns; an install under another +name puts that name first, `Installed devaf · codeaf dev-…`), `PATH` and `Linked`, then a +*Get started* guide: the bare `export PATH=…` line on its own line when the command is not +reachable yet, `cd your-project` and the command, how to connect a model, and links to +https://agentfield.ai/docs/codeaf. On a terminal it colours the steps, turns a spinner +while it resolves and downloads, and ends by asking `Start codeaf in now? [Y/n]`, +read from the terminal rather than the piped script. Piped output and `NO_COLOR` get +plain text; a pipe, `CI`, `--verbose`, `--no-start` and a run from the home folder or +`/` get no question. `--verbose` also reports the channel, the tag and the install path on stderr. The `/get/devaf` line selects the dev channel and names the file `devaf`, installing it beside codeaf. The `/get/stageaf` line selects staging and names the file diff --git a/docs/changes/unreleased/1635-installer-welcome.md b/docs/changes/unreleased/1635-installer-welcome.md new file mode 100644 index 0000000000..af53565935 --- /dev/null +++ b/docs/changes/unreleased/1635-installer-welcome.md @@ -0,0 +1,12 @@ +--- +kind: changed +title: the installer walks a person through the first minute and leaves codeaf ready in the same terminal +pr: 1635 +surface: [build, docs] +invalidates: + - "The installer printed one line, `installed codeaf `, and last the bare `export PATH=…` line. It now prints checked steps (`Downloaded`, `Installed codeaf ` with the rest of the version line dim beneath it, `PATH`, `Linked`), then a Get started guide; the PATH line is step 1 of that guide and only appears when the command is not reachable yet." + - "The PATH line was always the installer's last line. The last thing now is the guide, and on a terminal the question `Start codeaf in now? [Y/n]`." + - "The installer only edited a shell file, so `codeaf` did not work in the terminal that ran the install. When `~/.local/bin`, `~/bin` or `/usr/local/bin` is on PATH and writable it also links the command there; it replaces nothing there but its own link, so a file or a link to another build stays and the paste line is printed instead." + - "test/installer-telemetry.sh extracted `print_path_hint`. That function is gone; the test extracts `init_style` and `print_guide`." + - "The installer had no `--no-start` flag. `--no-start` or `CODEAF_NO_START=1` skips the start question, which is also never asked of a pipe, `CI` or `--verbose`, nor when the install runs in the home folder or `/`." +--- diff --git a/internal/manual/chat/getting-started.md b/internal/manual/chat/getting-started.md index 7f3fddba5e..67483c4daf 100644 --- a/internal/manual/chat/getting-started.md +++ b/internal/manual/chat/getting-started.md @@ -2,8 +2,9 @@ ## I just installed it — what is the first thing to do after installing codeaf -Run `codeaf`. That is the whole of it: the installer leaves the program at -`~/.codeaf/bin/codeaf` and asks nothing else of you, and the setup described below is the +Run `codeaf`, or press `enter` when the installer asks `Start codeaf in now?`. +That is the whole of it: the installer leaves the program at `~/.codeaf/bin/codeaf`, +links it into a folder already on `PATH` when it can, and asks nothing else of you, and the setup described below is the only setup there is. It opens by itself the first time, so there is no command to go looking for and nothing to configure by hand first. diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index d4964a96b0..dba7cc3def 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -94,11 +94,19 @@ exactly once, or what it fetched is not a shell script, it answers 502. Building from source needs nothing published: clone the repository, run `make build`, then run `bin/codeaf` from the checkout. -The installer writes `~/.codeaf/bin/codeaf` and prints two things: one line -naming the installed file's `version` (`installed codeaf built …`), and last, -when the folder is not yet on `PATH`, the bare `export PATH=…` line to paste. It -prints nothing about telemetry; codeaf itself shows that notice before any count -is sent. +The installer writes `~/.codeaf/bin/codeaf`, adds it to your shell profile, and +when a folder already on `PATH` is writable (`~/.local/bin`, `~/bin`, or +`/usr/local/bin`) it links `codeaf` there too, so the command works in the same +terminal with nothing to paste. It never replaces anything there but its own link: +a file, or a link to another build, stays, and you get the line to paste instead. It prints a few checked steps (`Downloaded`, `Installed codeaf `, `PATH`, +`Linked`), then a short *Get started* guide: the `export PATH=…` line to paste when +the command is not reachable yet, `cd your-project` and `codeaf`, and how to connect a +model. On a terminal it ends by asking `Start codeaf in now? [Y/n]`; `enter` +starts it there, and the first run connects a model. `--no-start` or +`CODEAF_NO_START=1` skips the question, and nothing is asked when the output is not a +terminal, when `CI` is set, or when the install runs in your home folder or `/` — +`cd` into a project and type `codeaf` there instead. It prints nothing about telemetry; codeaf itself shows that +notice before any count is sent. `/update` in the chat or `codeaf update` in a terminal replaces it in place; running the install line again works too. diff --git a/internal/release/install_test.go b/internal/release/install_test.go index aa0c119455..6b524fc9ee 100644 --- a/internal/release/install_test.go +++ b/internal/release/install_test.go @@ -263,10 +263,10 @@ func TestInstallerGetsLatestStableAndFinishesWithVersion(t *testing.T) { if run.code != 0 { t.Fatalf("exit %d:\n%s", run.code, run.output) } - // A normal run says three things and nothing else: the installed binary - // naming itself, the notice, the line to paste. The channel, the tag and - // the platform are --verbose's to say. - if !strings.Contains(run.output, "installed codeaf v1.2.3 · fake") { + // A normal run says what it did in checked steps, the installed binary + // naming itself on one of them, then the guide. The tag and the GETs are + // --verbose's to say. + if !strings.Contains(run.output, "Installed codeaf v1.2.3\n") { t.Errorf("output does not carry the receipt:\n%s", run.output) } for _, absent := range []string{"stable v1.2.3", "codeaf: installed"} { @@ -275,7 +275,7 @@ func TestInstallerGetsLatestStableAndFinishesWithVersion(t *testing.T) { } } verbose := runInstaller(t, github, []string{"--verbose"}, "CODEAF_NO_MODIFY_PATH=1") - for _, want := range []string{"stable v1.2.3", runtime.GOOS + "/" + runtime.GOARCH, "codeaf: installed", "installed codeaf v1.2.3 · fake"} { + for _, want := range []string{"stable v1.2.3", runtime.GOOS + "/" + runtime.GOARCH, "codeaf: installed", "Installed codeaf v1.2.3\n"} { if verbose.code != 0 || !strings.Contains(verbose.output, want) { t.Errorf("verbose output does not contain %q (exit %d):\n%s", want, verbose.code, verbose.output) } @@ -591,7 +591,7 @@ func TestInstallerPinsAReleaseAndNamesAMissingOne(t *testing.T) { t.Fatalf("exit %d:\n%s", run.code, run.output) } fromEnvironment := runInstaller(t, github, nil, "VERSION=v1.2.3", "CODEAF_NO_MODIFY_PATH=1") - if fromEnvironment.code != 0 || !strings.Contains(fromEnvironment.output, "installed codeaf v1.2.3 · fake") { + if fromEnvironment.code != 0 || !strings.Contains(fromEnvironment.output, "Installed codeaf v1.2.3\n") { t.Fatalf("VERSION install exit %d:\n%s", fromEnvironment.code, fromEnvironment.output) } legacy := runInstaller(t, github, []string{"--version", "build-legacy", "--verbose"}, "CODEAF_NO_MODIFY_PATH=1") @@ -633,7 +633,7 @@ func TestDocumentedVersionPinReachesThePipedInstaller(t *testing.T) { "SHELL=/bin/bash", } output, err := command.CombinedOutput() - if err != nil || !strings.Contains(string(output), "installed codeaf v1.2.3 · fake") || strings.Contains(string(output), "v9.9.9") { + if err != nil || !strings.Contains(string(output), "Installed codeaf v1.2.3\n") || strings.Contains(string(output), "v9.9.9") { t.Fatalf("documented pin failed: %v\n%s", err, output) } } @@ -834,7 +834,7 @@ func TestInstallerUsesWgetWhenCurlIsAbsent(t *testing.T) { } github := newInstallGitHub(t, "v1.2.3") run := runInstaller(t, github, nil, "PATH="+minimalPath(t, false), "CODEAF_NO_MODIFY_PATH=1") - if run.code != 0 || !strings.Contains(run.output, "codeaf v1.2.3 · fake") { + if run.code != 0 || !strings.Contains(run.output, "Installed codeaf v1.2.3\n") { t.Fatalf("wget install exit %d:\n%s", run.code, run.output) } missing := runInstaller(t, github, []string{"--version", "v9.9.9"}, "PATH="+minimalPath(t, false), "CODEAF_NO_MODIFY_PATH=1") @@ -901,16 +901,25 @@ func TestV1InstallerName(t *testing.T) { // line to paste, and the path is --verbose's to say. A devaf install that // said "installed codeaf" sent the person to type a command this install // never wrote (the fresh-install check of 2026-09-25). - if !strings.Contains(run.output, "installed devaf · codeaf "+tag+" · fake") { + if !strings.Contains(run.output, "Installed devaf · codeaf "+tag+"\n") { t.Fatalf("output does not carry the receipt naming devaf:\n%s", run.output) } + if strings.Contains(run.output, "Installed codeaf") { + t.Fatalf("a devaf install's receipt names codeaf:\n%s", run.output) + } + // The guide tells the person to type the name this install wrote, and + // the PATH line stands on a line of its own so it can be pasted whole. + if !strings.Contains(run.output, "\n devaf\n") || strings.Contains(run.output, "\n codeaf\n") { + t.Fatalf("the guide does not send the person to devaf:\n%s", run.output) + } + pasteable := false for _, line := range strings.Split(run.output, "\n") { - if strings.HasPrefix(line, "installed codeaf") { - t.Fatalf("a devaf install's receipt says %q:\n%s", line, run.output) + if strings.TrimSpace(line) == `export PATH="`+dir+`:$PATH"` { + pasteable = true } } - if got := strings.Split(strings.TrimSpace(run.output), "\n"); got[len(got)-1] != `export PATH="`+dir+`:$PATH"` { - t.Fatalf("last line = %q:\n%s", got[len(got)-1], run.output) + if !pasteable { + t.Fatalf("no line is the bare PATH line to paste:\n%s", run.output) } verbose := runInstaller(t, github, []string{"--name", "devaf", "--dev", "--verbose"}, "CODEAF_INSTALL_DIR="+dir, "CODEAF_NO_MODIFY_PATH=1") @@ -929,7 +938,7 @@ func TestV1InstallerName(t *testing.T) { if _, err := os.Stat(filepath.Join(dir, "devaf")); err != nil { t.Fatal(err) } - if !strings.Contains(fromEnv.output, "installed devaf · codeaf "+tag+" · fake") { + if !strings.Contains(fromEnv.output, "Installed devaf · codeaf "+tag+"\n") { t.Fatalf("an install named from the environment does not name devaf in its receipt:\n%s", fromEnv.output) } flag := runInstaller(t, github, []string{"--name", "mine", "--dev"}, @@ -996,3 +1005,82 @@ func TestV2InstallerNameSeamAndHelp(t *testing.T) { t.Fatalf("help exit %d:\n%s", help.code, help.output) } } + +// A piped install cannot change its parent shell's PATH, so the installer links +// the command into a folder already on PATH, and the guide then has no line to +// paste. A file there that is not a link is somebody else's install and is left +// alone, and the paste line comes back. +func TestInstallerLinksIntoAFolderAlreadyOnPath(t *testing.T) { + github := newInstallGitHub(t, "v1.2.3") + home := t.TempDir() + local := filepath.Join(home, ".local", "bin") + if err := os.MkdirAll(local, 0o755); err != nil { + t.Fatal(err) + } + path := local + ":" + minimalPath(t, true) + installDir := filepath.Join(home, ".codeaf", "bin") + run := runInstaller(t, github, nil, "HOME="+home, "PATH="+path, "CODEAF_INSTALL_DIR="+installDir) + if run.code != 0 { + t.Fatalf("exit %d:\n%s", run.code, run.output) + } + target, err := os.Readlink(filepath.Join(local, "codeaf")) + if err != nil || target != filepath.Join(installDir, "codeaf") { + t.Fatalf("link = %q, %v", target, err) + } + if !strings.Contains(run.output, "ready in this terminal") || strings.Contains(run.output, "export PATH=") { + t.Fatalf("a linked install still asks for a PATH line:\n%s", run.output) + } + // Installing again finds its own link and refreshes it. + rerun := runInstaller(t, github, nil, "HOME="+home, "PATH="+path, "CODEAF_INSTALL_DIR="+installDir) + if rerun.code != 0 { + t.Fatalf("exit %d:\n%s", rerun.code, rerun.output) + } + if target, err := os.Readlink(filepath.Join(local, "codeaf")); err != nil || target != filepath.Join(installDir, "codeaf") || !strings.Contains(rerun.output, "ready in this terminal") { + t.Fatalf("a second install did not keep its own link: %q, %v\n%s", target, err, rerun.output) + } + + // A link to another build is somebody's choice too: a developer's source + // build linked into ~/.local/bin must survive a stable install. + other := filepath.Join(home, "src", "codeaf", "bin", "codeaf") + if err := os.MkdirAll(filepath.Dir(other), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(other, []byte("#!/bin/sh\necho source build\n"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(local, "codeaf")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(other, filepath.Join(local, "codeaf")); err != nil { + t.Fatal(err) + } + linked := runInstaller(t, github, nil, "HOME="+home, "PATH="+path, "CODEAF_INSTALL_DIR="+installDir) + if linked.code != 0 { + t.Fatalf("exit %d:\n%s", linked.code, linked.output) + } + if target, err := os.Readlink(filepath.Join(local, "codeaf")); err != nil || target != other { + t.Fatalf("the installer replaced a link to another build: %q, %v", target, err) + } + if !strings.Contains(linked.output, `export PATH="$HOME/.codeaf/bin:$PATH"`) || strings.Contains(linked.output, "ready in this terminal") { + t.Fatalf("a link to another build does not leave the PATH line:\n%s", linked.output) + } + + theirs := []byte("#!/bin/sh\necho someone else's codeaf\n") + if err := os.Remove(filepath.Join(local, "codeaf")); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(local, "codeaf"), theirs, 0o755); err != nil { + t.Fatal(err) + } + again := runInstaller(t, github, nil, "HOME="+home, "PATH="+path, "CODEAF_INSTALL_DIR="+installDir) + if again.code != 0 { + t.Fatalf("exit %d:\n%s", again.code, again.output) + } + kept, err := os.ReadFile(filepath.Join(local, "codeaf")) + if err != nil || string(kept) != string(theirs) { + t.Fatalf("the installer replaced a file that was not its link: %q, %v", kept, err) + } + if !strings.Contains(again.output, `export PATH="$HOME/.codeaf/bin:$PATH"`) { + t.Fatalf("a shadowed install does not give the PATH line:\n%s", again.output) + } +} diff --git a/scripts/install.sh b/scripts/install.sh index 2781db5dea..7b57809152 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -9,6 +9,7 @@ INSTALL_NAME="${CODEAF_INSTALL_NAME:-codeaf}" VERSION="${VERSION:-}" VERBOSE="${VERBOSE:-0}" NO_MODIFY_PATH="${CODEAF_NO_MODIFY_PATH:-${AFORGE_NO_MODIFY_PATH:-0}}" # legacy-name +NO_START="${CODEAF_NO_START:-0}" INSTALL_DIR="${CODEAF_INSTALL_DIR:-${AFORGE_INSTALL_DIR:-${HOME}/.codeaf/bin}}" # legacy-name STATE_ROOT="${CODEAF_HOME:-${AFORGE_HOME:-${HOME}/.codeaf}}" # legacy-name GITHUB_API="${CODEAF_GITHUB_API:-${AFORGE_GITHUB_API:-https://api.github.com}}" # legacy-name @@ -22,7 +23,8 @@ Install codeaf from a GitHub release. Usage: install.sh [--stable|--rc|--dev|--staging] [--version TAG] - [--name WORD] [--dir PATH] [--no-modify-path] [--verbose] + [--name WORD] [--dir PATH] [--no-modify-path] [--no-start] + [--verbose] Channels: --stable Latest stable release (default). @@ -34,20 +36,113 @@ Flags: --version TAG Install one named release tag. --name WORD Install the binary with this file name. --dir PATH Install somewhere other than ~/.codeaf/bin. - --no-modify-path Print the PATH line without editing a shell file. + --no-modify-path Print the PATH line without editing a shell file or + linking the command into a folder already on PATH. + --no-start Do not offer to start codeaf when the install ends. --verbose Print download details. --help Show this help. Environment: CHANNEL, VERSION, CODEAF_INSTALL_NAME, CODEAF_INSTALL_DIR - CODEAF_NO_MODIFY_PATH, VERBOSE + CODEAF_NO_MODIFY_PATH, CODEAF_NO_START, VERBOSE GITHUB_TOKEN or GH_TOKEN: GitHub answers anonymous API calls sixty times an hour per address; a token raises that. CODEAF_GITHUB_API and CODEAF_GITHUB_DOWNLOAD for mirrors and tests EOF } +# THE INSTALLER SPEAKS IN ONE VOICE: a mark, a few checked steps, then a short +# guide to the first minute. Colour, the spinner and the non-ASCII marks are for +# a person at a terminal only. Piped, logged or under NO_COLOR +# (https://no-color.org) the same words print plain, so a log or a test reads +# them byte for byte. +init_style() { + BOLD="" DIM="" ACCENT="" GREEN="" RED="" RESET="" + MARK_OK="ok" MARK_FAIL="x" MARK_BRAND="*" DOT="-" + # The frames are an array, not a string sliced a character at a time: slicing + # by character needs the named locale to be installed, and a UTF-8 LANG that + # ssh forwarded to a machine without it turns every frame into a broken byte. + SPINNER_FRAMES=('-' '\' '|' '/') + local locale="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" + case "$locale" in + *UTF-8*|*utf-8*|*UTF8*|*utf8*) + MARK_OK="✓" MARK_FAIL="✗" MARK_BRAND="◆" DOT="·" + SPINNER_FRAMES=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') + ;; + esac + if [[ -t 1 && -z "${NO_COLOR:-}" && "${TERM:-}" != "dumb" ]]; then + BOLD=$'\033[1m' + DIM=$'\033[2m' + GREEN=$'\033[32m' + RED=$'\033[31m' + RESET=$'\033[0m' + # The brand's amber, in truecolor where the terminal says it has it and the + # nearest of the 256 otherwise. + case "${COLORTERM:-}" in + truecolor|24bit) ACCENT=$'\033[38;2;212;162;74m' ;; + *) ACCENT=$'\033[38;5;178m' ;; + esac + fi +} + +# A person's home folder reads as ~ in anything printed; the paths written into +# shell files stay absolute. +tidy_path() { + local path="$1" + if [[ -n "${HOME:-}" && "$path" == "$HOME"/* ]]; then + printf '~%s' "${path#"$HOME"}" + else + printf '%s' "$path" + fi +} + +# The spinner turns in a background loop while the real work runs in THIS +# shell, so the work's variables (the tag, the HTTP status, the asset name) +# survive it. It draws only on a terminal that has `sleep`, and it is erased +# before the line that replaces it is printed. +SPIN_PID="" +spin_start() { + [[ -t 1 && -z "${NO_COLOR:-}" && "${TERM:-}" != "dumb" ]] || return 0 + command -v sleep >/dev/null 2>&1 || return 0 + local label="$1" + printf '\033[?25l' + ( + local i=0 count=${#SPINNER_FRAMES[@]} + while :; do + printf '\r %s%s%s %s%s%s' "$ACCENT" "${SPINNER_FRAMES[$i]}" "$RESET" "$DIM" "$label" "$RESET" + i=$(( (i + 1) % count )) + sleep 0.08 + done + ) & + SPIN_PID=$! +} + +spin_stop() { + [[ -n "$SPIN_PID" ]] || return 0 + kill "$SPIN_PID" >/dev/null 2>&1 || true + wait "$SPIN_PID" 2>/dev/null || true + SPIN_PID="" + printf '\r\033[2K\033[?25h' +} + +# One finished step: a green check, a word, and what it came to. +step_ok() { + local word="$1" + local detail="${2:-}" + printf ' %s%s%s %-11s %s\n' "$GREEN" "$MARK_OK" "$RESET" "$word" "$detail" +} + +print_banner() { + printf '\n %s%s%s %scodeaf%s %sby AgentField AI%s\n\n' \ + "$ACCENT" "$MARK_BRAND" "$RESET" "$BOLD" "$RESET" "$DIM" "$RESET" +} + fail() { - printf 'codeaf: %s\n' "$*" >&2 + spin_stop + if [[ -t 2 && -n "$RED" ]]; then + printf ' %s%s%s codeaf: %s\n\n' "$RED" "$MARK_FAIL" "$RESET" "$*" >&2 + else + printf 'codeaf: %s\n' "$*" >&2 + fi exit 1 } @@ -84,22 +179,47 @@ write_install_marker() { chmod 0600 "$file" } -# The one line a person still has to paste, printed last of all, between a -# blank line above and a blank line below, bold green on a terminal. Bare -# `export PATH=...` and nothing else, so it can be selected and pasted without -# trimming a prefix. Colour is skipped when stdout is not a terminal or -# NO_COLOR is set (https://no-color.org). -print_path_hint() { - local hint="$1" - [[ -n "$hint" ]] || return 0 - local on="" off="" - if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then - on=$'\033[1;32m' - off=$'\033[0m' +# The guide is the installer's last word: what to type, in the order a person +# types it. The PATH line, when there is one, is step 1 and stands on a line of +# its own, bare, so it can be selected and pasted without trimming a prefix. +# $1 the command a person types (the install name), $2 the PATH line or empty, +# $3 the shell file the line was written to, or empty when none was edited. +print_guide() { + local command_name="$1" + local hint="$2" + local edited="$3" + local n=1 + printf '\n %sGet started%s\n\n' "$BOLD" "$RESET" + if [[ -n "$hint" ]]; then + if [[ -n "$edited" ]]; then + printf ' %s%d%s Open a new terminal, or run this to use %s here:\n' "$ACCENT" "$n" "$RESET" "$command_name" + else + printf ' %s%d%s Put %s on your PATH by adding this to your shell profile:\n' "$ACCENT" "$n" "$RESET" "$command_name" + fi + printf '\n %s%s%s\n\n' "$BOLD" "$hint" "$RESET" + n=$((n + 1)) fi - printf '\n%s%s%s\n\n' "$on" "$hint" "$off" + printf ' %s%d%s Start it inside any project:\n' "$ACCENT" "$n" "$RESET" + printf '\n %scd your-project%s\n' "$BOLD" "$RESET" + printf ' %s%s%s\n\n' "$BOLD" "$command_name" "$RESET" + n=$((n + 1)) + printf ' %s%d%s Connect a model when it asks: OpenRouter signs in through your browser.\n' "$ACCENT" "$n" "$RESET" + printf ' For your own DeepSeek, Qwen, GLM or Kimi key, or a local Ollama, type\n' + printf ' /connect in the chat. Then say what you want done, the way you would\n' + printf ' to a colleague.\n' + printf '\n %sMore%s\n\n' "$BOLD" "$RESET" + printf ' %s%-34s%s %s%s%s\n' "$BOLD" "$command_name do \"add a health check\"" "$RESET" "$DIM" "one task, answer on stdout" "$RESET" + printf ' %s%-34s%s %s%s%s\n' "$BOLD" "$command_name update" "$RESET" "$DIM" "the newest build, in place" "$RESET" + printf ' %s%-34s%s %s%s%s\n' "$BOLD" "$command_name --help" "$RESET" "$DIM" "every command" "$RESET" + printf '\n %sLearn more%s\n\n' "$BOLD" "$RESET" + printf ' %s%-10s%s %shttps://agentfield.ai/docs/codeaf%s\n' "$DIM" "Docs" "$RESET" "$ACCENT" "$RESET" + printf ' %s%-10s%s %shttps://agentfield.ai/docs/codeaf/playbooks%s\n' "$DIM" "Playbooks" "$RESET" "$ACCENT" "$RESET" + printf ' %s%-10s%s %shttps://agentfield.ai/docs/codeaf/connections%s\n' "$DIM" "Models" "$RESET" "$ACCENT" "$RESET" + printf ' %s%-10s%s %shttps://discord.gg/aBHaXMkpqh%s\n\n' "$DIM" "Discord" "$RESET" "$ACCENT" "$RESET" } +init_style + while [[ $# -gt 0 ]]; do case "$1" in --stable) CHANNEL="stable"; shift ;; @@ -122,6 +242,7 @@ while [[ $# -gt 0 ]]; do shift 2 ;; --no-modify-path) NO_MODIFY_PATH=1; shift ;; + --no-start) NO_START=1; shift ;; --verbose|-v) VERBOSE=1; shift ;; --help|-h) usage; exit 0 ;; *) usage_error "unknown option: $1" ;; @@ -144,6 +265,7 @@ fi TMP_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/codeaf.XXXXXX") INSTALL_TEMP="" cleanup() { + spin_stop rm -rf "$TMP_ROOT" if [[ -n "$INSTALL_TEMP" ]]; then rm -f "$INSTALL_TEMP" @@ -151,6 +273,8 @@ cleanup() { } trap cleanup EXIT HUP INT TERM +print_banner + HTTP_STATUS="" http_get() { local url="$1" @@ -292,7 +416,15 @@ extract_dated_tags() { ' "$1" } +# --verbose prints each GET as it goes, which a spinner would draw over. +spin_label() { + [[ "$VERBOSE" == "1" ]] || spin_start "$1" +} + release_file="$TMP_ROOT/release.json" +if [[ -z "$VERSION" ]]; then + spin_label "Finding the latest $CHANNEL build" +fi if [[ -n "$VERSION" ]]; then TAG="$VERSION" else @@ -329,6 +461,7 @@ else esac fi +spin_stop if [[ -z "${TAG:-}" ]]; then api_problem fi @@ -399,6 +532,7 @@ if [[ "$VERBOSE" == "1" ]]; then printf 'codeaf: %s for %s/%s\n' "$TAG" "$OS" "$ARCH" >&2 fi fi +spin_label "Downloading codeaf $TAG for $OS/$ARCH" DOWNLOAD_REPOSITORY="$REPOSITORY" if ! download_release "$DOWNLOAD_REPOSITORY"; then if [[ "$HTTP_STATUS" == "404" && "$DOWNLOAD_REPOSITORY" != "$LEGACY_REPOSITORY" ]]; then @@ -424,6 +558,12 @@ fi if [[ "$actual" != "$expected" ]]; then fail "the checksum for $ASSET did not match" fi +spin_stop +if [[ -n "$DISPLAY_CHANNEL" ]]; then + step_ok "Downloaded" "$DISPLAY_CHANNEL build for $OS/$ARCH ${DOT} checksum verified" +else + step_ok "Downloaded" "$TAG for $OS/$ARCH ${DOT} checksum verified" +fi # Running the verified binary before creating its destination gives boot # adoption its one chance to move an existing state root. A custom install @@ -451,6 +591,38 @@ if [[ "$VERBOSE" == "1" ]]; then printf 'codeaf: installed %s\n' "$INSTALL_DIR/$INSTALL_NAME${extension}" >&2 fi +# The receipt is the installed binary naming itself: `codeaf version` is one +# line by law, so it reads whole after "Installed". A FILE INSTALLED UNDER +# ANOTHER NAME IS NAMED FIRST, because the receipt tells a person what to type +# next: a devaf install that said "Installed codeaf …" sent them to a command +# this install never wrote, or to an older codeaf that happened to be on their +# PATH. The version line after it stays whole, so the build is still named and +# codeaf is still the product. +if [[ "$RUN_BOOT_ADOPTION" == "1" ]]; then + version_line=$("$INSTALL_DIR/$INSTALL_NAME${extension}" version) +else + version_line=$(CODEAF_HOME="$STATE_ROOT" "$INSTALL_DIR/$INSTALL_NAME${extension}" version) +fi +# The line is split where it would wrap an 80-column terminal: the name and the +# tag on the step, the rest of the build's own words dim beneath it, whole. +version_head="${version_line%% built *}" +version_rest="" +if [[ "$version_head" != "$version_line" ]]; then + version_rest="built ${version_line#* built }" +elif [[ "$version_line" == *" · "* ]]; then + version_head="${version_line%% · *}" + version_rest="${version_line#* · }" +fi +if [[ "$INSTALL_NAME" == "codeaf" ]]; then + step_ok "Installed" "$version_head" +else + step_ok "Installed" "$INSTALL_NAME · $version_head" +fi +if [[ -n "$version_rest" ]]; then + printf ' %-13s %s%s%s\n' "" "$DIM" "$version_rest" "$RESET" +fi +printf ' %-13s %s%s%s\n' "" "$DIM" "$(tidy_path "$INSTALL_DIR/$INSTALL_NAME${extension}")" "$RESET" + path_has_dir() { case ":${PATH}:" in *":$INSTALL_DIR:"*) return 0 ;; @@ -476,50 +648,84 @@ append_path_line() { fi } -# The PATH line is not printed here. It is the last thing the installer says, -# after `codeaf version`, so the one line a person -# has to paste sits at the bottom of the screen where their eye already is. +# The folders a link may go in, in order: the person's own first, then the +# system's customary one when they can write to it without sudo. A folder counts +# only when it is on PATH already. NOTHING THERE IS REPLACED BUT THIS INSTALL'S +# OWN LINK: a file, or a link to anything else (a source build, another install, +# a leftover pointing nowhere), is somebody's choice, and shadowing it silently +# is how a person ends up running a build they did not choose. The paste line is +# the answer then. Prints the folder it linked into. +link_into_path() { + local target="$INSTALL_DIR/$INSTALL_NAME${extension}" + local dir + for dir in "$HOME/.local/bin" "$HOME/bin" "/usr/local/bin"; do + case ":${PATH}:" in + *":$dir:"*) ;; + *) continue ;; + esac + [[ -d "$dir" && -w "$dir" ]] || continue + if [[ -L "$dir/$INSTALL_NAME" ]]; then + # -ef follows the link: true only when it lands on this very file. It is + # a shell test, so the installer needs no readlink. + [[ "$dir/$INSTALL_NAME" -ef "$target" ]] || return 1 + elif [[ -e "$dir/$INSTALL_NAME" ]]; then + return 1 + fi + if ln -sf "$target" "$dir/$INSTALL_NAME" 2>/dev/null; then + printf '%s' "$dir" + return 0 + fi + done + return 1 +} + +# The PATH line is not printed here. It is step 1 of the guide at the end, so +# the one line a person has to paste sits beside the words that say why. PATH_HINT="" +PATH_FILE="" if [[ "$OS" != "windows" ]] && ! path_has_dir; then export_line="export PATH=\"$INSTALL_DIR:\$PATH\"" PATH_HINT="$export_line" + # The line a person reads spells their home as $HOME, which is shorter and + # still right if they copy it into a profile on another machine. + if [[ -n "${HOME:-}" && "$INSTALL_DIR" == "$HOME"/* ]]; then + PATH_HINT="export PATH=\"\$HOME${INSTALL_DIR#"$HOME"}:\$PATH\"" + fi + shell_name=$(basename "${SHELL:-/bin/bash}") + if [[ "$shell_name" == "fish" ]]; then + PATH_HINT="fish_add_path \"$INSTALL_DIR\"" + fi if [[ "$NO_MODIFY_PATH" != "1" ]]; then - shell_name=$(basename "${SHELL:-/bin/bash}") case "$shell_name" in zsh) - append_path_line "$HOME/.zshrc" "$export_line # codeaf installer" + PATH_FILE="$HOME/.zshrc" + append_path_line "$PATH_FILE" "$export_line # codeaf installer" ;; fish) - append_path_line "$HOME/.config/fish/config.fish" "fish_add_path \"$INSTALL_DIR\" # codeaf installer" + PATH_FILE="$HOME/.config/fish/config.fish" + append_path_line "$PATH_FILE" "fish_add_path \"$INSTALL_DIR\" # codeaf installer" ;; *) - append_path_line "$HOME/.bashrc" "$export_line # codeaf installer" + PATH_FILE="$HOME/.bashrc" + append_path_line "$PATH_FILE" "$export_line # codeaf installer" if [[ "$OS" == "darwin" && -f "$HOME/.bash_profile" ]]; then append_path_line "$HOME/.bash_profile" "$export_line # codeaf installer" fi ;; esac + step_ok "PATH" "added to $(tidy_path "$PATH_FILE")" + # A piped install cannot change the PATH of the shell that ran it, so the + # profile line only reaches the NEXT terminal. A link in a folder that is + # already on PATH makes the command work in this one, with nothing to paste. + # The link only helps when it is what the name resolves to: an older + # codeaf earlier on PATH would still answer, so the paste line stays. + if link_dir=$(link_into_path) && [[ "$(type -P "$INSTALL_NAME" 2>/dev/null)" == "$link_dir/$INSTALL_NAME" ]]; then + step_ok "Linked" "$(tidy_path "$link_dir/$INSTALL_NAME"), ready in this terminal" + PATH_HINT="" + fi fi fi -# The receipt is the installed binary naming itself: `codeaf version` is one -# line by law, so "installed " in front of it reads as one sentence. A FILE -# INSTALLED UNDER ANOTHER NAME IS NAMED FIRST, because the receipt is the one -# line that tells a person what to type next: a devaf install that said -# "installed codeaf …" sent them to a command this install never wrote, or to -# an older codeaf that happened to be on their PATH. The version line after it -# stays whole, so the build is still named and codeaf is still the product. -if [[ "$RUN_BOOT_ADOPTION" == "1" ]]; then - version_line=$("$INSTALL_DIR/$INSTALL_NAME${extension}" version) -else - version_line=$(CODEAF_HOME="$STATE_ROOT" "$INSTALL_DIR/$INSTALL_NAME${extension}" version) -fi -if [[ "$INSTALL_NAME" == "codeaf" ]]; then - printf 'installed %s\n' "$version_line" -else - printf 'installed %s · %s\n' "$INSTALL_NAME" "$version_line" -fi - # The install marker lives under the state root, and a custom install outside # it must not create the login's state folders: the marker is written when the # install is inside the state root or the root already exists, and skipped @@ -527,4 +733,39 @@ fi if [[ "$RUN_BOOT_ADOPTION" == "1" || -d "$STATE_ROOT" ]]; then write_install_marker "$STATE_ROOT" fi -print_path_hint "$PATH_HINT" +print_guide "$INSTALL_NAME" "$PATH_HINT" "$PATH_FILE" + +# Starting codeaf here only helps when here is a project. The home folder and / +# are where a piped install usually runs, and neither is one: starting there +# would hand codeaf the whole of it, and the guide above already says to cd into +# a project first. A folder deleted from under the shell is no place either. +start_folder() { + local here home + here=$(pwd -P 2>/dev/null) || return 1 + home=$(cd "${HOME:-/}" 2>/dev/null && pwd -P) || home="${HOME:-}" + [[ "$here" != "/" && "$here" != "$home" ]] +} + +# The last step is offered, not taken: a person at a terminal is asked whether +# to start codeaf now, where the first run connects a model, and Enter says yes. +# The answer is read from the terminal itself, because under `curl | bash` +# standard input is the script. Nothing is asked of a pipe, a CI runner, a +# --verbose run or --no-start, nor in the home folder or /. +offer_start() { + [[ "$NO_START" != "1" && "$VERBOSE" != "1" && -z "${CI:-}" && "$OS" != "windows" ]] || return 0 + [[ -t 1 ]] || return 0 + start_folder || return 0 + { : /dev/null || return 0 + local reply="" + printf ' %sStart %s in %s now?%s %s[Y/n]%s ' "$BOLD" "$INSTALL_NAME" "$(tidy_path "$PWD")" "$RESET" "$DIM" "$RESET" + read -r reply /dev/null -type print_path_hint >/dev/null +type init_style >/dev/null +type print_guide >/dev/null pass=0 fail=0 @@ -94,27 +95,46 @@ readme_block=$(awk ' ' README.md) ok "README quotes the notice verbatim" '[ -n "$readme_block" ] && [ "$(printf "%s\n" "$expected")" = "$readme_block" ]' -# --- the PATH line comes last ------------------------------------------------- -# The line a person has to paste is the installer's final word: bare, after a -# blank line, and never prefixed with "codeaf: add it to this shell with:", -# which made it a sentence to trim rather than a line to select. +# --- the guide ends the install ------------------------------------------------ +# The installer's last word is a short guide. The PATH line, when there is one, +# is its first step and stands alone, bare, so it can be selected and pasted +# without trimming a prefix ("codeaf: add it to this shell with:" made it a +# sentence to trim rather than a line to select). hint='export PATH="/x/bin:$PATH"' has_escape() { printf '%s' "$1" | grep -q "$(printf '\033')"; } -ok "an empty hint prints nothing" '[ -z "$(print_path_hint "" 2>&1)" ]' -out=$(print_path_hint "$hint"; printf x); out=${out%x} -expected_hint=$(printf '\n%s\n\nx' "$hint"); expected_hint=${expected_hint%x} -ok "the hint is one blank line, the bare export, one blank line" '[ "$out" = "$expected_hint" ]' -ok "channel and path announcements go to stderr, verbose only" '[ -z "$(grep -E "printf .codeaf: (installed|%s %s for|%s for)" "$script" | grep -v ">&2")" ]' -ok "the receipt is the installed binary naming itself" 'grep -q "printf .installed %s" "$script"' +init_style >/dev/null # a pipe, whatever the test itself runs under +out=$(print_guide codeaf "$hint" "$tmp/.zshrc") +ok "the PATH line stands on a line of its own" 'printf "%s\n" "$out" | grep -qx " $hint"' +ok "the guide names the command to type" 'printf "%s\n" "$out" | grep -qx " codeaf"' +ok "the guide says to connect a model" 'printf "%s\n" "$out" | grep -q "Connect a model"' +ok "the guide links the docs" 'printf "%s\n" "$out" | grep -q "https://agentfield.ai/docs/codeaf"' +out_none=$(print_guide codeaf "" "") +ok "no hint, no PATH step" '! printf "%s\n" "$out_none" | grep -q "PATH"' +out_named=$(print_guide devaf "" "") +ok "a named install sends the person to its own name" 'printf "%s\n" "$out_named" | grep -qx " devaf"' ok "no colour when stdout is not a terminal" '! has_escape "$out"' export NO_COLOR=1 -out=$(print_path_hint "$hint" 2>&1) +init_style >/dev/null # a pipe, whatever the test itself runs under +out=$(print_guide codeaf "$hint" "" 2>&1) ok "NO_COLOR is respected" '! has_escape "$out"' unset NO_COLOR -last_line=$(grep -v '^[[:space:]]*#' "$script" | grep -v '^[[:space:]]*$' | tail -n 1) -ok "the hint is the installer's last line" '[ "$last_line" = "print_path_hint \"\$PATH_HINT\"" ]' +ok "channel and path announcements go to stderr, verbose only" '[ -z "$(grep -E "printf .codeaf: (installed|%s %s for|%s for)" "$script" | grep -v ">&2")" ]' +ok "the receipt is the installed binary naming itself" 'grep -q "step_ok \"Installed\" \"\$version_head\"" "$script"' +ok "the guide is printed after the marker is written" '[ "$(grep -n "^print_guide " "$script" | cut -d: -f1)" -gt "$(grep -n "^ write_install_marker \"\$STATE_ROOT\"" "$script" | cut -d: -f1)" ]' ok "the old prefixed sentence is gone" '! grep -q "add it to this shell with" "$script"' +ok "the answer to start now is read from the terminal, never the piped script" 'grep -q "read -r reply /dev/null; [ "${#SPINNER_FRAMES[@]}" = 10 ] && [ "${SPINNER_FRAMES[1]}" = "⠙" ])' +ok "the guide sends other services to /connect" 'printf "%s\n" "$(print_guide codeaf "" "")" | grep -q "/connect in the chat"' +ok "the guide fits an 80-column terminal" '[ "$(print_guide codeaf "$hint" "$tmp/.zshrc" | awk "{ if (length > m) m = length } END { print m }")" -lt 80 ]' + +# The start question is never asked where starting would hand codeaf a whole +# home folder or the filesystem root. +mkdir -p "$tmp/home/project" +ok "start is offered inside a project" '(HOME="$tmp/home"; cd "$tmp/home/project" && start_folder)' +ok "start is not offered in the home folder" '! (HOME="$tmp/home"; cd "$tmp/home" && start_folder)' +ok "start is not offered at /" '! (HOME="$tmp/home"; cd / && start_folder)' +ok "the offer asks start_folder before the question" '[ "$(grep -n "^ start_folder || return 0" "$script" | cut -d: -f1)" -lt "$(grep -n "read -r reply