diff --git a/crates/cli/src/commands/launch/claude.rs b/crates/cli/src/commands/launch/claude.rs index 7554d38..9a303be 100644 --- a/crates/cli/src/commands/launch/claude.rs +++ b/crates/cli/src/commands/launch/claude.rs @@ -77,7 +77,7 @@ pub async fn run(opts: Options) -> Result<()> { let mcp_config_path = write_mcp_config(&creds)?; cmd.arg("--mcp-config").arg(&mcp_config_path); let session_url = format!("{}/session/{}", crate::config::console_base_url(), session_id); - cmd.arg("--append-system-prompt").arg(system_prompt(&session_id, repo_origin.as_deref(), &session_url)); + cmd.arg("--append-system-prompt").arg(crate::commands::launch::agent_session_prompt(&session_id, repo_origin.as_deref(), &session_url)); cmd.arg("--allowedTools").arg("mcp__edgee__setSessionName,mcp__edgee__addSessionPullRequest,mcp__edgee__addSessionCommit,mcp__edgee__setSessionGitHubRepo"); } @@ -127,32 +127,3 @@ fn write_mcp_config(creds: &crate::config::Credentials) -> Result, session_url: &str) -> String { - let mut prompt = format!( - r#"You are running inside the Edgee CLI and have access to the Edgee MCP server for tracking session metadata. - -Your Edgee session ID is: {session_id} -Your Edgee public session page is: {session_url} - -You MUST use the following Edgee MCP tools during this session: - -1. `setSessionName` — call this immediately after the user's first message with a short descriptive name (3-6 words) summarizing what the user is asking for. Arguments: - - sessionId: "{session_id}" - - name: the descriptive name. - If at any later point during the session you come up with a clearly better name (e.g., the task's real scope becomes obvious only after exploring the code, or the user pivots the request), call `setSessionName` again with the improved name. Prefer calling it once, but do not hesitate to update when a materially better name emerges. - -2. `addSessionPullRequest` — call this EVERY TIME you create OR edit a pull request (e.g., via `gh pr create`, `gh pr edit`, or any other tool). Immediately after the PR is created or modified, call this tool with: - - sessionId: "{session_id}" - - pullRequest: the full PR URL. - This is required for every PR you touch during this session, with no exceptions. Always call it on edits too — the PR may not yet be associated with this session, and the API handles duplicates safely, so redundant calls are harmless."# - ); - - if let Some(repo) = repo { - prompt.push_str(&format!( - "\n\n3. `setSessionGitHubRepo` — call this EXACTLY ONCE at the start of the session, together with (or right after) `setSessionName`. Arguments:\n - sessionId: \"{session_id}\"\n - repo: \"{repo}\"\n Do not call this tool again during the session." - )); - } - - prompt -} diff --git a/crates/cli/src/commands/launch/codex.rs b/crates/cli/src/commands/launch/codex.rs index ad899a4..56ba6f2 100644 --- a/crates/cli/src/commands/launch/codex.rs +++ b/crates/cli/src/commands/launch/codex.rs @@ -33,12 +33,18 @@ pub async fn run(opts: Options) -> Result<()> { crate::config::write(&creds)?; } - // Step 3: launch codex with the correct env vars + // Step 3b: ensure MCP preference is set + crate::commands::auth::login::ensure_mcp_preference().await?; + creds = crate::config::read()?; + + // Step 4: launch codex with the correct env vars let codex = creds.codex.as_ref().unwrap(); let api_key = &codex.api_key; let session_id = uuid::Uuid::new_v4().to_string(); crate::commands::launch::spawn_cli_version_report(&creds, &session_id); - let repo_entry = crate::git::detect_origin() + let repo_origin = crate::git::detect_origin(); + let repo_entry = repo_origin + .as_ref() .map(|url| format!(",\"x-edgee-repo\"=\"{}\"", url)) .unwrap_or_default(); let base_url = format!("{}/v1", crate::config::gateway_base_url()); @@ -51,6 +57,32 @@ pub async fn run(opts: Options) -> Result<()> { "-c", &format!("model_providers.edgee-cli.http_headers={{\"x-edgee-api-key\"=\"{api_key}\",\"x-edgee-session-id\"=\"{session_id}\"{repo_entry}}}"), "-c", "model_providers.edgee-cli.wire_api=\"responses\"", ]); + + // Step 5: conditionally set up MCP integration + let use_mcp = creds.enable_mcp.unwrap_or(false); + let user_token = creds.user_token.as_deref().unwrap_or(""); + if use_mcp && !user_token.is_empty() { + cmd.env("EDGEE_USER_TOKEN", user_token); + let session_url = format!("{}/session/{}", crate::config::console_base_url(), session_id); + let prompt = crate::commands::launch::agent_session_prompt( + &session_id, + repo_origin.as_deref(), + &session_url, + ); + let escaped_prompt = crate::commands::launch::toml_escape_string(&prompt); + cmd.args([ + "-c", + &format!( + "mcp_servers.edgee.url=\"{}\"", + crate::config::mcp_base_url() + ), + "-c", + "mcp_servers.edgee.bearer_token_env_var=\"EDGEE_USER_TOKEN\"", + "-c", + &format!("developer_instructions={escaped_prompt}"), + ]); + } + cmd.args(&opts.args); let status = cmd.status().map_err(|e| { diff --git a/crates/cli/src/commands/launch/mod.rs b/crates/cli/src/commands/launch/mod.rs index b631177..303fec5 100644 --- a/crates/cli/src/commands/launch/mod.rs +++ b/crates/cli/src/commands/launch/mod.rs @@ -410,6 +410,67 @@ async fn fetch_stats( client.get_session_stats(org_id, session_id).await } +/// Build the per-session "agent instructions" prompt that tells the model to +/// call the Edgee MCP tools (`setSessionName`, `addSessionPullRequest`, +/// optionally `setSessionGitHubRepo`). +/// +/// Shared by every `edgee launch ` that wires Edgee MCP. The text +/// references only the bare MCP tool names, so it works regardless of how the +/// host agent namespaces them. +pub fn agent_session_prompt(session_id: &str, repo: Option<&str>, session_url: &str) -> String { + let mut prompt = format!( + r#"You are running inside the Edgee CLI and have access to the Edgee MCP server for tracking session metadata. + +Your Edgee session ID is: {session_id} +Your Edgee public session page is: {session_url} + +You MUST use the following Edgee MCP tools during this session: + +1. `setSessionName` — call this immediately after the user's first message with a short descriptive name (3-6 words) summarizing what the user is asking for. Arguments: + - sessionId: "{session_id}" + - name: the descriptive name. + If at any later point during the session you come up with a clearly better name (e.g., the task's real scope becomes obvious only after exploring the code, or the user pivots the request), call `setSessionName` again with the improved name. Prefer calling it once, but do not hesitate to update when a materially better name emerges. + +2. `addSessionPullRequest` — call this EVERY TIME you create OR edit a pull request (e.g., via `gh pr create`, `gh pr edit`, or any other tool). Immediately after the PR is created or modified, call this tool with: + - sessionId: "{session_id}" + - pullRequest: the full PR URL. + This is required for every PR you touch during this session, with no exceptions. Always call it on edits too — the PR may not yet be associated with this session, and the API handles duplicates safely, so redundant calls are harmless."# + ); + + if let Some(repo) = repo { + prompt.push_str(&format!( + "\n\n3. `setSessionGitHubRepo` — call this EXACTLY ONCE at the start of the session, together with (or right after) `setSessionName`. Arguments:\n - sessionId: \"{session_id}\"\n - repo: \"{repo}\"\n Do not call this tool again during the session." + )); + } + + prompt +} + +/// Wrap `s` as a TOML basic string literal (including surrounding `"`), escaping +/// the characters TOML requires per the spec. Used to embed multi-line prompts +/// into Codex's `-c key=value` overrides without TOML parse errors. +pub fn toml_escape_string(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '\\' => out.push_str(r"\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str(r"\n"), + '\r' => out.push_str(r"\r"), + '\t' => out.push_str(r"\t"), + '\x08' => out.push_str(r"\b"), + '\x0c' => out.push_str(r"\f"), + c if (c as u32) < 0x20 || c as u32 == 0x7F => { + out.push_str(&format!("\\u{:04X}", c as u32)); + } + c => out.push(c), + } + } + out.push('"'); + out +} + /// Fire-and-forget: record the running CLI version on the session metadata. /// /// No-op when the active profile has no user token or no selected org. All @@ -439,3 +500,48 @@ pub fn spawn_cli_version_report(creds: &crate::config::Credentials, session_id: } }); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn toml_escape_round_trip_through_parser() { + let prompt = agent_session_prompt( + "abc-123", + Some("git@github.com:owner/repo.git"), + "https://www.edgee.ai/session/abc-123", + ); + let escaped = toml_escape_string(&prompt); + let doc = format!("value = {escaped}"); + let parsed: toml::Value = + toml::from_str(&doc).expect("escaped TOML literal must parse cleanly"); + let round_tripped = parsed + .get("value") + .and_then(|v| v.as_str()) + .expect("value must be a string"); + assert_eq!(round_tripped, prompt); + } + + #[test] + fn toml_escape_handles_quotes_backslashes_and_control_chars() { + let raw = "He said \"hi\"\nback\\slash\ttab\x07bell"; + let escaped = toml_escape_string(raw); + let doc = format!("value = {escaped}"); + let parsed: toml::Value = toml::from_str(&doc).expect("must parse"); + assert_eq!(parsed.get("value").and_then(|v| v.as_str()).unwrap(), raw); + } + + #[test] + fn agent_prompt_omits_repo_block_when_absent() { + let p = agent_session_prompt("sid", None, "https://example/sid"); + assert!(!p.contains("setSessionGitHubRepo")); + } + + #[test] + fn agent_prompt_includes_repo_block_when_present() { + let p = agent_session_prompt("sid", Some("owner/repo"), "https://example/sid"); + assert!(p.contains("setSessionGitHubRepo")); + assert!(p.contains("owner/repo")); + } +}