From de924d2b01e543bed435465936af5b90fc373836 Mon Sep 17 00:00:00 2001 From: shiny-code-bot Date: Tue, 16 Jun 2026 18:10:59 -0400 Subject: [PATCH] Make codex-lab easy to dogfood --- .github/workflows/ci.yml | 3 + .github/workflows/codex-lab-app.yml | 4 +- .github/workflows/codex-lab-release.yml | 4 +- codex-rs/cli/Cargo.toml | 4 + codex-rs/cli/src/bin/codex-lab.rs | 1 + codex-rs/cli/src/main.rs | 85 ++++++++++--- codex-rs/exec/src/cli.rs | 7 +- docs/install.md | 21 +++- justfile | 10 ++ scripts/codex_lab_package/README.md | 2 +- scripts/local/install-codex-lab-dev.sh | 132 ++++++++++++++++++++ scripts/local/test_install_codex_lab_dev.py | 77 ++++++++++++ 12 files changed, 319 insertions(+), 31 deletions(-) create mode 100644 codex-rs/cli/src/bin/codex-lab.rs create mode 100755 scripts/local/install-codex-lab-dev.sh create mode 100644 scripts/local/test_install_codex_lab_dev.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0aebeb7dd524..8a7bf75acdfc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,9 @@ jobs: - name: Test Codex Lab app package builder run: python3 -m unittest discover -s scripts/codex_lab_package -p 'test_*.py' + - name: Test local developer scripts + run: python3 -m unittest discover -s scripts/local -p 'test_*.py' + - name: Smoke test Codex Lab app package layout shell: bash run: | diff --git a/.github/workflows/codex-lab-app.yml b/.github/workflows/codex-lab-app.yml index 9db7235f89b3..e181042e0fe8 100644 --- a/.github/workflows/codex-lab-app.yml +++ b/.github/workflows/codex-lab-app.yml @@ -39,7 +39,7 @@ jobs: - name: Build Codex Lab CLI working-directory: codex-rs shell: bash - run: cargo build --release -p codex-cli --bin codex + run: cargo build --release -p codex-cli --bin codex-lab - name: Build Codex Lab app bundle id: package @@ -52,7 +52,7 @@ jobs: lab_version="$(PYTHONPATH=scripts python3 -c 'from codex_package.version import read_workspace_version; print(read_workspace_version())')" mkdir -p "$output_root" python3 scripts/build_codex_lab_app.py \ - --codex-bin codex-rs/target/release/codex \ + --codex-bin codex-rs/target/release/codex-lab \ --app-dir "$app_dir" \ --shim-dir "$shim_dir" \ --short-version "$lab_version" \ diff --git a/.github/workflows/codex-lab-release.yml b/.github/workflows/codex-lab-release.yml index 6c74ee720ff9..a1560a3678f7 100644 --- a/.github/workflows/codex-lab-release.yml +++ b/.github/workflows/codex-lab-release.yml @@ -68,7 +68,7 @@ jobs: - name: Build Codex Lab CLI working-directory: codex-rs shell: bash - run: cargo build --release -p codex-cli --bin codex + run: cargo build --release -p codex-cli --bin codex-lab - name: Build Codex Lab app bundle id: package @@ -81,7 +81,7 @@ jobs: lab_version="$(PYTHONPATH=scripts python3 -c 'from codex_package.version import read_workspace_version; print(read_workspace_version())')" mkdir -p "$output_root" python3 scripts/build_codex_lab_app.py \ - --codex-bin codex-rs/target/release/codex \ + --codex-bin codex-rs/target/release/codex-lab \ --app-dir "$app_dir" \ --shim-dir "$shim_dir" \ --short-version "$lab_version" \ diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 6a4d78fbc74c..0bbbd991af68 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -9,6 +9,10 @@ build = "build.rs" name = "codex" path = "src/main.rs" +[[bin]] +name = "codex-lab" +path = "src/bin/codex-lab.rs" + [lib] name = "codex_cli" path = "src/lib.rs" diff --git a/codex-rs/cli/src/bin/codex-lab.rs b/codex-rs/cli/src/bin/codex-lab.rs new file mode 100644 index 000000000000..2bbd8f8d411a --- /dev/null +++ b/codex-rs/cli/src/bin/codex-lab.rs @@ -0,0 +1 @@ +include!("../main.rs"); diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 8ae735bbde82..203902b2623d 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1,5 +1,7 @@ use clap::Args; +use clap::Command; use clap::CommandFactory; +use clap::FromArgMatches; use clap::Parser; use clap_complete::Shell; use clap_complete::generate; @@ -92,12 +94,7 @@ use codex_terminal_detection::TerminalName; author, version, // If a sub‑command is given, ignore requirements of the default args. - subcommand_negates_reqs = true, - // The executable is sometimes invoked via a platform‑specific name like - // `codex-x86_64-unknown-linux-musl`, but the help output should always use - // the generic `codex` command name that users run. - bin_name = "codex", - override_usage = "codex [OPTIONS] [PROMPT]\n codex [OPTIONS] [ARGS]" + subcommand_negates_reqs = true )] struct MultitoolCli { #[clap(flatten)] @@ -894,19 +891,20 @@ fn stage_str(stage: Stage) -> &'static str { fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { - cli_main(arg0_paths).await?; + cli_main(arg0_paths, cli_command_name()).await?; Ok(()) }) } -async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { +async fn cli_main(arg0_paths: Arg0DispatchPaths, command_name: &'static str) -> anyhow::Result<()> { + let cli = named_multitool_command(command_name); let MultitoolCli { config_overrides: mut root_config_overrides, feature_toggles, remote, mut interactive, subcommand, - } = MultitoolCli::parse(); + } = MultitoolCli::from_arg_matches(&cli.get_matches())?; // Fold --enable/--disable into config overrides so they flow to all subcommands. let toggle_overrides = feature_toggles.to_overrides()?; @@ -959,7 +957,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { root_remote_auth_token_env.as_deref(), "review", )?; - let mut exec_cli = ExecCli::try_parse_from(["codex", "exec"])?; + let mut exec_cli = ExecCli::try_parse_from([command_name, "exec"])?; exec_cli .shared .inherit_exec_root_options(&interactive.shared); @@ -1306,7 +1304,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { root_remote_auth_token_env.as_deref(), "completion", )?; - print_completion(completion_cli); + print_completion(completion_cli, command_name); } Some(Subcommand::Update) => { reject_remote_mode_for_subcommand( @@ -2356,12 +2354,35 @@ fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) .extend(config_overrides.raw_overrides); } -fn print_completion(cmd: CompletionCommand) { - let mut app = MultitoolCli::command(); - let name = "codex"; +fn print_completion(cmd: CompletionCommand, command_name: &'static str) { + let mut app = named_multitool_command(command_name); + let name = command_name; generate(cmd.shell, &mut app, name, &mut std::io::stdout()); } +fn named_multitool_command(command_name: &'static str) -> Command { + MultitoolCli::command() + .bin_name(command_name) + .override_usage(format!( + "{command_name} [OPTIONS] [PROMPT]\n {command_name} [OPTIONS] [ARGS]" + )) +} + +fn cli_command_name() -> &'static str { + let Some(arg0) = std::env::args_os().next() else { + return "codex"; + }; + if std::path::Path::new(&arg0) + .file_name() + .and_then(|name| name.to_str()) + == Some("codex-lab") + { + "codex-lab" + } else { + "codex" + } +} + #[cfg(test)] mod tests { use super::*; @@ -2682,6 +2703,33 @@ mod tests { assert!(cmd.bundled); } + #[test] + fn codex_lab_command_name_updates_help_usage() { + let help = named_multitool_command("codex-lab") + .render_help() + .to_string(); + + assert!(help.contains("codex-lab [OPTIONS] [PROMPT]")); + assert!(help.contains("codex-lab [OPTIONS] [ARGS]")); + } + + #[test] + fn codex_command_name_keeps_upstream_usage() { + let help = named_multitool_command("codex").render_help().to_string(); + + assert!(help.contains("codex [OPTIONS] [PROMPT]")); + assert!(help.contains("codex [OPTIONS] [ARGS]")); + } + + #[test] + fn codex_lab_exec_help_uses_lab_command_and_home() { + let help = help_from_args(&["codex-lab", "exec", "--help"]); + + assert!(help.contains("Usage: codex-lab exec")); + assert!(help.contains("CODEX_LAB_HOME")); + assert!(!help.contains("CODEX_HOME")); + } + #[test] fn responses_subcommand_is_not_registered() { let command = MultitoolCli::command(); @@ -2693,7 +2741,14 @@ mod tests { } fn help_from_args(args: &[&str]) -> String { - let err = MultitoolCli::try_parse_from(args).expect_err("help should short-circuit"); + let command_name = if args.first().copied() == Some("codex-lab") { + "codex-lab" + } else { + "codex" + }; + let err = named_multitool_command(command_name) + .try_get_matches_from(args) + .expect_err("help should short-circuit"); assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp); err.to_string() } diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 53124c11a570..87e956adf4e3 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -7,10 +7,7 @@ use codex_utils_cli::SharedCliOptions; use std::path::PathBuf; #[derive(Parser, Debug)] -#[command( - version, - override_usage = "codex exec [OPTIONS] [PROMPT]\n codex exec [OPTIONS] [ARGS]" -)] +#[command(version)] pub struct Cli { /// Action to perform. If omitted, runs a new non-interactive session. #[command(subcommand)] @@ -31,7 +28,7 @@ pub struct Cli { #[arg(long = "ephemeral", global = true, default_value_t = false)] pub ephemeral: bool, - /// Do not load `$CODEX_HOME/config.toml`; auth still uses `CODEX_HOME`. + /// Do not load `$CODEX_LAB_HOME/config.toml`; auth still uses `CODEX_LAB_HOME`. #[arg(long = "ignore-user-config", global = true, default_value_t = false)] pub ignore_user_config: bool, diff --git a/docs/install.md b/docs/install.md index 2cd2cbe5cd6f..bdad4811b919 100644 --- a/docs/install.md +++ b/docs/install.md @@ -15,9 +15,9 @@ The GitHub Release also contains a [DotSlash](https://dotslash-cli.com/) file fo ### Build from source ```bash -# Clone the repository and navigate to the root of the Cargo workspace. -git clone https://github.com/openai/codex.git -cd codex/codex-rs +# Clone the repository and navigate to the root of the Codex Lab checkout. +git clone https://github.com/cbusillo/codex-lab.git +cd codex-lab # Install the Rust toolchain, if necessary. curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y @@ -29,11 +29,20 @@ cargo install --locked just # Install nextest for the `just test` helper. cargo install --locked cargo-nextest -# Build Codex. +# Build Codex Lab from the Rust workspace. +cd codex-rs cargo build -# Launch the TUI with a sample prompt. -cargo run --bin codex -- "explain this codebase to me" +# Launch the TUI with a sample prompt from the workspace. +cargo run --bin codex-lab -- "explain this codebase to me" +cd .. + +# Install the dogfood launcher into ~/.local/bin/codex-lab. The launcher keeps +# rebuilding this checkout incrementally, defaults CODEX_LAB_HOME to +# ~/.codex-lab when unset, and leaves upstream `codex` plus Every Code `code` +# untouched. +just install-codex-lab-dev +codex-lab "explain this codebase to me" # After making changes, use the root justfile helpers (they default to codex-rs): just fmt diff --git a/justfile b/justfile index 45275350c8a7..c6e2672ce481 100644 --- a/justfile +++ b/justfile @@ -18,6 +18,12 @@ alias c := codex codex *args: cargo run --bin codex -- {args} +# Install a local `codex-lab` launcher for dogfooding this checkout. +[no-cd] +[unix] +install-codex-lab-dev *args: + {{ justfile_directory() }}/scripts/local/install-codex-lab-dev.sh {args} + # `codex exec` exec *args: cargo run --bin codex -- exec {args} @@ -100,6 +106,10 @@ test *args: test-github-scripts: {{ python }} -m unittest discover -s {{ justfile_directory() }}/.github/scripts -p 'test_*.py' +[no-cd] +test-local-scripts: + {{ python }} -m unittest discover -s {{ justfile_directory() }}/scripts/local -p 'test_*.py' + # Run explicit workspace benchmark targets. bench *args: cargo bench --workspace --bench '*' {args} diff --git a/scripts/codex_lab_package/README.md b/scripts/codex_lab_package/README.md index c59f1026e18e..a453e21b1f7f 100644 --- a/scripts/codex_lab_package/README.md +++ b/scripts/codex_lab_package/README.md @@ -9,7 +9,7 @@ Example: ```shell scripts/build_codex_lab_app.py \ - --codex-bin codex-rs/target/release/codex \ + --codex-bin codex-rs/target/release/codex-lab \ --app-dir /tmp/Codex\ Lab.app \ --shim-dir /tmp/codex-lab-bin \ --force diff --git a/scripts/local/install-codex-lab-dev.sh b/scripts/local/install-codex-lab-dev.sh new file mode 100755 index 000000000000..caeaedba5fbe --- /dev/null +++ b/scripts/local/install-codex-lab-dev.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +bin_dir="${HOME}/.local/bin" +codex_lab_home="${HOME}/.codex-lab" +profile="dev" +force=0 +marker="# codex-lab-dev-shim: managed by scripts/local/install-codex-lab-dev.sh" + +usage() { + cat <&2 + usage >&2 + exit 2 + fi + bin_dir="$2" + shift + ;; + --codex-lab-home) + if [[ $# -lt 2 ]]; then + echo "error: --codex-lab-home requires a directory" >&2 + usage >&2 + exit 2 + fi + codex_lab_home="$2" + shift + ;; + --profile) + if [[ $# -lt 2 ]]; then + echo "error: --profile requires dev or release" >&2 + usage >&2 + exit 2 + fi + profile="$2" + shift + ;; + --force) + force=1 + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "error: unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac + shift +done + +case "$profile" in +dev) + target_subdir="debug" + cargo_profile_args=() + ;; +release) + target_subdir="release" + cargo_profile_args=(--release) + ;; +*) + echo "error: --profile must be dev or release" >&2 + exit 2 + ;; +esac + +mkdir -p "$bin_dir" +bin_dir="$(cd "$bin_dir" && pwd)" +mkdir -p "$codex_lab_home" +codex_lab_home="$(cd "$codex_lab_home" && pwd)" +shim_path="$bin_dir/codex-lab" + +if [[ -e "$shim_path" || -L "$shim_path" ]]; then + if [[ "$force" -ne 1 ]] && ! grep -Fq "$marker" "$shim_path" 2>/dev/null; then + echo "error: refusing to replace non-managed launcher: $shim_path" >&2 + echo "Re-run with --force if this is intentional." >&2 + exit 1 + fi +fi + +tmp_path="$shim_path.tmp.$$" +cat >"$tmp_path" </dev/null 2>&1 && [ -f "\$HOME/.cargo/env" ]; then + . "\$HOME/.cargo/env" +fi + +cargo build -p codex-cli --bin codex-lab ${cargo_profile_args[*]-} --manifest-path "\$REPO_ROOT/codex-rs/Cargo.toml" >/dev/null +TARGET_ROOT="\${CARGO_TARGET_DIR:-\$REPO_ROOT/codex-rs/target}" +exec "\$TARGET_ROOT/\$TARGET_SUBDIR/codex-lab" "\$@" +EOF +chmod 0755 "$tmp_path" +mv "$tmp_path" "$shim_path" + +echo "Installed Codex Lab dev launcher: $shim_path" +echo "Default CODEX_LAB_HOME: $codex_lab_home" +case ":$PATH:" in +*":$bin_dir:"*) ;; +*) echo "Note: $bin_dir is not currently on PATH." ;; +esac diff --git a/scripts/local/test_install_codex_lab_dev.py b/scripts/local/test_install_codex_lab_dev.py new file mode 100644 index 000000000000..34b436c76b9c --- /dev/null +++ b/scripts/local/test_install_codex_lab_dev.py @@ -0,0 +1,77 @@ +import subprocess +import tempfile +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +INSTALLER = REPO_ROOT / "scripts" / "local" / "install-codex-lab-dev.sh" + + +class InstallCodexLabDevTest(unittest.TestCase): + def test_installs_managed_launcher_with_lab_home_default(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir_name: + root = Path(temp_dir_name) + bin_dir = root / "bin" + lab_home = root / "home" + + result = subprocess.run( + [ + str(INSTALLER), + "--bin-dir", + str(bin_dir), + "--codex-lab-home", + str(lab_home), + ], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=True, + ) + + launcher = bin_dir / "codex-lab" + self.assertTrue(launcher.is_file()) + self.assertTrue(launcher.stat().st_mode & 0o111) + contents = launcher.read_text(encoding="utf-8") + self.assertIn("codex-lab-dev-shim", contents) + self.assertIn("CODEX_LAB_HOME", contents) + self.assertNotIn("CODEX_HOME", contents) + self.assertIn("--bin codex-lab", contents) + self.assertIn(str(REPO_ROOT), contents) + self.assertIn(str(lab_home), contents) + self.assertIn("Installed Codex Lab dev launcher", result.stdout) + + def test_refuses_to_replace_unmanaged_launcher_without_force(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir_name: + root = Path(temp_dir_name) + bin_dir = root / "bin" + bin_dir.mkdir() + launcher = bin_dir / "codex-lab" + launcher.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + + result = subprocess.run( + [str(INSTALLER), "--bin-dir", str(bin_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("refusing to replace non-managed launcher", result.stdout) + + def test_reports_missing_option_value(self) -> None: + result = subprocess.run( + [str(INSTALLER), "--bin-dir"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + + self.assertEqual(result.returncode, 2) + self.assertIn("--bin-dir requires a directory", result.stdout) + + +if __name__ == "__main__": + unittest.main()