diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 639a9467..55f754e4 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -666,6 +666,7 @@ dependencies = [ "codex-login", "codex-mcp-server", "codex-protocol", + "codex-protocol-ts", "codex-tui", "serde_json", "tokio", @@ -906,9 +907,20 @@ dependencies = [ "serde_json", "strum 0.27.2", "strum_macros 0.27.2", + "ts-rs", "uuid", ] +[[package]] +name = "codex-protocol-ts" +version = "0.0.0" +dependencies = [ + "anyhow", + "clap", + "codex-protocol", + "ts-rs", +] + [[package]] name = "codex-tui" version = "0.0.0" @@ -5225,6 +5237,7 @@ dependencies = [ "serde_json", "thiserror 2.0.12", "ts-rs-macros", + "uuid", ] [[package]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 2fb9b927..8a48ef81 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -16,6 +16,7 @@ members = [ "mcp-types", "ollama", "protocol", + "protocol-ts", "tui", ] resolver = "2" diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 0183ae28..f7af3349 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -37,3 +37,4 @@ tokio = { version = "1", features = [ ] } tracing = "0.1.41" tracing-subscriber = "0.3.19" +codex-protocol-ts = { path = "../protocol-ts" } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 8f59d2d4..d237fe67 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -72,6 +72,10 @@ enum Subcommand { /// Apply the latest diff produced by Codex agent as a `git apply` to your local working tree. #[clap(visible_alias = "a")] Apply(ApplyCommand), + + /// Internal: generate TypeScript protocol bindings. + #[clap(hide = true)] + GenerateTs(GenerateTsCommand), } #[derive(Debug, Parser)] @@ -120,6 +124,17 @@ struct LogoutCommand { config_overrides: CliConfigOverrides, } +#[derive(Debug, Parser)] +struct GenerateTsCommand { + /// Output directory where .ts files will be written + #[arg(short = 'o', long = "out", value_name = "DIR")] + out_dir: PathBuf, + + /// Optional path to the Prettier executable to format generated files + #[arg(short = 'p', long = "prettier", value_name = "PRETTIER_BIN")] + prettier: Option, +} + fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move { cli_main(codex_linux_sandbox_exe).await?; @@ -194,6 +209,9 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() prepend_config_flags(&mut apply_cli.config_overrides, cli.config_overrides); run_apply_command(apply_cli, None).await?; } + Some(Subcommand::GenerateTs(gen_cli)) => { + codex_protocol_ts::generate_ts(&gen_cli.out_dir, gen_cli.prettier.as_deref())?; + } } Ok(()) diff --git a/codex-rs/protocol-ts/Cargo.toml b/codex-rs/protocol-ts/Cargo.toml new file mode 100644 index 00000000..9faa9344 --- /dev/null +++ b/codex-rs/protocol-ts/Cargo.toml @@ -0,0 +1,21 @@ +[package] +edition = "2024" +name = "codex-protocol-ts" +version = { workspace = true } + +[lints] +workspace = true + +[lib] +name = "codex_protocol_ts" +path = "src/lib.rs" + +[[bin]] +name = "codex-protocol-ts" +path = "src/main.rs" + +[dependencies] +anyhow = "1" +codex-protocol = { path = "../protocol" } +ts-rs = "11" +clap = { version = "4", features = ["derive"] } diff --git a/codex-rs/protocol-ts/generate-ts b/codex-rs/protocol-ts/generate-ts new file mode 100755 index 00000000..8f90bced --- /dev/null +++ b/codex-rs/protocol-ts/generate-ts @@ -0,0 +1,10 @@ +#!/bin/bash + +set -euo pipefail + +cd "$(dirname "$0")"/.. + +tmpdir=$(mktemp -d) +just codex generate-ts --prettier ../node_modules/.bin/prettier --out "$tmpdir" + +echo "wrote output to $tmpdir" diff --git a/codex-rs/protocol-ts/src/lib.rs b/codex-rs/protocol-ts/src/lib.rs new file mode 100644 index 00000000..a37130b8 --- /dev/null +++ b/codex-rs/protocol-ts/src/lib.rs @@ -0,0 +1,108 @@ +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use std::ffi::OsStr; +use std::fs; +use std::io::Read; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use ts_rs::TS; + +const HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n"; + +pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { + ensure_dir(out_dir)?; + + // Generate TS bindings + codex_protocol::mcp_protocol::ConversationId::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::InputItem::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::ClientRequest::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::ServerRequest::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::NewConversationParams::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::NewConversationResponse::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::AddConversationListenerParams::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::AddConversationSubscriptionResponse::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::RemoveConversationListenerParams::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::RemoveConversationSubscriptionResponse::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::SendUserMessageParams::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::SendUserMessageResponse::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::SendUserTurnParams::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::SendUserTurnResponse::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::InterruptConversationParams::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::InterruptConversationResponse::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::LoginChatGptResponse::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::LoginChatGptCompleteNotification::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::CancelLoginChatGptParams::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::CancelLoginChatGptResponse::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::ApplyPatchApprovalParams::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::ApplyPatchApprovalResponse::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::ExecCommandApprovalParams::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::ExecCommandApprovalResponse::export_all_to(out_dir)?; + + // Prepend header to each generated .ts file + let ts_files = ts_files_in(out_dir)?; + for file in &ts_files { + prepend_header_if_missing(file)?; + } + + // Format with Prettier by passing individual files (no shell globbing) + if let Some(prettier_bin) = prettier { + if !ts_files.is_empty() { + let status = Command::new(prettier_bin) + .arg("--write") + .args(ts_files.iter().map(|p| p.as_os_str())) + .status() + .with_context(|| { + format!("Failed to invoke Prettier at {}", prettier_bin.display()) + })?; + if !status.success() { + return Err(anyhow!("Prettier failed with status {}", status)); + } + } + } + + Ok(()) +} + +fn ensure_dir(dir: &Path) -> Result<()> { + fs::create_dir_all(dir) + .with_context(|| format!("Failed to create output directory {}", dir.display())) +} + +fn prepend_header_if_missing(path: &Path) -> Result<()> { + let mut content = String::new(); + { + let mut f = fs::File::open(path) + .with_context(|| format!("Failed to open {} for reading", path.display()))?; + f.read_to_string(&mut content) + .with_context(|| format!("Failed to read {}", path.display()))?; + } + + if content.starts_with(HEADER) { + return Ok(()); + } + + let mut f = fs::File::create(path) + .with_context(|| format!("Failed to open {} for writing", path.display()))?; + f.write_all(HEADER.as_bytes()) + .with_context(|| format!("Failed to write header to {}", path.display()))?; + f.write_all(content.as_bytes()) + .with_context(|| format!("Failed to write content to {}", path.display()))?; + Ok(()) +} + +fn ts_files_in(dir: &Path) -> Result> { + let mut files = Vec::new(); + for entry in + fs::read_dir(dir).with_context(|| format!("Failed to read dir {}", dir.display()))? + { + let entry = entry?; + let path = entry.path(); + if path.is_file() && path.extension() == Some(OsStr::new("ts")) { + files.push(path); + } + } + Ok(files) +} diff --git a/codex-rs/protocol-ts/src/main.rs b/codex-rs/protocol-ts/src/main.rs new file mode 100644 index 00000000..f477b9f5 --- /dev/null +++ b/codex-rs/protocol-ts/src/main.rs @@ -0,0 +1,20 @@ +use anyhow::Result; +use clap::Parser; +use std::path::PathBuf; + +#[derive(Parser, Debug)] +#[command(about = "Generate TypeScript bindings for the Codex protocol")] +struct Args { + /// Output directory where .ts files will be written + #[arg(short = 'o', long = "out", value_name = "DIR")] + out_dir: PathBuf, + + /// Optional path to the Prettier executable to format generated files + #[arg(short = 'p', long = "prettier", value_name = "PRETTIER_BIN")] + prettier: Option, +} + +fn main() -> Result<()> { + let args = Args::parse(); + codex_protocol_ts::generate_ts(&args.out_dir, args.prettier.as_deref()) +} diff --git a/codex-rs/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml index 9525d043..c94bdb8e 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -17,6 +17,7 @@ serde_bytes = "0.11" serde_json = "1" strum = "0.27.2" strum_macros = "0.27.2" +ts-rs = { version = "11", features = ["uuid-impl", "serde-json-impl"] } uuid = { version = "1", features = ["serde", "v4"] } [dev-dependencies] diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 4d72e27a..1c88e9cb 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -1,9 +1,10 @@ use serde::Deserialize; use serde::Serialize; use strum_macros::Display; +use ts_rs::TS; /// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning -#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display)] +#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, TS)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum ReasoningEffort { @@ -19,7 +20,7 @@ pub enum ReasoningEffort { /// A summary of the reasoning performed by the model. This can be useful for /// debugging and understanding the model's reasoning process. /// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries -#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display)] +#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, TS)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum ReasoningSummary { @@ -31,7 +32,7 @@ pub enum ReasoningSummary { None, } -#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Default, Serialize, Display)] +#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Default, Serialize, Display, TS)] #[serde(rename_all = "kebab-case")] #[strum(serialize_all = "kebab-case")] pub enum SandboxMode { diff --git a/codex-rs/protocol/src/mcp_protocol.rs b/codex-rs/protocol/src/mcp_protocol.rs index 5110f469..383b2033 100644 --- a/codex-rs/protocol/src/mcp_protocol.rs +++ b/codex-rs/protocol/src/mcp_protocol.rs @@ -13,10 +13,11 @@ use crate::protocol::TurnAbortReason; use mcp_types::RequestId; use serde::Deserialize; use serde::Serialize; +use ts_rs::TS; use uuid::Uuid; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(transparent)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(type = "string")] pub struct ConversationId(pub Uuid); impl Display for ConversationId { @@ -26,7 +27,7 @@ impl Display for ConversationId { } /// Request from the client to the server. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(tag = "method", rename_all = "camelCase")] pub enum ClientRequest { NewConversation { @@ -70,7 +71,7 @@ pub enum ClientRequest { }, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)] #[serde(rename_all = "camelCase")] pub struct NewConversationParams { /// Optional override for the model name (e.g. "o3", "o4-mini"). @@ -113,24 +114,24 @@ pub struct NewConversationParams { pub include_apply_patch_tool: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(rename_all = "camelCase")] pub struct NewConversationResponse { pub conversation_id: ConversationId, pub model: String, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(rename_all = "camelCase")] pub struct AddConversationSubscriptionResponse { pub subscription_id: Uuid, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(rename_all = "camelCase")] pub struct RemoveConversationSubscriptionResponse {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(rename_all = "camelCase")] pub struct LoginChatGptResponse { pub login_id: Uuid, @@ -141,7 +142,7 @@ pub struct LoginChatGptResponse { // Event name for notifying client of login completion or failure. pub const LOGIN_CHATGPT_COMPLETE_EVENT: &str = "codex/event/login_chatgpt_complete"; -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(rename_all = "camelCase")] pub struct LoginChatGptCompleteNotification { pub login_id: Uuid, @@ -150,24 +151,24 @@ pub struct LoginChatGptCompleteNotification { pub error: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(rename_all = "camelCase")] pub struct CancelLoginChatGptParams { pub login_id: Uuid, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(rename_all = "camelCase")] pub struct CancelLoginChatGptResponse {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(rename_all = "camelCase")] pub struct SendUserMessageParams { pub conversation_id: ConversationId, pub items: Vec, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(rename_all = "camelCase")] pub struct SendUserTurnParams { pub conversation_id: ConversationId, @@ -180,39 +181,39 @@ pub struct SendUserTurnParams { pub summary: ReasoningSummary, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(rename_all = "camelCase")] pub struct SendUserTurnResponse {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(rename_all = "camelCase")] pub struct InterruptConversationParams { pub conversation_id: ConversationId, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, TS)] #[serde(rename_all = "camelCase")] pub struct InterruptConversationResponse { pub abort_reason: TurnAbortReason, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(rename_all = "camelCase")] pub struct SendUserMessageResponse {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(rename_all = "camelCase")] pub struct AddConversationListenerParams { pub conversation_id: ConversationId, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(rename_all = "camelCase")] pub struct RemoveConversationListenerParams { pub subscription_id: Uuid, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(rename_all = "camelCase")] #[serde(tag = "type", content = "data")] pub enum InputItem { @@ -237,7 +238,7 @@ pub const APPLY_PATCH_APPROVAL_METHOD: &str = "applyPatchApproval"; pub const EXEC_COMMAND_APPROVAL_METHOD: &str = "execCommandApproval"; /// Request initiated from the server and sent to the client. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(tag = "method", rename_all = "camelCase")] pub enum ServerRequest { /// Request to approve a patch. @@ -254,7 +255,7 @@ pub enum ServerRequest { }, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] pub struct ApplyPatchApprovalParams { pub conversation_id: ConversationId, /// Use to correlate this with [codex_core::protocol::PatchApplyBeginEvent] @@ -270,7 +271,7 @@ pub struct ApplyPatchApprovalParams { pub grant_root: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] pub struct ExecCommandApprovalParams { pub conversation_id: ConversationId, /// Use to correlate this with [codex_core::protocol::ExecCommandBeginEvent] @@ -282,12 +283,12 @@ pub struct ExecCommandApprovalParams { pub reason: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] pub struct ExecCommandApprovalResponse { pub decision: ReviewDecision, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] pub struct ApplyPatchApprovalResponse { pub decision: ReviewDecision, } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 2aea2189..23ef4668 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -15,6 +15,7 @@ use serde::Deserialize; use serde::Serialize; use serde_bytes::ByteBuf; use strum_macros::Display; +use ts_rs::TS; use uuid::Uuid; use crate::config_types::ReasoningEffort as ReasoningEffortConfig; @@ -145,7 +146,7 @@ pub enum Op { /// Determines the conditions under which the user is consulted to approve /// running the command proposed by Codex. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, Display)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, Display, TS)] #[serde(rename_all = "kebab-case")] #[strum(serialize_all = "kebab-case")] pub enum AskForApproval { @@ -172,7 +173,7 @@ pub enum AskForApproval { } /// Determines execution restrictions for model shell commands. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display, TS)] #[strum(serialize_all = "kebab-case")] #[serde(tag = "mode", rename_all = "kebab-case")] pub enum SandboxPolicy { @@ -737,7 +738,7 @@ pub struct SessionConfiguredEvent { } /// User's decision in response to an ExecApprovalRequest. -#[derive(Debug, Default, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Debug, Default, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, TS)] #[serde(rename_all = "snake_case")] pub enum ReviewDecision { /// User has approved this command and the agent should execute it. @@ -758,7 +759,7 @@ pub enum ReviewDecision { Abort, } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] #[serde(rename_all = "snake_case")] pub enum FileChange { Add { @@ -784,7 +785,7 @@ pub struct TurnAbortedEvent { pub reason: TurnAbortReason, } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] #[serde(rename_all = "snake_case")] pub enum TurnAbortReason { Interrupted,