diff --git a/Brewfile b/Brewfile index 01e67a9..6f643b8 100644 --- a/Brewfile +++ b/Brewfile @@ -71,7 +71,7 @@ cask "font-zed-mono-nerd-font" # Zed editor font with icons # macOS only (no Linux formula/cask available) if OS.mac? - cask "codex" # OpenAI's coding assistant (use npm on Linux) + cask "codex" # OpenAI's coding assistant (npm on Linux, from the dev_user role) cask "gcloud-cli" # Google Cloud CLI (use apt/dnf on Linux) end diff --git a/ansible/roles/dev_user/defaults/main.yml b/ansible/roles/dev_user/defaults/main.yml index 5c5f1a1..dc02a85 100644 --- a/ansible/roles/dev_user/defaults/main.yml +++ b/ansible/roles/dev_user/defaults/main.yml @@ -105,3 +105,13 @@ dev_user_brewfile: true # keeps the tool inside the home, like everything else this role puts there. dev_user_claude_code: true dev_user_claude_code_channel: "" + +# Codex CLI. The Brewfile covers macOS with a cask, but Homebrew publishes no formula for it, and +# casks do not exist on Linux -- hence npm here, which is the install OpenAI document alongside the +# cask and which ships the same version. So this needs the Brewfile: npm arrives with node, out of +# the prefix. It installs globally, which for that node means into the prefix rather than the home, +# and the account owns the prefix outright -- so it is that npm the role installs with, and a box +# whose prefix has none is one this leaves alone. Empty takes the latest release; set an exact +# version to pin one, for the reason the Claude Code channel gives. +dev_user_codex: true +dev_user_codex_version: "" diff --git a/ansible/roles/dev_user/tasks/main.yml b/ansible/roles/dev_user/tasks/main.yml index 04f09ff..98a055b 100644 --- a/ansible/roles/dev_user/tasks/main.yml +++ b/ansible/roles/dev_user/tasks/main.yml @@ -342,7 +342,7 @@ # It went unnoticed because a GitHub runner ships apt's jq on the default PATH; on a stock # cloud image, which is what this role is for, there is no jq anywhere but the prefix. environment: - PATH: "{{ dev_user_brew_prefix }}/bin:{{ ansible_env.PATH }}" + PATH: "{{ dev_user_brew_prefix }}/bin:{{ ansible_facts.env.PATH }}" register: dev_user_dotfiles_install # It marks work with a tick and no-ops with an equals sign, which is the whole reason the dry run # and the already-linked check were worth porting. @@ -373,6 +373,61 @@ changed_when: false when: dev_user_claude_code | bool +# The Brewfile installs this on macOS and cannot here: Homebrew has only a cask for Codex, and +# casks are macOS-only. npm is what OpenAI document beside the cask and it publishes the same +# version, so this is the same tool by another route rather than a substitute for it. +# +# Global, which with the account's own Homebrew node means the Homebrew prefix -- the account owns +# it, so no escalation is involved. npm is not on the PATH `command` gets, for the reason the +# installer above needs the same line: no login shell has run, so `brew shellenv` has not either. +# +# That whole sentence is about the account's own npm, so it is the one named, and the pair is +# skipped where it is absent rather than falling through to whatever npm the PATH turns up. A +# system npm installs into a prefix this account may not own and leaves the binary somewhere the +# check below does not look, which is what the executed-role job saw: it runs with the Brewfile +# off, the install went to the runner's own npm, and `{{ dev_user_brew_prefix }}/bin/codex` was +# never written. A box without the Brewfile has no Codex until it has one. +- name: Whether the account has an npm of its own to install with + ansible.builtin.stat: + path: "{{ dev_user_brew_prefix }}/bin/npm" + register: dev_user_brew_npm + +- name: Codex CLI, from npm + community.general.npm: + name: "@openai/codex" + version: "{{ dev_user_codex_version or omit }}" + executable: "{{ dev_user_brew_prefix }}/bin/npm" + global: true + state: present + become: true + become_user: "{{ dev_user_name }}" + environment: + PATH: "{{ dev_user_brew_prefix }}/bin:{{ ansible_facts.env.PATH }}" + when: + - dev_user_codex | bool + - dev_user_brew_npm.stat.exists + +# Stated rather than assumed, as with Claude Code above. npm reporting a package installed says +# the tarball unpacked; it does not say the launcher resolves or that the platform-specific binary +# it shells out to exists for this architecture. +# +# The PATH again, and not for the reason the install task has it: what npm links into the prefix is +# a script whose shebang is `/usr/bin/env node`, so running it needs node found the same way. Claude +# Code above gets away without this because what its installer leaves behind is a native binary that +# asks nothing of the PATH -- which is why the task said `node: No such file or directory` when it +# was written to that shape, on a box where the install itself had just succeeded. +- name: Codex answers + ansible.builtin.command: "{{ dev_user_brew_prefix }}/bin/codex --version" + become: true + become_user: "{{ dev_user_name }}" + register: dev_user_codex_reported_version + environment: + PATH: "{{ dev_user_brew_prefix }}/bin:{{ ansible_facts.env.PATH }}" + changed_when: false + when: + - dev_user_codex | bool + - dev_user_brew_npm.stat.exists + # The private overlay. This repo is public and deliberately knows nothing about any # employer, so the default is empty and the whole block is skipped. Supply the repo in an # untracked vars file to turn it on: ansible-playbook ... -e @private.yml