Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,18 +116,26 @@ curl -fsSL https://agentfield.ai/get/codeaf | VERSION=<tag> bash
| `--version TAG` or `VERSION=<tag>` | 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 <folder> 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
Expand Down
12 changes: 12 additions & 0 deletions docs/changes/unreleased/1635-installer-welcome.md
Original file line number Diff line number Diff line change
@@ -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 <version>`, and last the bare `export PATH=…` line. It now prints checked steps (`Downloaded`, `Installed codeaf <tag>` 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 <folder> 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 `/`."
---
5 changes: 3 additions & 2 deletions internal/manual/chat/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <folder> 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.

Expand Down
18 changes: 13 additions & 5 deletions internal/manual/chat/running-from-the-terminal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tag> 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 <tag>`, `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 <folder> 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.

Expand Down
116 changes: 102 additions & 14 deletions internal/release/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"} {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand All @@ -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"},
Expand Down Expand Up @@ -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)
}
}
Loading
Loading