diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0114133b..cb9c00ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,12 +93,22 @@ jobs: done offer_ai_cli_install >/dev/null 2>&1 echo "bash $BASH_VERSION: --ai-cli answers the menu, also with nothing to offer" - # What the desktop app runs: preflight on stdout, and only JSON there. - out=$(bash /w/install.sh preflight --no-admin) - [[ "$out" == "{\"schema\":1,"* && "$out" == *"]}" ]] + # What the desktop app runs: preflight on stdout, and only JSON there. These + # child runs must not inherit CODEMAN_INSTALL_SH_LIB, or they stop at the + # sourcing guard and print nothing. Each check ends in `|| exit 1`, because + # bash 3.2 does not let a false [[ ]] trip set -e. + out=$(env -u CODEMAN_INSTALL_SH_LIB bash /w/install.sh preflight --no-admin) + [[ "$out" == "{\"schema\":1,"* && "$out" == *"]}" ]] || { echo "preflight printed: $out"; exit 1; } json_string "a\"b\\c" >/tmp/json-out - [[ "$(cat /tmp/json-out)" == "\"a\\\"b\\\\c\"" ]] + [[ "$(cat /tmp/json-out)" == "\"a\\\"b\\\\c\"" ]] || { echo "json_string printed: $(cat /tmp/json-out)"; exit 1; } echo "bash $BASH_VERSION: preflight prints JSON" + # How the desktop app adds an agent: a real run of the subcommand, which stops + # on the missing npm before it could install anything. + if out=$(env -u CODEMAN_INSTALL_SH_LIB bash /w/install.sh agent codex --no-admin --progress 2>&1); then + echo "agent ran with no npm to run"; exit 1 + fi + [[ "$out" == *"@@codeman phase agent"* && "$out" != *"@@codeman phase done"* ]] || { echo "agent printed: $out"; exit 1; } + echo "bash $BASH_VERSION: agent stops without saying done" ' # Issue #382: the dsh identity probe builds an OPTIONAL `timeout` prefix as an # array, and on stock macOS there is no `timeout`, so the array is empty and the diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index ed0ca148..3917aaca 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1303,7 +1303,7 @@ On this route Node.js comes from nodejs.org (`latest-v.x/SHASUMS256.txt` ⚠️ `no_admin_blocker` runs before the first download and stops for what this route cannot supply: a C/C++ toolchain on Linux (node-pty 1.1.0 compiles there; a node-pty with Linux prebuilds is what would lift this, and it is a dependency decision, not an installer one), musl, a platform nodejs.org or tmux-builds has no build for, and macOS below 15 (the tmux build's deployment target). ⚠️ Nothing between the "No-admin route" header and "End of the no-admin route" may run `sudo`, `ensure_sudo`, `run_as_root`, Homebrew or `xcode-select --install`; `run_as_root` itself refuses while `NO_ADMIN=1`, so a missed call site fails instead of prompting. -**Driving the installer from a program** (`--ai-cli`, `--progress`, `install.sh preflight`, 2026-11-06). Codeman Desktop installs with no terminal: it runs `install.sh --no-admin --service --progress --ai-cli |none` with `CODEMAN_NONINTERACTIVE=1` and its stdin closed, and asks the two questions in its own window (`packages/desktop/src/gui_install.rs`). `--ai-cli` answers the agent menu, so the one thing `CODEMAN_NONINTERACTIVE` used to decide by itself (the first entry of the catalogue) is the user's. ⚠️ `cli_offerable` is the ONE rule for what can be installed here: the menu, `--ai-cli` and `preflight`'s `installable` all read it, so the app's list and the menu cannot disagree. A name that is not offerable here warns and carries on, and never falls back to another CLI; a typo dies in `parse_flags` before anything runs. ⚠️ `preflight` prints only JSON on stdout and changes nothing; `schema` moves only when a field changes meaning, because the app ships on its own and refuses a number it does not know. Fields may be added freely. ⚠️ `progress_phase` writes to stderr, not stdout, because a function can run inside a `$( )` that captures stdout. The phases must stay in the order of `PHASES` in `connect.html` (pinned by test), `done` must stay the last thing `main()` reports, since a run that ends without it failed, and an unknown phase is ignored by the app, so a new phase needs no new app. ⚠️ `error()` carries the text as `@@codeman error`, and the app shows the FIRST one: the ones after it are consequences (`Installation failed. Partial...`). +**Driving the installer from a program** (`--ai-cli`, `--progress`, `install.sh preflight`, 2026-11-06). Codeman Desktop installs with no terminal: it runs `install.sh --no-admin --service --progress --ai-cli |none` with `CODEMAN_NONINTERACTIVE=1` and its stdin closed, and asks the two questions in its own window (`packages/desktop/src/gui_install.rs`). `--ai-cli` answers the agent menu, so the one thing `CODEMAN_NONINTERACTIVE` used to decide by itself (the first entry of the catalogue) is the user's. ⚠️ `cli_offerable` is the ONE rule for what can be installed here: the menu, `--ai-cli` and `preflight`'s `installable` all read it, so the app's list and the menu cannot disagree. A name that is not offerable here warns and carries on, and never falls back to another CLI; a typo dies in `parse_flags` before anything runs. `install.sh agent ` installs one CLI and nothing else, for Codeman Desktop's *Add a coding agent*: no service, no source, no build, so it may run next to a live server, and an agent already there is a success that runs nothing. ⚠️ It checks `CLI_FOUND_PATH` of that one entry itself and only then prints `done`: `install_offered_cli` is fatal only when NO CLI is left, which is right for a first install and would report success here, where another CLI is usually present. ⚠️ The app passes `--no-admin` to both `preflight` and `agent`, because that flag decides what `cli_offerable` allows; pinned in `gui_install.rs`. ⚠️ The app runs it from a folder of its own (`Run::Agent`), so `ps` never reads an agent run as "Codeman is being installed". A running server remembers a missing CLI for up to 5 minutes (`cliResolveRetryDelayMs`), and only the CLI management routes clear that, so the app says so on its last screen and never restarts the service for it. ⚠️ `preflight` prints only JSON on stdout and changes nothing; `schema` moves only when a field changes meaning, because the app ships on its own and refuses a number it does not know. Fields may be added freely. ⚠️ `progress_phase` writes to stderr, not stdout, because a function can run inside a `$( )` that captures stdout. The phases must stay in the order of `PHASES` in `connect.html` (pinned by test), `done` must stay the last thing `main()` reports, since a run that ends without it failed, and an unknown phase is ignored by the app, so a new phase needs no new app. ⚠️ `error()` carries the text as `@@codeman error`, and the app shows the FIRST one: the ones after it are consequences (`Installation failed. Partial...`). ⚠️ **git is judged by whether it works, never by `command -v`.** On a Mac without the Xcode Command Line Tools `/usr/bin/git` is a stub that opens Apple's install dialog, so `check_git` judges that path by `xcode-select -p` and never executes it; any other git is asked for `--version`. `scripts/git-hooks.mjs` has the matching guard (no `.git`, no `git` call), because postinstall runs it on every `npm install`. Without a working git the source is an archive (`fetch_source_archive`, GitHub's `archive/.tar.gz` or `CODEMAN_TARBALL_URL`) and the install directory carries the marker `.install-source`, which `detectInstallKind` reads as `archive` (the name is pinned across both files by `test/install-sh-invariants.test.ts`). ⚠️ An archive install is replaced as a WHOLE, never patched in place: nothing says which files a newer version dropped, and a stale `src/*.ts` would be compiled. The new tree is fetched and built in `.new` and `finish_source_swap` moves it into place only after `npm run build` succeeded, so a failed update leaves the running install untouched; a re-run on a machine that has gained a working git stages a clone instead, which turns the install back into a git one. ⚠️ `download`/`download_to_stdout` resolve `DOWNLOADER` themselves (`require_downloader`): only `main()` used to set it, so a subcommand that downloads died on `set -u` with the message swallowed by the step log. diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index 0474ecce..0db7045a 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -79,6 +79,7 @@ install.sh tailscale # retrofit Tailscale access onto an existing install install.sh name [] # rename this machine on your tailnet (default codeman-) install.sh cloudflared # install cloudflared for the in-app Cloudflare tunnel install.sh preflight # print what is on this machine as JSON; changes nothing +install.sh agent # install one agent CLI and nothing else, also next to a running Codeman ``` **Flags** answer the questions from the command line and pipe through `bash -s --`: @@ -97,6 +98,10 @@ existing install, `--port` and `--password` re-run the setup so the service file and a re-run with `--lan` or `--tailscale` keeps the password the service already has. `--ai-cli ` answers the agent menu that appears when no agent CLI is found, with no terminal needed (`--ai-cli claude`, `--ai-cli codex`, ...), and `--ai-cli none` skips it. +To add an agent later, `install.sh agent codex` installs that one and changes nothing else: +it does not touch Codeman or its service, and an agent that is already there is left alone. +Codeman Desktop does the same from *Add a coding agent*. A running Codeman can take a few +minutes to notice the new agent; reload the page if it does not show up. **Automation and CI**: with no terminal attached, any step that would change the system aborts with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to diff --git a/install.sh b/install.sh index 6efa3320..772cb1ba 100755 --- a/install.sh +++ b/install.sh @@ -882,6 +882,44 @@ install_offered_cli() { return 0 } +# Install one AI CLI and nothing else (`install.sh agent `): how the desktop +# app adds an agent once Codeman is here, also over a running server, which this +# never touches. install_offered_cli alone cannot end this run: it is fatal only +# when NO CLI is left, and here another one usually is, so a failed install would +# report success. A CLI that is here already is a success, and installs nothing. +agent_subcommand() { + if [[ -z "$AI_CLI_CHOICE" || "$AI_CLI_CHOICE" == "none" ]]; then + die "Name the AI CLI to install: install.sh agent (one of: $(cli_catalog_ids))." + fi + check_curl_or_wget || die "curl or wget is required but neither is installed. Please install one first." + detect_all_clis + progress_phase agent + + local idx offerable=0 + idx=$(cli_index "$AI_CLI_CHOICE") + if [[ -n "${CLI_FOUND_PATH[$idx]}" ]]; then + success "${CLI_LABELS[$idx]} is already installed at ${CLI_FOUND_PATH[$idx]}" + progress_phase done + return 0 + fi + cli_offerable "$idx" || offerable=$? + if [[ "$offerable" -ne 0 ]]; then + die "${CLI_LABELS[$idx]} cannot be installed automatically here. See ${CLI_DOCS[$idx]:-its documentation}." + fi + # What this run sees is a login shell's PATH at best: a Node.js that only an + # interactive profile loads (nvm in .zshrc) is not on it. + if [[ "${CLI_INSTALL_CMD_TRUSTED[$idx]}" == npm\ * ]] && ! check_npm; then + die "${CLI_LABELS[$idx]} installs with npm, and this run cannot find it. Run this in a terminal instead: ${CLI_INSTALL_CMD_TRUSTED[$idx]}" + fi + + install_offered_cli "$idx" + if [[ -z "${CLI_FOUND_PATH[$idx]}" ]]; then + die "${CLI_LABELS[$idx]} did not install. You can run its installer yourself: ${CLI_INSTALL_CMD_TRUSTED[$idx]}" + fi + progress_phase done + return 0 +} + # Offer to install one AI CLI from the catalogue, or let the user skip. # # Split out of main() so the bash 3.2 CI step and test/install-sh-invariants.test.ts @@ -4664,6 +4702,7 @@ Subcommands status Print the URLs, the QR code and how to manage the service cloudflared Install cloudflared for the in-app Cloudflare tunnel preflight Print what is on this machine as JSON; changes nothing + agent Install one AI CLI and nothing else, also next to a running Codeman Environment: CODEMAN_NONINTERACTIVE=1, CODEMAN_INSTALL_DIR, CODEMAN_HOST, CODEMAN_PASSWORD, CODEMAN_PORT, CODEMAN_TAILSCALE=1, CODEMAN_TAILSCALE_NAME, @@ -4713,13 +4752,15 @@ parse_flags() { CODEMAN_PORT="$1"; export CODEMAN_PORT; RECONFIGURE="1" ;; --port=*) CODEMAN_PORT="${1#--port=}"; export CODEMAN_PORT; RECONFIGURE="1" ;; --help|-h) usage; exit 0 ;; - update|uninstall|tailscale|name|status|cloudflared|preflight) + update|uninstall|tailscale|name|status|cloudflared|preflight|agent) [[ -z "$SUBCOMMAND" ]] || die "Only one subcommand at a time ($SUBCOMMAND and $1 given)." SUBCOMMAND="$1" ;; -*) die "Unknown option: $1 (see --help)" ;; *) if [[ "$SUBCOMMAND" == "name" && -z "$SUBCOMMAND_ARG" ]]; then SUBCOMMAND_ARG="$1" + elif [[ "$SUBCOMMAND" == "agent" && -z "$AI_CLI_CHOICE" ]]; then + AI_CLI_CHOICE="$1" else die "Unexpected argument: $1 (see --help)" fi ;; @@ -4757,6 +4798,7 @@ case "$SUBCOMMAND" in status) status_subcommand ;; cloudflared) cloudflared_subcommand ;; preflight) preflight_subcommand ;; + agent) agent_subcommand ;; *) # Only a COMPLETED install re-runs as a quiet update. A partial one # (clone succeeded but build/menu never finished) lacks the marker and diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 8ba34096..3b9cd3f7 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -28,7 +28,7 @@ cannot do. | | | | --- | --- | -| **This Mac** | The connect screen's first tab looks for Codeman on this Mac and offers what fits: *Open Codeman* when it runs, *Start Codeman* when it is installed but stopped, *Install Codeman on this Mac* when it is not there. The address comes from Codeman's LaunchAgent (port, sub-path, HTTPS). When nothing answers there but one of your own Codeman servers runs (found with `ps` and `lsof`: a `codeman web --port 4000` in a terminal, say), the tab opens that one, or says where it listens; it never offers to start a second server, which would take over the first one's sessions, and Start refuses while one runs. Start asks launchd to run the LaunchAgent (`kickstart`, or `bootstrap` when it is not loaded), so the server keeps its own settings and supervision; without a LaunchAgent it runs `codeman web -d`. The login comes from where Codeman keeps it: the LaunchAgent's `CODEMAN_USERNAME`/`CODEMAN_PASSWORD`, else `~/.codeman/.env` (what Codeman's command-line clients read), `admin` by default. It goes only to `127.0.0.1` on that Codeman's port, only while one of your own Codeman processes holds that port, is never saved to the Keychain, and never reaches the page. Whether Codeman is installed is looked up in its files and processes, then the login shell; the interactive profile (nvm in `.zshrc`, say) only on *Check again*. Install sets Codeman up from the app itself, with no Terminal and no password (see Decisions). It fetches the installer, asks it what is on this Mac, then asks two things: which coding agent to install, only when this Mac has none (the list comes from the installer, and *Skip for now* is there), and who can reach Codeman (this Mac only, or the local network with a password). A checklist follows the install, the installer's output is one click away, and Cancel stops it; installing again picks up where it stopped. Codeman runs as a LaunchAgent, so it keeps running when the app quits and starts at login. Port 3000 is used unless something else answers there, then the next free one. Closing the app does not stop an install: it runs on its own, and the app picks it up when it opens again. When it is done the app looks every 3 seconds and opens Codeman as soon as it answers; it stops looking after the first answer, after 30 minutes, or 90 seconds after a finished install whose server never answered (then it offers Start). *Install in Terminal instead* runs the installer in a Terminal window, for what the app cannot do (see Known limits): it may ask for the Mac password (Homebrew) and asks its own questions there. The app offers it by itself on a Mac where the graphical install cannot finish. An install still running in Terminal is recognized by its process, so the tab waits for it instead of offering Install twice. | +| **This Mac** | The connect screen's first tab looks for Codeman on this Mac and offers what fits: *Open Codeman* when it runs, *Start Codeman* when it is installed but stopped, *Install Codeman on this Mac* when it is not there. The address comes from Codeman's LaunchAgent (port, sub-path, HTTPS). When nothing answers there but one of your own Codeman servers runs (found with `ps` and `lsof`: a `codeman web --port 4000` in a terminal, say), the tab opens that one, or says where it listens; it never offers to start a second server, which would take over the first one's sessions, and Start refuses while one runs. Start asks launchd to run the LaunchAgent (`kickstart`, or `bootstrap` when it is not loaded), so the server keeps its own settings and supervision; without a LaunchAgent it runs `codeman web -d`. The login comes from where Codeman keeps it: the LaunchAgent's `CODEMAN_USERNAME`/`CODEMAN_PASSWORD`, else `~/.codeman/.env` (what Codeman's command-line clients read), `admin` by default. It goes only to `127.0.0.1` on that Codeman's port, only while one of your own Codeman processes holds that port, is never saved to the Keychain, and never reaches the page. Whether Codeman is installed is looked up in its files and processes, then the login shell; the interactive profile (nvm in `.zshrc`, say) only on *Check again*. Install sets Codeman up from the app itself, with no Terminal and no password (see Decisions). It fetches the installer, asks it what is on this Mac, then asks two things: which coding agent to install, only when this Mac has none (the list comes from the installer, and *Skip for now* is there), and who can reach Codeman (this Mac only, or the local network with a password). A checklist follows the install, the installer's output is one click away, and Cancel stops it; installing again picks up where it stopped. Codeman runs as a LaunchAgent, so it keeps running when the app quits and starts at login. Port 3000 is used unless something else answers there, then the next free one. Closing the app does not stop an install: it runs on its own, and the app picks it up when it opens again. When it is done the app looks every 3 seconds and opens Codeman as soon as it answers; it stops looking after the first answer, after 30 minutes, or 90 seconds after a finished install whose server never answered (then it offers Start). *Install in Terminal instead* runs the installer in a Terminal window, for what the app cannot do (see Known limits): it may ask for the Mac password (Homebrew) and asks its own questions there. The app offers it by itself on a Mac where the graphical install cannot finish. An install still running in Terminal is recognized by its process, so the tab waits for it instead of offering Install twice. *Add a coding agent*, on this tab and in the app menu whenever Codeman is on this Mac, is the same chooser after the install: pick one agent, watch it install, open Codeman. It needs no password, leaves a running Codeman alone, and can be cancelled or left running like an install. After *Skip for now* the app says Codeman has no agent yet and offers this before it opens Codeman. | | **Another machine** | The second tab connects to a server by address (below). *Codeman is not installed there yet?* opens Terminal, connects with `ssh -t` and runs Codeman's installer on that machine, which asks its questions there; the app then suggests `http://:3000` and the installer prints the real address at the end. The SSH login is checked to the characters of a user and host name (no option can be slipped in) and may carry a `:port`. The remote command runs under `bash -o pipefail`, so a failed download fails the install instead of reporting success. The same tab shows the one-line install command, with a Copy button, to run on a machine by hand. | | **Connect screen** | Server address, recent servers, a login for password-protected servers (password kept in the macOS Keychain), auto-connect at launch. It explains each failure: nothing listening; wrong password; too many failed logins (the password field stays open, since a single-user Codeman accepts the right password even then); unknown host name; an address this Mac cannot reach (with a pointer to the Local Network setting); an `http://` address on a port that only speaks HTTPS; an untrusted certificate; a redirect, with a button for the suggested `https://` address; not a Codeman server. A multi-user account that must change its password connects, and Codeman's own dialog asks for the new one. If the server page has not started showing within 15 seconds, or you press Cancel while it opens, the connect screen comes back with a notice. On a Mac whose WebKit is too old for Codeman it says so and Connect stays off (see Known limits). | | **Start Codeman** | On the This Mac tab, and when `http://127.0.0.1:` or `http://localhost:` answers nothing, one click runs `codeman web -d` (or `~/.codeman/app`) with your login shell's environment, so the server gets your real PATH (Homebrew, nvm) for the agents it spawns. zsh, bash and other POSIX shells run the start themselves; fish, nu, xonsh, tcsh and other shells only report their environment, and the start runs under `/bin/sh` with it. Offered for those two addresses only: `codeman web -d` listens on 127.0.0.1. | @@ -160,6 +160,18 @@ first line. That is why the app keeps no state about a run. A run that ends without the installer's last `done` line failed, and the first error it reported is the one shown: the ones after it are consequences. +**Adding an agent is a run of its own.** `install.sh agent ` installs one +agent and touches neither Codeman nor its service, so it may run next to a live +server, which an install must refuse: the installer's service step would replace +that server under its sessions. It has its own temporary folder, so `ps` tells the +two apart: everything that reads a running install takes it to mean "Codeman is +being installed", which an agent run does not. One run goes at a time, since both +can write to the runtime folder. It asks `preflight` for the list like an install +does, and both pass `--no-admin`: that flag decides which agents count as +installable, so without it on both the page could offer one the run refuses. The +menu item is on the connect page because that is where the app may run things: +from a Codeman page it leaves the page the way *Switch Server* does. + **Terminal stays for what the app cannot do.** The route with Homebrew asks for the administrator password on a terminal, the Xcode tools arrive as a system dialog, and ssh asks about host keys and passwords on a terminal too. The app @@ -208,6 +220,14 @@ and the Dock icon bounces. ## Known limits +- **A new agent can take a few minutes to show up in a running Codeman.** The + server remembers that a CLI is missing, for up to 5 minutes after it last looked + (`cliResolveRetryDelayMs`), so that a status poll never spawns a login shell. The + app cannot clear that from outside, so the screen after *Add a coding agent* says + so. An open Codeman page also needs a reload, because the list of installed + agents is part of the page. +- **Agents the app can add are the ones `--no-admin` can install.** OMP's macOS + install is Homebrew, so it is not offered, also on a Mac that has Homebrew. - **Self-signed HTTPS is refused.** A server started with `codeman web --https` uses a self-signed certificate, and WKWebView rejects it (wry has no trust override). Use `http://127.0.0.1:3000` for a server on this Mac. For another diff --git a/packages/desktop/assets/connect.html b/packages/desktop/assets/connect.html index cd2457a2..9e294a73 100644 --- a/packages/desktop/assets/connect.html +++ b/packages/desktop/assets/connect.html @@ -149,6 +149,7 @@ .choice input { accent-color: var(--accent-2); margin: 2px 0 0; flex: none; } .choice b { display: block; font-weight: 600; } .choice small { display: block; margin-top: 1px; color: var(--muted); font-size: 12px; } + .choice:has(input:disabled) { border-color: var(--line); background: none; color: var(--muted); } #wiz-reach .choice + .choice { margin-top: 6px; } #wiz-password { margin-top: 10px; } #wiz-agent-note { margin: 10px 0 0; color: var(--muted); font-size: 12px; } @@ -270,6 +271,7 @@ +