diff --git a/README.md b/README.md index 09580e1..0f52e48 100644 --- a/README.md +++ b/README.md @@ -104,9 +104,28 @@ Studio remains on the ThinkPads as an existing IDE/workflow choice, but its bundled SDK is worth reviewing if moving fully to project-local tooling. Codex CLI (`codex`) and Claude Code (`claude`) are installed on every host. -Run each command as your normal user and follow its sign-in flow. Update these -Nix-managed packages through the flake and rebuild rather than their standalone -installers. Authentication stays in your home directory, outside this repo. +Run each command as your normal user and follow its sign-in flow. +They use official upstream binaries, with versions and SHA-256 checksums pinned +in `packages/ai-cli-versions.json`, independently of the stable NixOS release. +`packages/ai-cli.nix` packages Codex and reuses Nixpkgs' Claude Code wrapper. +Authentication stays in your home directory, outside this repo. + +To update both to the latest stable upstream releases, run from this repo: + +```sh +nix shell nixpkgs#python3 -c python3 scripts/update-ai-cli.py +git diff -- packages/ai-cli-versions.json +nix build .#codex .#claude-code --no-link +sudo nixos-rebuild switch --flake .#framework +codex --version +claude --version +git add packages/ai-cli-versions.json +git commit -m "chore(ai-cli): update upstream releases" +``` + +Use your own host name when rebuilding. The updater changes only the CLI pins; +`nix flake update` updates NixOS inputs but does not update these pins. +Updates remain explicit and reproducible; no upstream self-updaters are used. The official ChatGPT Linux desktop preview does not formally support NixOS. Our pinned `chatgpt` package supports macOS only, so it is not installed here. diff --git a/flake.nix b/flake.nix index 0b400a9..4d20efb 100644 --- a/flake.nix +++ b/flake.nix @@ -19,6 +19,14 @@ ... }: { + # Build the CLI packages separately before switching a host. + packages.x86_64-linux = import ./packages/ai-cli.nix { + pkgs = import nixpkgs { + system = "x86_64-linux"; + config.allowUnfree = true; + }; + }; + nixosConfigurations = { t450s = nixpkgs.lib.nixosSystem { specialArgs.unstable = import nixpkgs-unstable { diff --git a/modules/development.nix b/modules/development.nix index 056f583..2e6b1d7 100644 --- a/modules/development.nix +++ b/modules/development.nix @@ -1,4 +1,7 @@ { pkgs, ... }: +let + aiCli = import ../packages/ai-cli.nix { inherit pkgs; }; +in { # Workstation tools only; language runtimes and SDKs belong in projects. environment.systemPackages = with pkgs; [ @@ -19,8 +22,8 @@ zip # Coding assistants; authenticate separately as your normal user. - codex - claude-code + aiCli.codex + aiCli.claude-code # Infrastructure, containers, and structured data. ansible diff --git a/packages/ai-cli-versions.json b/packages/ai-cli-versions.json new file mode 100644 index 0000000..ccb08d0 --- /dev/null +++ b/packages/ai-cli-versions.json @@ -0,0 +1,10 @@ +{ + "codex": { + "version": "0.161.0", + "sha256": "04d8ab9dbcb9df0edf3c67dca5072a374babfdf762a9bc4ae649ae140b8e2cf0" + }, + "claude-code": { + "version": "2.1.292", + "sha256": "a967e7b1d8b4e47ee421d5433027880347952b0c0857abf880e2c942a4ec93b3" + } +} diff --git a/packages/ai-cli.nix b/packages/ai-cli.nix new file mode 100644 index 0000000..3ab060e --- /dev/null +++ b/packages/ai-cli.nix @@ -0,0 +1,53 @@ +{ pkgs }: +let + versions = builtins.fromJSON (builtins.readFile ./ai-cli-versions.json); +in +{ + # Use the complete official package, including Code Mode and voice helpers. + codex = pkgs.stdenvNoCC.mkDerivation { + pname = "codex"; + inherit (versions.codex) version; + src = pkgs.fetchurl { + url = "https://github.com/openai/codex/releases/download/rust-v${versions.codex.version}/codex-package-x86_64-unknown-linux-musl.tar.gz"; + inherit (versions.codex) sha256; + }; + sourceRoot = "."; + nativeBuildInputs = [ pkgs.autoPatchelfHook pkgs.makeWrapper ]; + buildInputs = [ pkgs.glibc pkgs.ncurses ]; + dontBuild = true; + dontStrip = true; + + installPhase = '' + runHook preInstall + mkdir -p "$out" + cp -r bin codex-path codex-resources codex-package.json "$out/" + runHook postInstall + ''; + postFixup = '' + wrapProgram "$out/bin/codex" \ + --prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.ripgrep pkgs.bubblewrap ]} + ''; + doInstallCheck = true; + installCheckPhase = '' + runHook preInstallCheck + "$out/bin/codex" --version | grep -F "codex-cli ${versions.codex.version}" + runHook postInstallCheck + ''; + meta = { + description = "OpenAI coding agent, official upstream binary package"; + homepage = "https://github.com/openai/codex"; + license = pkgs.lib.licenses.asl20; + mainProgram = "codex"; + platforms = [ "x86_64-linux" ]; + sourceProvenance = [ pkgs.lib.sourceTypes.binaryNativeCode ]; + }; + }; + + # Keep Nixpkgs' NixOS compatibility fixes and sandbox dependencies. + claude-code = pkgs.claude-code.override { + manifest = { + inherit (versions.claude-code) version; + platforms.linux-x64.checksum = versions.claude-code.sha256; + }; + }; +} diff --git a/scripts/update-ai-cli.py b/scripts/update-ai-cli.py new file mode 100644 index 0000000..a63e9cf --- /dev/null +++ b/scripts/update-ai-cli.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Pin the latest stable upstream CLI releases without updating NixOS.""" + +import json +import re +import urllib.request +from pathlib import Path + + +def fetch_json(url): + request = urllib.request.Request( + url, headers={"User-Agent": "nixos-config-ai-cli-updater"} + ) + with urllib.request.urlopen(request, timeout=60) as response: + return json.load(response) + + +def latest_release(repository): + release = fetch_json(f"https://api.github.com/repos/{repository}/releases/latest") + if release["prerelease"] or release["draft"]: + raise ValueError(f"Expected a stable release from {repository}") + return release + + +codex = latest_release("openai/codex") +codex_version = codex["tag_name"].removeprefix("rust-v") +claude = latest_release("anthropics/claude-code") +claude_version = claude["tag_name"].removeprefix("v") +for version in (codex_version, claude_version): + if not re.fullmatch(r"\d+\.\d+\.\d+", version): + raise ValueError(f"Unexpected release version: {version}") + +asset = next( + item for item in codex["assets"] + if item["name"] == "codex-package-x86_64-unknown-linux-musl.tar.gz" +) +digest = asset["digest"] +if not re.fullmatch(r"sha256:[0-9a-f]{64}", digest): + raise ValueError("Codex release asset has no valid SHA-256 digest") + +manifest = fetch_json( + f"https://downloads.claude.ai/claude-code-releases/{claude_version}/manifest.json" +) +if manifest["version"] != claude_version: + raise ValueError("Claude release and download manifest versions differ") +claude_hash = manifest["platforms"]["linux-x64"]["checksum"] +if not re.fullmatch(r"[0-9a-f]{64}", claude_hash): + raise ValueError("Claude manifest has no valid SHA-256 checksum") + +versions = { + "codex": {"version": codex_version, "sha256": digest.removeprefix("sha256:")}, + "claude-code": {"version": claude_version, "sha256": claude_hash}, +} +destination = Path(__file__).resolve().parent.parent / "packages" / "ai-cli-versions.json" +destination.write_text(json.dumps(versions, indent=2) + "\n") +print(f"Pinned Codex {codex_version} and Claude Code {claude_version}.") +print("Review the diff, build the packages, rebuild your host and commit the pins.")