From 0560079c414d191632bdfdb05568b92e2e974214 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Mon, 15 Sep 2025 10:22:02 -0700 Subject: [PATCH 1/3] notifications on approvals and turn end (#3329) uses OSC 9 to notify when a turn ends or approval is required. won't work in vs code or terminal.app but iterm2/kitty/wezterm supports it :) --- codex-rs/core/src/config.rs | 57 ++++++++++++++++++++++ codex-rs/core/src/config_types.rs | 19 +++++++- codex-rs/tui/src/app.rs | 1 + codex-rs/tui/src/chatwidget.rs | 73 ++++++++++++++++++++++++++++ codex-rs/tui/src/chatwidget/tests.rs | 1 + codex-rs/tui/src/diff_render.rs | 2 +- codex-rs/tui/src/tui.rs | 47 ++++++++++++++++++ docs/config.md | 20 +++++++- 8 files changed, 216 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index f96933069d..0479e857c2 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -1,6 +1,7 @@ use crate::config_profile::ConfigProfile; use crate::config_types::History; use crate::config_types::McpServerConfig; +use crate::config_types::Notifications; use crate::config_types::ReasoningSummaryFormat; use crate::config_types::SandboxWorkspaceWrite; use crate::config_types::ShellEnvironmentPolicy; @@ -117,6 +118,10 @@ pub struct Config { /// If unset the feature is disabled. pub notify: Option>, + /// TUI notifications preference. When set, the TUI will send OSC 9 notifications on approvals + /// and turn completions when not focused. + pub tui_notifications: Notifications, + /// The directory that should be treated as the current working directory /// for the session. All relative paths inside the business-logic layer are /// resolved against this path. @@ -1043,6 +1048,11 @@ impl Config { include_view_image_tool, active_profile: active_profile_name, disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false), + tui_notifications: cfg + .tui + .as_ref() + .map(|t| t.notifications.clone()) + .unwrap_or_default(), }; Ok(config) } @@ -1606,6 +1616,7 @@ model_verbosity = "high" include_view_image_tool: true, active_profile: Some("o3".to_string()), disable_paste_burst: false, + tui_notifications: Default::default(), }, o3_profile_config ); @@ -1663,6 +1674,7 @@ model_verbosity = "high" include_view_image_tool: true, active_profile: Some("gpt3".to_string()), disable_paste_burst: false, + tui_notifications: Default::default(), }; assert_eq!(expected_gpt3_profile_config, gpt3_profile_config); @@ -1735,6 +1747,7 @@ model_verbosity = "high" include_view_image_tool: true, active_profile: Some("zdr".to_string()), disable_paste_burst: false, + tui_notifications: Default::default(), }; assert_eq!(expected_zdr_profile_config, zdr_profile_config); @@ -1793,6 +1806,7 @@ model_verbosity = "high" include_view_image_tool: true, active_profile: Some("gpt5".to_string()), disable_paste_burst: false, + tui_notifications: Default::default(), }; assert_eq!(expected_gpt5_profile_config, gpt5_profile_config); @@ -1896,3 +1910,46 @@ trust_level = "trusted" Ok(()) } } + +#[cfg(test)] +mod notifications_tests { + use crate::config_types::Notifications; + use serde::Deserialize; + + #[derive(Deserialize, Debug, PartialEq)] + struct TuiTomlTest { + notifications: Notifications, + } + + #[derive(Deserialize, Debug, PartialEq)] + struct RootTomlTest { + tui: TuiTomlTest, + } + + #[test] + fn test_tui_notifications_true() { + let toml = r#" + [tui] + notifications = true + "#; + let parsed: RootTomlTest = toml::from_str(toml).expect("deserialize notifications=true"); + assert!(matches!( + parsed.tui.notifications, + Notifications::Enabled(true) + )); + } + + #[test] + fn test_tui_notifications_custom_array() { + let toml = r#" + [tui] + notifications = ["foo"] + "#; + let parsed: RootTomlTest = + toml::from_str(toml).expect("deserialize notifications=[\"foo\"]"); + assert!(matches!( + parsed.tui.notifications, + Notifications::Custom(ref v) if v == &vec!["foo".to_string()] + )); + } +} diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index 0722dfc0ea..ec8e8e67c6 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -76,9 +76,26 @@ pub enum HistoryPersistence { None, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(untagged)] +pub enum Notifications { + Enabled(bool), + Custom(Vec), +} + +impl Default for Notifications { + fn default() -> Self { + Self::Enabled(false) + } +} + /// Collection of settings that are specific to the TUI. #[derive(Deserialize, Debug, Clone, PartialEq, Default)] -pub struct Tui {} +pub struct Tui { + /// Enable desktop notifications from the TUI when the terminal is unfocused. + /// Defaults to `false`. + pub notifications: Notifications, +} #[derive(Deserialize, Debug, Clone, PartialEq, Default)] pub struct SandboxWorkspaceWrite { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index bfa9c0caf2..205986c916 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -177,6 +177,7 @@ impl App { self.chat_widget.handle_paste(pasted); } TuiEvent::Draw => { + self.chat_widget.maybe_post_pending_notification(tui); if self .chat_widget .handle_paste_burst_tick(tui.frame_requester()) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 367a63cf11..c03e7d3bc5 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use std::sync::Arc; use codex_core::config::Config; +use codex_core::config_types::Notifications; use codex_core::protocol::AgentMessageDeltaEvent; use codex_core::protocol::AgentMessageEvent; use codex_core::protocol::AgentReasoningDeltaEvent; @@ -59,6 +60,7 @@ use crate::bottom_pane::InputResult; use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; use crate::clipboard_paste::paste_image_to_temp_png; +use crate::diff_render::display_path_for; use crate::get_git_diff::get_git_diff; use crate::history_cell; use crate::history_cell::CommandOutput; @@ -66,6 +68,7 @@ use crate::history_cell::ExecCell; use crate::history_cell::HistoryCell; use crate::history_cell::PatchEventType; use crate::slash_command::SlashCommand; +use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; // streaming internals are provided by crate::streaming and crate::markdown_stream use crate::user_approval_widget::ApprovalRequest; @@ -136,6 +139,8 @@ pub(crate) struct ChatWidget { suppress_session_configured_redraw: bool, // User messages queued while a turn is in progress queued_user_messages: VecDeque, + // Pending notification to show when unfocused on next Draw + pending_notification: Option, } struct UserMessage { @@ -265,6 +270,8 @@ impl ChatWidget { // If there is a queued user message, send exactly one now to begin the next turn. self.maybe_send_next_queued_input(); + // Emit a notification when the turn completes (suppressed if focused). + self.notify(Notification::AgentTurnComplete); } pub(crate) fn set_token_info(&mut self, info: Option) { @@ -531,6 +538,9 @@ impl ChatWidget { self.flush_answer_stream_with_separator(); // Emit the proposed command into history (like proposed patches) self.add_to_history(history_cell::new_proposed_command(&ev.command)); + let command = shlex::try_join(ev.command.iter().map(|s| s.as_str())) + .unwrap_or_else(|_| ev.command.join(" ")); + self.notify(Notification::ExecApprovalRequested { command }); let request = ApprovalRequest::Exec { id, @@ -560,6 +570,10 @@ impl ChatWidget { }; self.bottom_pane.push_approval_request(request); self.request_redraw(); + self.notify(Notification::EditApprovalRequested { + cwd: self.config.cwd.clone(), + changes: ev.changes.keys().cloned().collect(), + }); } pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) { @@ -686,6 +700,7 @@ impl ChatWidget { queued_user_messages: VecDeque::new(), show_welcome_banner: true, suppress_session_configured_redraw: false, + pending_notification: None, } } @@ -741,6 +756,7 @@ impl ChatWidget { queued_user_messages: VecDeque::new(), show_welcome_banner: true, suppress_session_configured_redraw: true, + pending_notification: None, } } @@ -1137,6 +1153,20 @@ impl ChatWidget { self.frame_requester.schedule_frame(); } + fn notify(&mut self, notification: Notification) { + if !notification.allowed_for(&self.config.tui_notifications) { + return; + } + self.pending_notification = Some(notification); + self.request_redraw(); + } + + pub(crate) fn maybe_post_pending_notification(&mut self, tui: &mut crate::tui::Tui) { + if let Some(notif) = self.pending_notification.take() { + tui.notify(notif.display()); + } + } + /// Mark the active exec cell as failed (✗) and flush it into history. fn finalize_active_exec_cell_as_failed(&mut self) { if let Some(cell) = self.active_exec_cell.take() { @@ -1449,6 +1479,49 @@ impl WidgetRef for &ChatWidget { } } +enum Notification { + AgentTurnComplete, + ExecApprovalRequested { command: String }, + EditApprovalRequested { cwd: PathBuf, changes: Vec }, +} + +impl Notification { + fn display(&self) -> String { + match self { + Notification::AgentTurnComplete => "Agent turn complete".to_string(), + Notification::ExecApprovalRequested { command } => { + format!("Approval requested: {}", truncate_text(command, 30)) + } + Notification::EditApprovalRequested { cwd, changes } => { + format!( + "Codex wants to edit {}", + if changes.len() == 1 { + #[allow(clippy::unwrap_used)] + display_path_for(changes.first().unwrap(), cwd) + } else { + format!("{} files", changes.len()) + } + ) + } + } + } + + fn type_name(&self) -> &str { + match self { + Notification::AgentTurnComplete => "agent-turn-complete", + Notification::ExecApprovalRequested { .. } + | Notification::EditApprovalRequested { .. } => "approval-requested", + } + } + + fn allowed_for(&self, settings: &Notifications) -> bool { + match settings { + Notifications::Enabled(enabled) => *enabled, + Notifications::Custom(allowed) => allowed.iter().any(|a| a == self.type_name()), + } + } +} + const EXAMPLE_PROMPTS: [&str; 6] = [ "Explain this codebase", "Summarize recent commits", diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 73845bfe09..fb4ea5e888 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -251,6 +251,7 @@ fn make_chatwidget_manual() -> ( show_welcome_banner: true, queued_user_messages: VecDeque::new(), suppress_session_configured_redraw: false, + pending_notification: None, }; (widget, rx, op_rx) } diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index 20d26106b2..2787f76607 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -265,7 +265,7 @@ fn render_changes_block( out } -fn display_path_for(path: &Path, cwd: &Path) -> String { +pub(crate) fn display_path_for(path: &Path, cwd: &Path) -> String { let path_in_same_repo = match (get_git_repo_root(cwd), get_git_repo_root(path)) { (Some(cwd_repo), Some(path_repo)) => cwd_repo == path_repo, _ => false, diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 26c188668d..6d7b9e8101 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -17,7 +17,9 @@ use crossterm::SynchronizedUpdate; use crossterm::cursor; use crossterm::cursor::MoveTo; use crossterm::event::DisableBracketedPaste; +use crossterm::event::DisableFocusChange; use crossterm::event::EnableBracketedPaste; +use crossterm::event::EnableFocusChange; use crossterm::event::Event; use crossterm::event::KeyEvent; use crossterm::event::KeyboardEnhancementFlags; @@ -60,6 +62,8 @@ pub fn set_modes() -> Result<()> { | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS ) ); + + let _ = execute!(stdout(), EnableFocusChange); Ok(()) } @@ -111,6 +115,7 @@ pub fn restore() -> Result<()> { // Pop may fail on platforms that didn't support the push; ignore errors. let _ = execute!(stdout(), PopKeyboardEnhancementFlags); execute!(stdout(), DisableBracketedPaste)?; + let _ = execute!(stdout(), DisableFocusChange); disable_raw_mode()?; let _ = execute!(stdout(), crossterm::cursor::Show); Ok(()) @@ -163,6 +168,8 @@ pub struct Tui { suspend_cursor_y: Arc, // Bottom line of inline viewport // True when overlay alt-screen UI is active alt_screen_active: Arc, + // True when terminal/tab is focused; updated internally from crossterm events + terminal_focused: Arc, } #[cfg(unix)] @@ -214,6 +221,16 @@ impl FrameRequester { } impl Tui { + /// Emit a desktop notification now if the terminal is unfocused. + /// Returns true if a notification was posted. + pub fn notify(&mut self, message: impl AsRef) -> bool { + if !self.terminal_focused.load(Ordering::Relaxed) { + let _ = execute!(stdout(), PostNotification(message.as_ref().to_string())); + true + } else { + false + } + } pub fn new(terminal: Terminal) -> Self { let (frame_schedule_tx, frame_schedule_rx) = tokio::sync::mpsc::unbounded_channel(); let (draw_tx, _) = tokio::sync::broadcast::channel(1); @@ -270,6 +287,7 @@ impl Tui { #[cfg(unix)] suspend_cursor_y: Arc::new(AtomicU16::new(0)), alt_screen_active: Arc::new(AtomicBool::new(false)), + terminal_focused: Arc::new(AtomicBool::new(true)), } } @@ -289,6 +307,7 @@ impl Tui { let alt_screen_active = self.alt_screen_active.clone(); #[cfg(unix)] let suspend_cursor_y = self.suspend_cursor_y.clone(); + let terminal_focused = self.terminal_focused.clone(); let event_stream = async_stream::stream! { loop { select! { @@ -332,6 +351,12 @@ impl Tui { Event::Paste(pasted) => { yield TuiEvent::Paste(pasted); } + Event::FocusGained => { + terminal_focused.store(true, Ordering::Relaxed); + } + Event::FocusLost => { + terminal_focused.store(false, Ordering::Relaxed); + } _ => {} } } @@ -535,3 +560,25 @@ impl Tui { })? } } + +/// Command that emits an OSC 9 desktop notification with a message. +#[derive(Debug, Clone)] +pub struct PostNotification(pub String); + +impl Command for PostNotification { + fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result { + write!(f, "\x1b]9;{}\x07", self.0) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + Err(std::io::Error::other( + "tried to execute PostNotification using WinAPI; use ANSI instead", + )) + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + true + } +} diff --git a/docs/config.md b/docs/config.md index 6f10cb3fbd..01b3efe0a1 100644 --- a/docs/config.md +++ b/docs/config.md @@ -504,6 +504,9 @@ To have Codex use this script for notifications, you would configure it via `not notify = ["python3", "/Users/mbolin/.codex/notify.py"] ``` +> [!NOTE] +> Use `notify` for automation and integrations: Codex invokes your external program with a single JSON argument for each event, independent of the TUI. If you only want lightweight desktop notifications while using the TUI, prefer `tui.notifications`, which uses terminal escape codes and requires no external program. You can enable both; `tui.notifications` covers in‑TUI alerts (e.g., approval prompts), while `notify` is best for system‑level hooks or custom notifiers. Currently, `notify` emits only `agent-turn-complete`, whereas `tui.notifications` supports `agent-turn-complete` and `approval-requested` with optional filtering. + ## history By default, Codex CLI records messages sent to the model in `$CODEX_HOME/history.jsonl`. Note that on UNIX, the file permissions are set to `o600`, so it should only be readable and writable by the owner. @@ -576,9 +579,21 @@ Options that are specific to the TUI. ```toml [tui] -# More to come here +# Send desktop notifications when approvals are required or a turn completes. +# Defaults to false. +notifications = true + +# You can optionally filter to specific notification types. +# Available types are "agent-turn-complete" and "approval-requested". +notifications = [ "agent-turn-complete", "approval-requested" ] ``` +> [!NOTE] +> Codex emits desktop notifications using terminal escape codes. Not all terminals support these (notably, macOS Terminal.app and VS Code's terminal do not support custom notifications. iTerm2, Ghostty and WezTerm do support these notifications). + +> [!NOTE] +> `tui.notifications` is built‑in and limited to the TUI session. For programmatic or cross‑environment notifications—or to integrate with OS‑specific notifiers—use the top‑level `notify` option to run an external program that receives event JSON. The two settings are independent and can be used together. + ## Config reference | Key | Type / Values | Notes | @@ -616,7 +631,8 @@ Options that are specific to the TUI. | `history.persistence` | `save-all` \| `none` | History file persistence (default: `save-all`). | | `history.max_bytes` | number | Currently ignored (not enforced). | | `file_opener` | `vscode` \| `vscode-insiders` \| `windsurf` \| `cursor` \| `none` | URI scheme for clickable citations (default: `vscode`). | -| `tui` | table | TUI‑specific options (reserved). | +| `tui` | table | TUI‑specific options. | +| `tui.notifications` | boolean \| array | Enable desktop notifications in the tui (default: false). | | `hide_agent_reasoning` | boolean | Hide model reasoning events. | | `show_raw_agent_reasoning` | boolean | Show raw reasoning (when available). | | `model_reasoning_effort` | `minimal` \| `low` \| `medium` \| `high` | Responses API reasoning effort. | From 2df7f7efe5ace16214ed6c9360f459871e3b5e75 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 15 Sep 2025 10:52:49 -0700 Subject: [PATCH 2/3] chore: restore prerelease logic in rust-release.yml (#3659) Revert #3645. --- .github/workflows/rust-release.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 2b51c099eb..07af62a17c 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -211,8 +211,7 @@ jobs: files: dist/** # Mark as prerelease only when the version has a suffix after x.y.z # (e.g. -alpha, -beta). Otherwise publish a normal release. - # prerelease: ${{ contains(steps.release_name.outputs.name, '-') }} - prerelease: true + prerelease: ${{ contains(steps.release_name.outputs.name, '-') }} - uses: facebook/dotslash-publish-release@v2 env: From 34cb2225ab116ecbe2994a0c9f1ae12ea842fcd2 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 15 Sep 2025 11:11:27 -0700 Subject: [PATCH 3/3] fix: vendor ripgrep in the npm module --- .github/workflows/rust-release.yml | 9 +- codex-cli/bin/codex.js | 25 +-- codex-cli/bin/rg | 79 +++++++++ codex-cli/package-lock.json | 101 ------------ codex-cli/package.json | 3 - codex-cli/scripts/build_npm_package.py | 163 +++++++++++++++++++ codex-cli/scripts/install_native_deps.sh | 4 +- codex-cli/scripts/install_rg.py | 61 +++++++ codex-cli/scripts/rg_utils.py | 197 +++++++++++++++++++++++ codex-cli/scripts/stage_release.sh | 76 ++++----- codex-cli/scripts/stage_rust_release.py | 16 +- 11 files changed, 550 insertions(+), 184 deletions(-) create mode 100755 codex-cli/bin/rg create mode 100755 codex-cli/scripts/build_npm_package.py create mode 100755 codex-cli/scripts/install_rg.py create mode 100644 codex-cli/scripts/rg_utils.py diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 07af62a17c..92b47873d3 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -195,13 +195,8 @@ jobs: TMP_DIR="${RUNNER_TEMP}/npm-stage" python3 codex-cli/scripts/stage_rust_release.py \ --release-version "${{ steps.release_name.outputs.name }}" \ - --tmp "${TMP_DIR}" - mkdir -p dist/npm - # Produce an npm-ready tarball using `npm pack` and store it in dist/npm. - # We then rename it to a stable name used by our publishing script. - (cd "$TMP_DIR" && npm pack --pack-destination "${GITHUB_WORKSPACE}/dist/npm") - mv "${GITHUB_WORKSPACE}"/dist/npm/*.tgz \ - "${GITHUB_WORKSPACE}/dist/npm/codex-npm-${{ steps.release_name.outputs.name }}.tgz" + --tmp "${TMP_DIR}" \ + --pack-output "${GITHUB_WORKSPACE}/dist/npm/codex-npm-${{ steps.release_name.outputs.name }}.tgz" - name: Create GitHub Release uses: softprops/action-gh-release@v2 diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js index f24065170d..47b28679b3 100755 --- a/codex-cli/bin/codex.js +++ b/codex-cli/bin/codex.js @@ -1,6 +1,7 @@ #!/usr/bin/env node // Unified entry point for the Codex CLI. +import { existsSync } from "fs"; import path from "path"; import { fileURLToPath } from "url"; @@ -66,23 +67,6 @@ const binaryPath = path.join(__dirname, "..", "bin", `codex-${targetTriple}`); // receives a fatal signal, both processes exit in a predictable manner. const { spawn } = await import("child_process"); -async function tryImport(moduleName) { - try { - // eslint-disable-next-line node/no-unsupported-features/es-syntax - return await import(moduleName); - } catch (err) { - return null; - } -} - -async function resolveRgDir() { - const ripgrep = await tryImport("@vscode/ripgrep"); - if (!ripgrep?.rgPath) { - return null; - } - return path.dirname(ripgrep.rgPath); -} - function getUpdatedPath(newDirs) { const pathSep = process.platform === "win32" ? ";" : ":"; const existingPath = process.env.PATH || ""; @@ -94,9 +78,10 @@ function getUpdatedPath(newDirs) { } const additionalDirs = []; -const rgDir = await resolveRgDir(); -if (rgDir) { - additionalDirs.push(rgDir); +const rgBinaryName = `rg-${targetTriple}`; +const rgBinaryPath = path.join(__dirname, "..", "bin", rgBinaryName); +if (existsSync(rgBinaryPath)) { + additionalDirs.push(path.dirname(rgBinaryPath)); } const updatedPath = getUpdatedPath(additionalDirs); diff --git a/codex-cli/bin/rg b/codex-cli/bin/rg new file mode 100755 index 0000000000..5a992570a9 --- /dev/null +++ b/codex-cli/bin/rg @@ -0,0 +1,79 @@ +#!/usr/bin/env dotslash + +{ + "name": "rg", + "platforms": { + "macos-aarch64": { + "size": 1787248, + "hash": "blake3", + "digest": "8d9942032585ea8ee805937634238d9aee7b210069f4703c88fbe568e26fb78a", + "format": "tar.gz", + "path": "ripgrep-14.1.1-aarch64-apple-darwin/rg", + "providers": [ + { + "url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-aarch64-apple-darwin.tar.gz" + } + ] + }, + "linux-aarch64": { + "size": 2047405, + "hash": "blake3", + "digest": "0b670b8fa0a3df2762af2fc82cc4932f684ca4c02dbd1260d4f3133fd4b2a515", + "format": "tar.gz", + "path": "ripgrep-14.1.1-aarch64-unknown-linux-gnu/rg", + "providers": [ + { + "url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-aarch64-unknown-linux-gnu.tar.gz" + } + ] + }, + "macos-x86_64": { + "size": 2082672, + "hash": "blake3", + "digest": "e9b862fc8da3127f92791f0ff6a799504154ca9d36c98bf3e60a81c6b1f7289e", + "format": "tar.gz", + "path": "ripgrep-14.1.1-x86_64-apple-darwin/rg", + "providers": [ + { + "url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-x86_64-apple-darwin.tar.gz" + } + ] + }, + "linux-x86_64": { + "size": 2566310, + "hash": "blake3", + "digest": "f73cca4e54d78c31f832c7f6e2c0b4db8b04fa3eaa747915727d570893dbee76", + "format": "tar.gz", + "path": "ripgrep-14.1.1-x86_64-unknown-linux-musl/rg", + "providers": [ + { + "url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-x86_64-unknown-linux-musl.tar.gz" + } + ] + }, + "windows-x86_64": { + "size": 2058893, + "hash": "blake3", + "digest": "a8ce1a6fed4f8093ee997e57f33254e94b2cd18e26358b09db599c89882eadbd", + "format": "zip", + "path": "ripgrep-14.1.1-x86_64-pc-windows-msvc/rg.exe", + "providers": [ + { + "url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-x86_64-pc-windows-msvc.zip" + } + ] + }, + "windows-aarch64": { + "size": 1667740, + "hash": "blake3", + "digest": "47b971a8c4fca1d23a4e7c19bd4d88465ebc395598458133139406d3bf85f3fa", + "format": "zip", + "path": "rg.exe", + "providers": [ + { + "url": "https://github.com/microsoft/ripgrep-prebuilt/releases/download/v13.0.0-13/ripgrep-v13.0.0-13-aarch64-pc-windows-msvc.zip" + } + ] + } + } +} diff --git a/codex-cli/package-lock.json b/codex-cli/package-lock.json index a1c840ade0..a28bb9cd9f 100644 --- a/codex-cli/package-lock.json +++ b/codex-cli/package-lock.json @@ -2,118 +2,17 @@ "name": "@openai/codex", "version": "0.0.0-dev", "lockfileVersion": 3, - "requires": true, "packages": { "": { "name": "@openai/codex", "version": "0.0.0-dev", "license": "Apache-2.0", - "dependencies": { - "@vscode/ripgrep": "^1.15.14" - }, "bin": { "codex": "bin/codex.js" }, "engines": { "node": ">=20" } - }, - "node_modules/@vscode/ripgrep": { - "version": "1.15.14", - "resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.15.14.tgz", - "integrity": "sha512-/G1UJPYlm+trBWQ6cMO3sv6b8D1+G16WaJH1/DSqw32JOVlzgZbLkDxRyzIpTpv30AcYGMkCf5tUqGlW6HbDWw==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "https-proxy-agent": "^7.0.2", - "proxy-from-env": "^1.1.0", - "yauzl": "^2.9.2" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "license": "MIT" - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } } } } diff --git a/codex-cli/package.json b/codex-cli/package.json index 614ca1a832..162773a25d 100644 --- a/codex-cli/package.json +++ b/codex-cli/package.json @@ -17,9 +17,6 @@ "type": "git", "url": "git+https://github.com/openai/codex.git" }, - "dependencies": { - "@vscode/ripgrep": "^1.15.14" - }, "devDependencies": { "prettier": "^3.3.3" } diff --git a/codex-cli/scripts/build_npm_package.py b/codex-cli/scripts/build_npm_package.py new file mode 100755 index 0000000000..fbf412db49 --- /dev/null +++ b/codex-cli/scripts/build_npm_package.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Stage and optionally package the @openai/codex npm module.""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Sequence + +SCRIPT_DIR = Path(__file__).resolve().parent +CODEX_CLI_ROOT = SCRIPT_DIR.parent +REPO_ROOT = CODEX_CLI_ROOT.parent + +sys.path.insert(0, str(SCRIPT_DIR)) + +from rg_utils import DEFAULT_RG_TARGETS, fetch_rg # noqa: E402 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build the Codex CLI npm package.") + parser.add_argument( + "--version", + required=True, + help="Version number to write to package.json inside the staged package.", + ) + parser.add_argument( + "--workflow-url", + help="Optional GitHub Actions workflow run URL used to download native binaries.", + ) + parser.add_argument( + "--staging-dir", + type=Path, + help=( + "Directory to stage the package contents. Defaults to a new temporary directory " + "if omitted. The directory must be empty when provided." + ), + ) + parser.add_argument( + "--pack-output", + type=Path, + help="Path where the generated npm tarball should be written.", + ) + parser.add_argument( + "--rg-target", + action="append", + dest="rg_targets", + help="Codex target triple for which ripgrep should be fetched. Repeatable.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + + staging_dir, created_temp = prepare_staging_dir(args.staging_dir) + + try: + stage_sources(staging_dir, args.version) + install_native_binaries(staging_dir, args.workflow_url) + + rg_targets: Sequence[str] | None = args.rg_targets + if rg_targets is None: + rg_targets = DEFAULT_RG_TARGETS + fetch_rg(staging_dir / "bin", rg_targets) + + print(f"Staged package in {staging_dir}") + + if args.pack_output is not None: + output_path = run_npm_pack(staging_dir, args.pack_output) + print(f"npm pack output written to {output_path}") + finally: + if created_temp: + # Preserve the staging directory for further inspection. + pass + + return 0 + + +def prepare_staging_dir(staging_dir: Path | None) -> tuple[Path, bool]: + if staging_dir is not None: + staging_dir = staging_dir.resolve() + staging_dir.mkdir(parents=True, exist_ok=True) + if any(staging_dir.iterdir()): + raise RuntimeError(f"Staging directory {staging_dir} is not empty.") + return staging_dir, False + + temp_dir = Path(tempfile.mkdtemp(prefix="codex-npm-stage-")) + return temp_dir, True + + +def stage_sources(staging_dir: Path, version: str) -> None: + bin_dir = staging_dir / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + + shutil.copy2(CODEX_CLI_ROOT / "bin" / "codex.js", bin_dir / "codex.js") + rg_manifest = CODEX_CLI_ROOT / "bin" / "rg" + if rg_manifest.exists(): + shutil.copy2(rg_manifest, bin_dir / "rg") + + readme_src = REPO_ROOT / "README.md" + if readme_src.exists(): + shutil.copy2(readme_src, staging_dir / "README.md") + + with open(CODEX_CLI_ROOT / "package.json", "r", encoding="utf-8") as fh: + package_json = json.load(fh) + package_json["version"] = version + + with open(staging_dir / "package.json", "w", encoding="utf-8") as out: + json.dump(package_json, out, indent=2) + out.write("\n") + + +def install_native_binaries(staging_dir: Path, workflow_url: str | None) -> None: + cmd = ["./scripts/install_native_deps.sh"] + if workflow_url: + cmd.extend(["--workflow-url", workflow_url]) + cmd.append(str(staging_dir)) + subprocess.run(cmd, cwd=CODEX_CLI_ROOT, check=True) + + +def run_npm_pack(staging_dir: Path, output_path: Path) -> Path: + output_path = output_path.resolve() + output_path.parent.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory(prefix="codex-npm-pack-") as pack_dir_str: + pack_dir = Path(pack_dir_str) + result = subprocess.run( + ["npm", "pack", "--json", "--pack-destination", str(pack_dir)], + cwd=staging_dir, + check=True, + text=True, + capture_output=True, + ) + + try: + pack_output = json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise RuntimeError("Failed to parse npm pack output.") from exc + + if not pack_output: + raise RuntimeError("npm pack did not produce an output tarball.") + + tarball_name = pack_output[0].get("filename") or pack_output[0].get("name") + if not tarball_name: + raise RuntimeError("Unable to determine npm pack output filename.") + + tarball_path = pack_dir / tarball_name + if not tarball_path.exists(): + raise RuntimeError(f"Expected npm pack output not found: {tarball_path}") + + shutil.move(str(tarball_path), output_path) + + return output_path + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/codex-cli/scripts/install_native_deps.sh b/codex-cli/scripts/install_native_deps.sh index b9fda2543e..368d89865a 100755 --- a/codex-cli/scripts/install_native_deps.sh +++ b/codex-cli/scripts/install_native_deps.sh @@ -91,4 +91,6 @@ zstd -d "$ARTIFACTS_DIR/x86_64-pc-windows-msvc/codex-x86_64-pc-windows-msvc.exe. zstd -d "$ARTIFACTS_DIR/aarch64-pc-windows-msvc/codex-aarch64-pc-windows-msvc.exe.zst" \ -o "$BIN_DIR/codex-aarch64-pc-windows-msvc.exe" -echo "Installed native dependencies into $BIN_DIR" +python3 "$CODEX_CLI_ROOT/scripts/install_rg.py" --bin-dir "$BIN_DIR" + +echo "Installed native dependencies (including ripgrep) into $BIN_DIR" diff --git a/codex-cli/scripts/install_rg.py b/codex-cli/scripts/install_rg.py new file mode 100755 index 0000000000..5615938da6 --- /dev/null +++ b/codex-cli/scripts/install_rg.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Download ripgrep binaries into the codex-cli bin directory.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from rg_utils import DEFAULT_RG_TARGETS, detect_current_target, fetch_rg # noqa: E402 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Install ripgrep binaries.") + parser.add_argument( + "--bin-dir", + type=Path, + required=True, + help="Directory where ripgrep binaries should be written.", + ) + parser.add_argument( + "--target", + action="append", + dest="targets", + help="Codex target triple to fetch. Repeatable. Defaults to all.", + ) + parser.add_argument( + "--current-only", + action="store_true", + help="Download ripgrep only for the current platform.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + + if args.current_only and args.targets: + print("Cannot combine --current-only with explicit --target options.", file=sys.stderr) + return 1 + + if args.current_only: + current = detect_current_target() + if current is None: + print("Unable to detect current platform for ripgrep download.", file=sys.stderr) + return 1 + targets = [current] + elif args.targets: + targets = args.targets + else: + targets = DEFAULT_RG_TARGETS + + fetch_rg(args.bin_dir, targets) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/codex-cli/scripts/rg_utils.py b/codex-cli/scripts/rg_utils.py new file mode 100644 index 0000000000..217f9b4d9e --- /dev/null +++ b/codex-cli/scripts/rg_utils.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""Shared helpers for staging ripgrep binaries.""" + +from __future__ import annotations + +import json +import os +import platform +import shutil +import subprocess +import tarfile +import tempfile +import zipfile +from pathlib import Path +from typing import Iterable, Sequence +from urllib.parse import urlparse +from urllib.request import urlopen + +SCRIPT_DIR = Path(__file__).resolve().parent +CODEX_CLI_ROOT = SCRIPT_DIR.parent +RG_MANIFEST = CODEX_CLI_ROOT / "bin" / "rg" + +RG_TARGET_PLATFORM_PAIRS: list[tuple[str, str]] = [ + ("x86_64-unknown-linux-musl", "linux-x86_64"), + ("aarch64-unknown-linux-musl", "linux-aarch64"), + ("x86_64-apple-darwin", "macos-x86_64"), + ("aarch64-apple-darwin", "macos-aarch64"), + ("x86_64-pc-windows-msvc.exe", "windows-x86_64"), + ("aarch64-pc-windows-msvc.exe", "windows-aarch64"), +] +RG_TARGET_TO_PLATFORM = {target: platform for target, platform in RG_TARGET_PLATFORM_PAIRS} +DEFAULT_RG_TARGETS = [target for target, _ in RG_TARGET_PLATFORM_PAIRS] + + +def detect_current_target() -> str | None: + """Return the Codex target triple for the current platform, if known.""" + + sys_platform = platform.system().lower() + machine = platform.machine().lower() + + if sys_platform in {"linux", "linux2"}: + if machine in {"x86_64", "amd64"}: + return "x86_64-unknown-linux-musl" + if machine in {"arm64", "aarch64"}: + return "aarch64-unknown-linux-musl" + elif sys_platform == "darwin": + if machine == "x86_64": + return "x86_64-apple-darwin" + if machine in {"arm64", "aarch64"}: + return "aarch64-apple-darwin" + elif sys_platform.startswith("win"): + if machine in {"x86_64", "amd64"}: + return "x86_64-pc-windows-msvc.exe" + if machine in {"arm64", "aarch64"}: + return "aarch64-pc-windows-msvc.exe" + return None + + +def fetch_rg( + bin_dir: Path, + targets: Sequence[str] | None = None, + *, + manifest_path: Path = RG_MANIFEST, +) -> list[Path]: + """Download ripgrep binaries described by the DotSlash manifest. + + Args: + bin_dir: Destination directory where the binaries will be written. + targets: Optional iterable of Codex target triples. When omitted, all + supported targets are fetched. + manifest_path: Path to the DotSlash manifest describing providers. + + Returns: + List of Paths to the downloaded binaries. + """ + + if targets is None: + targets = DEFAULT_RG_TARGETS + + if not manifest_path.exists(): + raise FileNotFoundError(f"DotSlash manifest not found: {manifest_path}") + + manifest = json.loads(manifest_path.read_text()) + platforms = manifest.get("platforms", {}) + + bin_dir.mkdir(parents=True, exist_ok=True) + + results: list[Path] = [] + for target in targets: + platform_key = RG_TARGET_TO_PLATFORM.get(target) + if platform_key is None: + raise ValueError(f"Unsupported ripgrep target '{target}'.") + + platform_info = platforms.get(platform_key) + if platform_info is None: + raise RuntimeError( + f"Platform '{platform_key}' not found in manifest {manifest_path}." + ) + + providers = platform_info.get("providers", []) + if not providers: + raise RuntimeError( + f"No providers listed for platform '{platform_key}' in {manifest_path}." + ) + + url = providers[0]["url"] + archive_format = platform_info.get("format", "zst") + archive_path = platform_info.get("path") + + dest_name = f"rg-{target}" + dest = bin_dir / dest_name + if platform_key.startswith("win") and dest.suffix != ".exe": + dest = dest.with_suffix(".exe") + + with tempfile.TemporaryDirectory() as tmp_dir_str: + tmp_dir = Path(tmp_dir_str) + archive_filename = os.path.basename(urlparse(url).path) + download_path = tmp_dir / archive_filename + _download_file(url, download_path) + + dest.parent.mkdir(parents=True, exist_ok=True) + dest.unlink(missing_ok=True) + _extract_archive(download_path, archive_format, archive_path, dest) + + if not platform_key.startswith("win"): + dest.chmod(0o755) + + results.append(dest) + + return results + + +def _download_file(url: str, dest: Path) -> None: + """Download the content at url and write it to dest.""" + + dest.parent.mkdir(parents=True, exist_ok=True) + with urlopen(url) as response, open(dest, "wb") as out: + shutil.copyfileobj(response, out) + + +def _extract_archive( + archive_path: Path, + archive_format: str, + archive_member: str | None, + dest: Path, +) -> None: + """Extract `archive_member` from `archive_path` into `dest`.""" + + dest.parent.mkdir(parents=True, exist_ok=True) + + if archive_format == "zst": + output_path = archive_path.parent / dest.name + subprocess.run( + ["zstd", "-f", "-d", str(archive_path), "-o", str(output_path)], + check=True, + ) + shutil.move(str(output_path), dest) + return + + if archive_format == "tar.gz": + if not archive_member: + raise RuntimeError("Missing 'path' for tar.gz archive in DotSlash manifest.") + with tarfile.open(archive_path, "r:gz") as tar: + try: + member = tar.getmember(archive_member) + except KeyError as exc: + raise RuntimeError( + f"Entry '{archive_member}' not found in archive {archive_path}." + ) from exc + tar.extract(member, path=archive_path.parent) + extracted = archive_path.parent / archive_member + shutil.move(str(extracted), dest) + return + + if archive_format == "zip": + if not archive_member: + raise RuntimeError("Missing 'path' for zip archive in DotSlash manifest.") + with zipfile.ZipFile(archive_path) as archive: + try: + with archive.open(archive_member) as src, open(dest, "wb") as out: + shutil.copyfileobj(src, out) + except KeyError as exc: + raise RuntimeError( + f"Entry '{archive_member}' not found in archive {archive_path}." + ) from exc + return + + raise RuntimeError(f"Unsupported archive format '{archive_format}'.") + + +__all__ = [ + "DEFAULT_RG_TARGETS", + "RG_TARGET_PLATFORM_PAIRS", + "RG_TARGET_TO_PLATFORM", + "detect_current_target", + "fetch_rg", +] diff --git a/codex-cli/scripts/stage_release.sh b/codex-cli/scripts/stage_release.sh index 96236fc53c..02461e9018 100755 --- a/codex-cli/scripts/stage_release.sh +++ b/codex-cli/scripts/stage_release.sh @@ -7,22 +7,23 @@ # Usage: # # --tmp : Use instead of a freshly created temp directory. +# --version : Version string to write into package.json. +# --workflow-url : Workflow run that produced the native binaries. # -h|--help : Print usage. # # ----------------------------------------------------------------------------- set -euo pipefail -# Helper - usage / flag parsing - usage() { cat <&2 - usage 1 + if [[ -z "$TMPDIR" ]]; then + TMPDIR="$1" + else + echo "Unexpected extra argument: $1" >&2 + usage 1 + fi ;; esac shift done -# Fallback when the caller did not specify a directory. -# If no directory was specified create a fresh temporary one. if [[ -z "$TMPDIR" ]]; then TMPDIR="$(mktemp -d)" fi -# Ensure the directory exists, then resolve to an absolute path. mkdir -p "$TMPDIR" TMPDIR="$(cd "$TMPDIR" && pwd)" -# Main build logic - -echo "Staging release in $TMPDIR" - -# The script lives in codex-cli/scripts/ - change into codex-cli root so that -# relative paths keep working. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CODEX_CLI_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -pushd "$CODEX_CLI_ROOT" >/dev/null +BUILD_ARGS=( + --version "$VERSION" + --staging-dir "$TMPDIR" +) +if [[ -n "$WORKFLOW_URL" ]]; then + BUILD_ARGS+=(--workflow-url "$WORKFLOW_URL") +fi -# 1. Build the JS artifacts --------------------------------------------------- +python3 "$CODEX_CLI_ROOT/scripts/build_npm_package.py" "${BUILD_ARGS[@]}" -# Paths inside the staged package -mkdir -p "$TMPDIR/bin" +cat < "$TMPDIR/package.json" - -# 2. Native runtime deps (sandbox plus optional Rust binaries) - -./scripts/install_native_deps.sh --workflow-url "$WORKFLOW_URL" "$TMPDIR" - -popd >/dev/null - -echo "Staged version $VERSION for release in $TMPDIR" - -echo "Verify the CLI:" -echo " node ${TMPDIR}/bin/codex.js --version" -echo " node ${TMPDIR}/bin/codex.js --help" - -# Print final hint for convenience -echo "Next: cd \"$TMPDIR\" && npm publish" +Next: cd "$TMPDIR" && npm publish +EOF diff --git a/codex-cli/scripts/stage_rust_release.py b/codex-cli/scripts/stage_rust_release.py index 9a554b77d0..e1142648c3 100755 --- a/codex-cli/scripts/stage_rust_release.py +++ b/codex-cli/scripts/stage_rust_release.py @@ -23,7 +23,11 @@ forwarded to stage_release.sh. ) parser.add_argument( "--tmp", - help="Optional path to stage the npm package; forwarded to stage_release.sh", + help="Optional path to stage the npm package; forwarded to the builder", + ) + parser.add_argument( + "--pack-output", + help="Optional path to write the generated npm tarball.", ) args = parser.parse_args() version = args.release_version @@ -51,17 +55,19 @@ forwarded to stage_release.sh. current_dir = Path(__file__).parent.resolve() cmd = [ - str(current_dir / "stage_release.sh"), + sys.executable, + str(current_dir / "build_npm_package.py"), "--version", version, "--workflow-url", workflow["url"], ] if args.tmp: - cmd.extend(["--tmp", args.tmp]) + cmd.extend(["--staging-dir", args.tmp]) + if args.pack_output: + cmd.extend(["--pack-output", args.pack_output]) - stage_release = subprocess.run(cmd) - stage_release.check_returncode() + subprocess.run(cmd, check=True) return 0