first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -25,6 +25,15 @@ pub enum CLIAgentEventType {
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
/// How a CLI agent event reached Warp.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CLIAgentEventSource {
|
||||
/// Structured OSC 777 notification from a rich plugin.
|
||||
RichPlugin,
|
||||
/// Native Codex OSC 9 fallback notification.
|
||||
CodexOsc9Fallback,
|
||||
}
|
||||
|
||||
/// Event-specific fields that vary by event type.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -49,6 +58,7 @@ pub struct CLIAgentEvent {
|
||||
pub cwd: Option<String>,
|
||||
pub project: Option<String>,
|
||||
pub payload: CLIAgentEventPayload,
|
||||
pub source: CLIAgentEventSource,
|
||||
}
|
||||
|
||||
/// Version-specific parsers, indexed by (version - 1).
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::{CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventSource, CLIAgentEventType};
|
||||
use crate::terminal::CLIAgent;
|
||||
|
||||
use super::{CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventType};
|
||||
|
||||
/// Resolves a CLI agent from the `"agent"` string in a CLI agent event.
|
||||
/// Returns `None` if the string doesn't match any known agent.
|
||||
fn resolve_agent(agent: &str) -> Option<CLIAgent> {
|
||||
@@ -55,6 +54,7 @@ pub(super) fn parse(body: &str) -> Option<CLIAgentEvent> {
|
||||
tool_input_preview,
|
||||
plugin_version: raw.plugin_version,
|
||||
},
|
||||
source: CLIAgentEventSource::RichPlugin,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use galaxyui::{EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use super::{CLIAgentEvent, CLIAgentSessionsModel};
|
||||
use crate::terminal::cli_agent_sessions::event::parse_event;
|
||||
use crate::terminal::cli_agent_sessions::event::{CLIAgentEventPayload, CLIAgentEventType};
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::terminal::cli_agent_sessions::event::{
|
||||
parse_event, CLIAgentEventPayload, CLIAgentEventSource, CLIAgentEventType,
|
||||
};
|
||||
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
|
||||
use crate::terminal::CLIAgent;
|
||||
|
||||
@@ -14,27 +16,23 @@ trait CLIAgentSessionHandler {
|
||||
/// The default implementation delegates to the structured JSON parser
|
||||
/// (`parse_event`); agents with non-JSON notification formats (e.g. Codex
|
||||
/// OSC 9 plain text) should override this.
|
||||
fn try_parse(&self, title: Option<&str>, body: &str) -> Option<CLIAgentEvent> {
|
||||
///
|
||||
/// `plugin_already_active` is true when the session has already received a
|
||||
/// structured OSC 777 notification; Codex uses it to drop OSC 9 fallback
|
||||
/// once the rich plugin is active. Other handlers ignore it.
|
||||
fn try_parse(
|
||||
&mut self,
|
||||
title: Option<&str>,
|
||||
body: &str,
|
||||
plugin_already_active: bool,
|
||||
) -> Option<CLIAgentEvent> {
|
||||
let _ = plugin_already_active;
|
||||
parse_event(title, body)
|
||||
}
|
||||
|
||||
/// Decide whether a parsed event should be forwarded to the sessions model.
|
||||
/// Returns the event (possibly transformed) if it should be processed.
|
||||
fn handle_event(&mut self, event: CLIAgentEvent) -> Option<CLIAgentEvent>;
|
||||
|
||||
/// Whether this handler provides meaningful, fine-grained status
|
||||
/// (e.g. in-progress / blocked / success) that should be shown in the UI.
|
||||
/// Handlers backed by the structured plugin protocol report rich status;
|
||||
/// handlers that only forward opaque OS notifications (e.g. Codex) do not.
|
||||
fn supports_rich_status(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the listener for the given agent provides rich status.
|
||||
/// Returns `false` for agents without a handler or whose handler opts out.
|
||||
pub fn agent_supports_rich_status(agent: &CLIAgent) -> bool {
|
||||
create_handler(agent).is_some_and(|h| h.supports_rich_status())
|
||||
}
|
||||
|
||||
/// Returns `true` if the given CLI agent has a supported session handler.
|
||||
@@ -46,25 +44,35 @@ pub fn is_agent_supported(agent: &CLIAgent) -> bool {
|
||||
| CLIAgent::Codex
|
||||
| CLIAgent::Gemini
|
||||
| CLIAgent::Auggie
|
||||
| CLIAgent::Droid
|
||||
| CLIAgent::Pi
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates the appropriate handler for the given CLI agent.
|
||||
fn create_handler(agent: &CLIAgent) -> Option<Box<dyn CLIAgentSessionHandler>> {
|
||||
match agent {
|
||||
// Auggie is supported via the community-maintained auggie-warp plugin
|
||||
// (https://github.com/augmentmoogi/auggie-warp), which emits the same
|
||||
// Auggie and Pi are supported via community-maintained plugins
|
||||
// (https://github.com/augmentmoogi/auggie-warp,
|
||||
// https://github.com/badlogic/pi-mono), which emit the same
|
||||
// structured OSC 777 events as the first-party Claude/OpenCode/Gemini
|
||||
// plugins. We don't ship an install flow for it — we just listen.
|
||||
CLIAgent::Claude | CLIAgent::OpenCode | CLIAgent::Gemini | CLIAgent::Auggie => {
|
||||
Some(Box::new(DefaultSessionListener))
|
||||
}
|
||||
CLIAgent::Codex => Some(Box::new(CodexSessionHandler)),
|
||||
CLIAgent::Amp
|
||||
// plugins. Droid can be supported by user-configured hooks or future
|
||||
// integrations that emit the same structured OSC 777 events. We don't
|
||||
// ship install flows for these agents here — we just listen.
|
||||
CLIAgent::Claude
|
||||
| CLIAgent::OpenCode
|
||||
| CLIAgent::Gemini
|
||||
| CLIAgent::Auggie
|
||||
| CLIAgent::Droid
|
||||
| CLIAgent::Pi => Some(Box::new(DefaultSessionListener)),
|
||||
CLIAgent::Codex => Some(Box::new(CodexSessionHandler)),
|
||||
CLIAgent::Hermes
|
||||
| CLIAgent::Amp
|
||||
| CLIAgent::Copilot
|
||||
| CLIAgent::Pi
|
||||
| CLIAgent::CursorCli
|
||||
| CLIAgent::Goose
|
||||
| CLIAgent::Vibe
|
||||
| CLIAgent::Antigravity
|
||||
| CLIAgent::Unknown => None,
|
||||
}
|
||||
}
|
||||
@@ -84,14 +92,11 @@ impl CLIAgentSessionHandler for DefaultSessionListener {
|
||||
}
|
||||
}
|
||||
|
||||
/// Codex-specific handler that parses plain-text OSC 9 desktop notifications
|
||||
/// into CLI agent events.
|
||||
/// Codex-specific handler that supports both native OSC 9 fallback and structured plugin events.
|
||||
///
|
||||
/// Codex sends notifications via OSC 9 (`\x1b]9;message\x07`) with
|
||||
/// human-readable text. Since there's no way to distinguish notification types
|
||||
/// from the raw text, all OSC 9 notifications are treated as `Stop` (success).
|
||||
/// The notification body becomes the event's `query` so it surfaces as the
|
||||
/// notification title in the UI.
|
||||
/// human-readable text. Since there's no way to distinguish notification types from the raw text,
|
||||
/// OSC 9 fallback notifications are treated as `Stop` (success).
|
||||
struct CodexSessionHandler;
|
||||
|
||||
impl CodexSessionHandler {
|
||||
@@ -114,22 +119,34 @@ impl CodexSessionHandler {
|
||||
query: Some(body.to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
source: CLIAgentEventSource::CodexOsc9Fallback,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl CLIAgentSessionHandler for CodexSessionHandler {
|
||||
/// Codex sends plain-text OSC 9 notifications (title = `None`) instead of
|
||||
/// the structured OSC 777 JSON used by Claude Code / OpenCode.
|
||||
fn try_parse(&self, title: Option<&str>, body: &str) -> Option<CLIAgentEvent> {
|
||||
// If the notification carries the structured sentinel, try the normal
|
||||
// JSON parser first (future-proofing in case Codex adds plugin
|
||||
// support later).
|
||||
if let Some(parsed) = parse_event(title, body) {
|
||||
return Some(parsed);
|
||||
/// Before Codex enabled support for hooks, we relied on OSC 9 to trigger notifications in Warp.
|
||||
/// Here, we try to parse an OSC 777 event if we can, and remember when we've seen one.
|
||||
/// This lets us ignore OSC 9 notifications if we are working with a client that is using
|
||||
/// the new plugin, but keeps them intact for legacy clients.
|
||||
fn try_parse(
|
||||
&mut self,
|
||||
title: Option<&str>,
|
||||
body: &str,
|
||||
plugin_already_active: bool,
|
||||
) -> Option<CLIAgentEvent> {
|
||||
if let Some(event) = parse_event(title, body) {
|
||||
if event.agent == CLIAgent::Codex {
|
||||
if !FeatureFlag::CodexPlugin.is_enabled() {
|
||||
return None;
|
||||
}
|
||||
return Some(event);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
// OSC 9 notifications have no title.
|
||||
if title.is_some() {
|
||||
// OSC 9 notifications have no title. Skip OSC 9 once the rich plugin is
|
||||
// active, otherwise we'd process both OSC 777 and OSC 9 notifications.
|
||||
if title.is_some() || plugin_already_active {
|
||||
return None;
|
||||
}
|
||||
Self::parse_osc9_text(body)
|
||||
@@ -138,10 +155,6 @@ impl CLIAgentSessionHandler for CodexSessionHandler {
|
||||
fn handle_event(&mut self, event: CLIAgentEvent) -> Option<CLIAgentEvent> {
|
||||
Some(event)
|
||||
}
|
||||
|
||||
fn supports_rich_status(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-agent listener that subscribes to PTY events and forwards them to the
|
||||
@@ -169,14 +182,21 @@ impl CLIAgentSessionListener {
|
||||
// Subscribe to subsequent OSC events from this terminal's PTY.
|
||||
// Parsing is delegated to the handler's `try_parse`; the handler's
|
||||
// `handle_event` then filters/transforms the result.
|
||||
ctx.subscribe_to_model(model_event_dispatcher, move |me, event, ctx| {
|
||||
ctx.subscribe_to_model(model_event_dispatcher, move |me, _, event, ctx| {
|
||||
if let ModelEvent::PluggableNotification { title, body } = event {
|
||||
let Some(parsed) = me.inner.try_parse(title.as_deref(), body) else {
|
||||
let view_id = me.terminal_view_id;
|
||||
let plugin_already_active = CLIAgentSessionsModel::as_ref(ctx)
|
||||
.session(view_id)
|
||||
.is_some_and(|session| session.received_rich_notification);
|
||||
let Some(parsed) =
|
||||
me.inner
|
||||
.try_parse(title.as_deref(), body, plugin_already_active)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Some(event) = me.inner.handle_event(parsed) {
|
||||
CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions_model, ctx| {
|
||||
sessions_model.update_from_event(me.terminal_view_id, &event, ctx);
|
||||
sessions_model.update_from_event(view_id, &event, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -190,100 +210,5 @@ impl CLIAgentSessionListener {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::terminal::cli_agent_sessions::event::CLIAgentEventType;
|
||||
|
||||
#[test]
|
||||
fn codex_parses_any_text_as_stop() {
|
||||
let event = CodexSessionHandler::parse_osc9_text("Agent turn complete").unwrap();
|
||||
assert_eq!(event.event, CLIAgentEventType::Stop);
|
||||
assert_eq!(event.agent, CLIAgent::Codex);
|
||||
assert_eq!(event.payload.query.as_deref(), Some("Agent turn complete"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_body_becomes_query() {
|
||||
let event = CodexSessionHandler::parse_osc9_text(
|
||||
"I've updated the README with the new instructions.",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(event.event, CLIAgentEventType::Stop);
|
||||
assert_eq!(
|
||||
event.payload.query.as_deref(),
|
||||
Some("I've updated the README with the new instructions.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_approval_text_still_becomes_stop() {
|
||||
let event =
|
||||
CodexSessionHandler::parse_osc9_text("Approval requested: rm -rf /tmp/foo").unwrap();
|
||||
assert_eq!(event.event, CLIAgentEventType::Stop);
|
||||
assert_eq!(
|
||||
event.payload.query.as_deref(),
|
||||
Some("Approval requested: rm -rf /tmp/foo")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_ignores_empty_body() {
|
||||
assert!(CodexSessionHandler::parse_osc9_text("").is_none());
|
||||
assert!(CodexSessionHandler::parse_osc9_text(" ").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_try_parse_ignores_titled_notifications() {
|
||||
let handler = CodexSessionHandler;
|
||||
assert!(handler
|
||||
.try_parse(Some("some-title"), "Agent turn complete")
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_try_parse_handles_osc9() {
|
||||
let handler = CodexSessionHandler;
|
||||
let event = handler.try_parse(None, "Agent turn complete").unwrap();
|
||||
assert_eq!(event.event, CLIAgentEventType::Stop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auggie_is_supported() {
|
||||
assert!(is_agent_supported(&CLIAgent::Auggie));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auggie_uses_default_handler_with_rich_status() {
|
||||
assert!(agent_supports_rich_status(&CLIAgent::Auggie));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auggie_default_handler_skips_session_start() {
|
||||
let mut handler = DefaultSessionListener;
|
||||
let event = CLIAgentEvent {
|
||||
v: 1,
|
||||
agent: CLIAgent::Auggie,
|
||||
event: CLIAgentEventType::SessionStart,
|
||||
session_id: None,
|
||||
cwd: None,
|
||||
project: None,
|
||||
payload: CLIAgentEventPayload::default(),
|
||||
};
|
||||
assert!(handler.handle_event(event).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auggie_default_handler_forwards_stop() {
|
||||
let mut handler = DefaultSessionListener;
|
||||
let event = CLIAgentEvent {
|
||||
v: 1,
|
||||
agent: CLIAgent::Auggie,
|
||||
event: CLIAgentEventType::Stop,
|
||||
session_id: None,
|
||||
cwd: None,
|
||||
project: None,
|
||||
payload: CLIAgentEventPayload::default(),
|
||||
};
|
||||
assert!(handler.handle_event(event).is_some());
|
||||
}
|
||||
}
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
use super::*;
|
||||
use crate::terminal::cli_agent_sessions::event::{
|
||||
CLIAgentEventSource, CLIAgentEventType, CLI_AGENT_NOTIFICATION_SENTINEL,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn codex_parses_any_text_as_stop() {
|
||||
let event = CodexSessionHandler::parse_osc9_text("Agent turn complete").unwrap();
|
||||
assert_eq!(event.event, CLIAgentEventType::Stop);
|
||||
assert_eq!(event.agent, CLIAgent::Codex);
|
||||
assert_eq!(event.payload.query.as_deref(), Some("Agent turn complete"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_body_becomes_query() {
|
||||
let event =
|
||||
CodexSessionHandler::parse_osc9_text("I've updated the README with the new instructions.")
|
||||
.unwrap();
|
||||
assert_eq!(event.event, CLIAgentEventType::Stop);
|
||||
assert_eq!(
|
||||
event.payload.query.as_deref(),
|
||||
Some("I've updated the README with the new instructions.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_approval_text_still_becomes_stop() {
|
||||
let event =
|
||||
CodexSessionHandler::parse_osc9_text("Approval requested: rm -rf /tmp/foo").unwrap();
|
||||
assert_eq!(event.event, CLIAgentEventType::Stop);
|
||||
assert_eq!(
|
||||
event.payload.query.as_deref(),
|
||||
Some("Approval requested: rm -rf /tmp/foo")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_ignores_empty_body() {
|
||||
assert!(CodexSessionHandler::parse_osc9_text("").is_none());
|
||||
assert!(CodexSessionHandler::parse_osc9_text(" ").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_try_parse_ignores_titled_notifications() {
|
||||
let mut handler = CodexSessionHandler;
|
||||
assert!(handler
|
||||
.try_parse(Some("some-title"), "Agent turn complete", false)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_try_parse_handles_osc9() {
|
||||
let mut handler = CodexSessionHandler;
|
||||
let event = handler
|
||||
.try_parse(None, "Agent turn complete", false)
|
||||
.unwrap();
|
||||
assert_eq!(event.event, CLIAgentEventType::Stop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_try_parse_ignores_osc9_when_plugin_already_active() {
|
||||
let _guard = FeatureFlag::CodexPlugin.override_enabled(true);
|
||||
let mut handler = CodexSessionHandler;
|
||||
let body = r#"{"v":1,"agent":"codex","event":"permission_request","summary":"Approve?","tool_name":"Bash"}"#;
|
||||
|
||||
let event = handler
|
||||
.try_parse(Some(CLI_AGENT_NOTIFICATION_SENTINEL), body, false)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(event.event, CLIAgentEventType::PermissionRequest);
|
||||
// Once the session is rich, OSC 9 fallback is dropped.
|
||||
assert!(handler
|
||||
.try_parse(None, "Agent turn complete", true)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_try_parse_ignores_structured_event_without_codex_plugin() {
|
||||
let _guard = FeatureFlag::CodexPlugin.override_enabled(false);
|
||||
let mut handler = CodexSessionHandler;
|
||||
let body = r#"{"v":1,"agent":"codex","event":"permission_request","summary":"Approve?","tool_name":"Bash"}"#;
|
||||
|
||||
assert!(handler
|
||||
.try_parse(Some(CLI_AGENT_NOTIFICATION_SENTINEL), body, false)
|
||||
.is_none());
|
||||
assert!(handler
|
||||
.try_parse(None, "Agent turn complete", false)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_try_parse_ignores_other_structured_agents() {
|
||||
let mut handler = CodexSessionHandler;
|
||||
let body = r#"{"v":1,"agent":"claude","event":"stop"}"#;
|
||||
|
||||
assert!(handler
|
||||
.try_parse(Some(CLI_AGENT_NOTIFICATION_SENTINEL), body, false)
|
||||
.is_none());
|
||||
assert!(handler
|
||||
.try_parse(None, "Agent turn complete", false)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auggie_is_supported() {
|
||||
assert!(is_agent_supported(&CLIAgent::Auggie));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auggie_default_handler_skips_session_start() {
|
||||
let mut handler = DefaultSessionListener;
|
||||
let event = CLIAgentEvent {
|
||||
source: CLIAgentEventSource::RichPlugin,
|
||||
v: 1,
|
||||
agent: CLIAgent::Auggie,
|
||||
event: CLIAgentEventType::SessionStart,
|
||||
session_id: None,
|
||||
cwd: None,
|
||||
project: None,
|
||||
payload: CLIAgentEventPayload::default(),
|
||||
};
|
||||
assert!(handler.handle_event(event).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auggie_default_handler_forwards_stop() {
|
||||
let mut handler = DefaultSessionListener;
|
||||
let event = CLIAgentEvent {
|
||||
source: CLIAgentEventSource::RichPlugin,
|
||||
v: 1,
|
||||
agent: CLIAgent::Auggie,
|
||||
event: CLIAgentEventType::Stop,
|
||||
session_id: None,
|
||||
cwd: None,
|
||||
project: None,
|
||||
payload: CLIAgentEventPayload::default(),
|
||||
};
|
||||
assert!(handler.handle_event(event).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_is_supported() {
|
||||
assert!(is_agent_supported(&CLIAgent::Pi));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_default_handler_skips_session_start() {
|
||||
let mut handler = DefaultSessionListener;
|
||||
let event = CLIAgentEvent {
|
||||
source: CLIAgentEventSource::RichPlugin,
|
||||
v: 1,
|
||||
agent: CLIAgent::Pi,
|
||||
event: CLIAgentEventType::SessionStart,
|
||||
session_id: None,
|
||||
cwd: None,
|
||||
project: None,
|
||||
payload: CLIAgentEventPayload::default(),
|
||||
};
|
||||
assert!(handler.handle_event(event).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_default_handler_forwards_stop() {
|
||||
let mut handler = DefaultSessionListener;
|
||||
let event = CLIAgentEvent {
|
||||
source: CLIAgentEventSource::RichPlugin,
|
||||
v: 1,
|
||||
agent: CLIAgent::Pi,
|
||||
event: CLIAgentEventType::Stop,
|
||||
session_id: None,
|
||||
cwd: None,
|
||||
project: None,
|
||||
payload: CLIAgentEventPayload::default(),
|
||||
};
|
||||
assert!(handler.handle_event(event).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn droid_is_supported() {
|
||||
assert!(is_agent_supported(&CLIAgent::Droid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn droid_default_handler_skips_session_start() {
|
||||
let mut handler = DefaultSessionListener;
|
||||
let event = CLIAgentEvent {
|
||||
source: CLIAgentEventSource::RichPlugin,
|
||||
v: 1,
|
||||
agent: CLIAgent::Droid,
|
||||
event: CLIAgentEventType::SessionStart,
|
||||
session_id: None,
|
||||
cwd: None,
|
||||
project: None,
|
||||
payload: CLIAgentEventPayload::default(),
|
||||
};
|
||||
assert!(handler.handle_event(event).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn droid_default_handler_forwards_stop() {
|
||||
let mut handler = DefaultSessionListener;
|
||||
let event = CLIAgentEvent {
|
||||
source: CLIAgentEventSource::RichPlugin,
|
||||
v: 1,
|
||||
agent: CLIAgent::Droid,
|
||||
event: CLIAgentEventType::Stop,
|
||||
session_id: None,
|
||||
cwd: None,
|
||||
project: None,
|
||||
payload: CLIAgentEventPayload::default(),
|
||||
};
|
||||
assert!(handler.handle_event(event).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn droid_default_handler_forwards_permission_request() {
|
||||
let mut handler = DefaultSessionListener;
|
||||
let event = CLIAgentEvent {
|
||||
source: CLIAgentEventSource::RichPlugin,
|
||||
v: 1,
|
||||
agent: CLIAgent::Droid,
|
||||
event: CLIAgentEventType::PermissionRequest,
|
||||
session_id: None,
|
||||
cwd: None,
|
||||
project: None,
|
||||
payload: CLIAgentEventPayload::default(),
|
||||
};
|
||||
assert!(handler.handle_event(event).is_some());
|
||||
}
|
||||
@@ -5,13 +5,12 @@ pub(crate) mod plugin_manager;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use event::{CLIAgentEvent, CLIAgentEventSource, CLIAgentEventType};
|
||||
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use crate::ai::blocklist::InputConfig;
|
||||
|
||||
use self::listener::CLIAgentSessionListener;
|
||||
use super::CLIAgent;
|
||||
use event::{CLIAgentEvent, CLIAgentEventType};
|
||||
use crate::ai::blocklist::InputConfig;
|
||||
|
||||
/// Status of a tracked CLI agent session.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -123,12 +122,12 @@ pub struct CLIAgentSession {
|
||||
pub input_state: CLIAgentInputState,
|
||||
/// Whether status-driven auto-toggle is enabled for this session.
|
||||
pub should_auto_toggle_input: bool,
|
||||
/// Plugin-backed event listener, if the CLI agent plugin is installed.
|
||||
/// `None` for sessions created by command detection alone.
|
||||
/// Event listener for plugin-backed sessions or Codex OSC9 fallback.
|
||||
/// `None` for non-Codex sessions created by command detection alone.
|
||||
/// Dropping this handle cleans up the listener's PTY event subscription.
|
||||
pub listener: Option<ModelHandle<CLIAgentSessionListener>>,
|
||||
/// The plugin version reported by the `SessionStart` event.
|
||||
/// `None` if the plugin predates version reporting or hasn't connected yet.
|
||||
/// The plugin version reported by structured plugin events.
|
||||
/// `None` if the plugin predates version reporting or Codex is using OSC9 fallback.
|
||||
pub plugin_version: Option<String>,
|
||||
/// `None` when the session is local.
|
||||
/// `Some("user@hostname")` when running over SSH (warpified or legacy).
|
||||
@@ -141,6 +140,10 @@ pub struct CLIAgentSession {
|
||||
/// the first word of the command (the binary/alias the user typed).
|
||||
/// Used to customize plugin instructions and force manual install mode.
|
||||
pub custom_command_prefix: Option<String>,
|
||||
/// Set once the session has received any structured OSC 777 (rich)
|
||||
/// notification. Codex's OSC 9 fallback never sets it, so this is the
|
||||
/// single source of truth for whether the session is plugin-backed.
|
||||
pub received_rich_notification: bool,
|
||||
}
|
||||
|
||||
impl CLIAgentSession {
|
||||
@@ -148,6 +151,28 @@ impl CLIAgentSession {
|
||||
self.remote_host.is_some()
|
||||
}
|
||||
|
||||
/// Whether the session surfaces trustworthy fine-grained status
|
||||
/// (in-progress / blocked / success). True only after receiving a rich OSC
|
||||
/// 777 notification. Codex's OSC 9 fallback emits only opaque `Stop`
|
||||
/// notifications and never sets `received_rich_notification`, so it does
|
||||
/// not qualify. Synthetic listener registration also does not qualify until
|
||||
/// an actual rich notification arrives.
|
||||
pub fn supports_rich_status(&self) -> bool {
|
||||
self.received_rich_notification
|
||||
}
|
||||
|
||||
/// Clears state populated by `PermissionRequest`. Called whenever the
|
||||
/// session leaves the permission flow (the user replied, a blocking tool
|
||||
/// completed, a new prompt is submitted, or the session ends successfully)
|
||||
/// so the permission summary doesn't leak into later UI surfaces — most
|
||||
/// visibly the tab title, which can fall back to `summary` when `query`
|
||||
/// is unset.
|
||||
fn clear_permission_scoped_state(&mut self) {
|
||||
self.session_context.summary = None;
|
||||
self.session_context.tool_name = None;
|
||||
self.session_context.tool_input_preview = None;
|
||||
}
|
||||
|
||||
/// Applies an event to this session, updating context and status.
|
||||
/// Returns the new status if it changed, or `None` if the event was irrelevant.
|
||||
fn apply_event(&mut self, event: &CLIAgentEvent) -> Option<CLIAgentSessionStatus> {
|
||||
@@ -165,17 +190,20 @@ impl CLIAgentSession {
|
||||
CLIAgentEventType::PromptSubmit => {
|
||||
self.session_context.query = event.payload.query.clone();
|
||||
self.session_context.response = None;
|
||||
self.clear_permission_scoped_state();
|
||||
CLIAgentSessionStatus::InProgress
|
||||
}
|
||||
CLIAgentEventType::ToolComplete => {
|
||||
if !matches!(self.status, CLIAgentSessionStatus::Blocked { .. }) {
|
||||
return None;
|
||||
}
|
||||
self.clear_permission_scoped_state();
|
||||
CLIAgentSessionStatus::InProgress
|
||||
}
|
||||
CLIAgentEventType::Stop => {
|
||||
self.session_context.query = event.payload.query.clone();
|
||||
self.session_context.response = event.payload.response.clone();
|
||||
self.clear_permission_scoped_state();
|
||||
CLIAgentSessionStatus::Success
|
||||
}
|
||||
CLIAgentEventType::PermissionRequest => {
|
||||
@@ -197,6 +225,7 @@ impl CLIAgentSession {
|
||||
if !matches!(self.status, CLIAgentSessionStatus::Blocked { .. }) {
|
||||
return None;
|
||||
}
|
||||
self.clear_permission_scoped_state();
|
||||
CLIAgentSessionStatus::InProgress
|
||||
}
|
||||
// IdlePrompt means the agent is sitting at its prompt waiting for input.
|
||||
@@ -364,6 +393,7 @@ impl CLIAgentSessionsModel {
|
||||
remote_host,
|
||||
draft_text: None,
|
||||
custom_command_prefix: None,
|
||||
received_rich_notification: false,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
@@ -379,6 +409,8 @@ impl CLIAgentSessionsModel {
|
||||
}
|
||||
|
||||
/// Updates the session's status and context from a parsed CLI agent event.
|
||||
/// Rich plugin events latch `received_rich_notification` so rich-status
|
||||
/// surfaces stay consistent even if the first event was not SessionStart.
|
||||
pub fn update_from_event(
|
||||
&mut self,
|
||||
terminal_view_id: EntityId,
|
||||
@@ -389,6 +421,10 @@ impl CLIAgentSessionsModel {
|
||||
return;
|
||||
};
|
||||
|
||||
if event.source == CLIAgentEventSource::RichPlugin {
|
||||
session.received_rich_notification = true;
|
||||
}
|
||||
|
||||
let event_type = &event.event;
|
||||
if let Some(new_status) = session.apply_event(event) {
|
||||
let agent = session.agent;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use super::event::{parse_event, CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventType};
|
||||
use super::event::{
|
||||
parse_event, CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventSource, CLIAgentEventType,
|
||||
};
|
||||
use super::{
|
||||
CLIAgentInputEntrypoint, CLIAgentInputState, CLIAgentSession, CLIAgentSessionContext,
|
||||
CLIAgentSessionStatus, CLIAgentSessionsModel,
|
||||
@@ -222,6 +224,34 @@ fn parse_auggie_stop_notification() {
|
||||
assert_eq!(notif.payload.response.as_deref(), Some("Memory is safe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pi_stop_notification() {
|
||||
// Mirrors what the community pi-mono plugin emits on the Stop hook —
|
||||
// matches the Auggie shape and uses `"agent":"pi"`, which `resolve_agent`
|
||||
// already maps to `CLIAgent::Pi` via `command_prefix()`.
|
||||
let body = r#"{"v":1,"agent":"pi","event":"stop","session_id":"abc","cwd":"/tmp/proj","project":"proj","query":"write a haiku","response":"Memory is safe"}"#;
|
||||
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
|
||||
|
||||
assert_eq!(notif.agent, CLIAgent::Pi);
|
||||
assert_eq!(notif.event, CLIAgentEventType::Stop);
|
||||
assert_eq!(notif.payload.query.as_deref(), Some("write a haiku"));
|
||||
assert_eq!(notif.payload.response.as_deref(), Some("Memory is safe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_droid_stop_notification() {
|
||||
// Droid is already a known CLI agent, so structured OSC 777 events using
|
||||
// `"agent":"droid"` should resolve through the existing command prefix
|
||||
// parser without any Droid-specific parser logic.
|
||||
let body = r#"{"v":1,"agent":"droid","event":"stop","session_id":"abc","cwd":"/tmp/proj","project":"proj","query":"write a haiku","response":"Memory is safe"}"#;
|
||||
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
|
||||
|
||||
assert_eq!(notif.agent, CLIAgent::Droid);
|
||||
assert_eq!(notif.event, CLIAgentEventType::Stop);
|
||||
assert_eq!(notif.payload.query.as_deref(), Some("write a haiku"));
|
||||
assert_eq!(notif.payload.response.as_deref(), Some("Memory is safe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_event_preserves_input_session() {
|
||||
let input_state = CLIAgentInputState::Open {
|
||||
@@ -243,9 +273,11 @@ fn apply_event_preserves_input_session() {
|
||||
plugin_version: None,
|
||||
draft_text: None,
|
||||
custom_command_prefix: None,
|
||||
received_rich_notification: false,
|
||||
};
|
||||
|
||||
let event = CLIAgentEvent {
|
||||
source: CLIAgentEventSource::RichPlugin,
|
||||
v: 1,
|
||||
agent: CLIAgent::Claude,
|
||||
event: CLIAgentEventType::PermissionRequest,
|
||||
@@ -276,6 +308,7 @@ fn is_remote_returns_true_when_remote_host_is_set() {
|
||||
draft_text: None,
|
||||
remote_host: Some("user@devbox".to_owned()),
|
||||
custom_command_prefix: None,
|
||||
received_rich_notification: false,
|
||||
};
|
||||
assert!(session.is_remote());
|
||||
}
|
||||
@@ -293,6 +326,7 @@ fn is_remote_returns_false_when_remote_host_is_none() {
|
||||
plugin_version: None,
|
||||
draft_text: None,
|
||||
custom_command_prefix: None,
|
||||
received_rich_notification: false,
|
||||
};
|
||||
assert!(!session.is_remote());
|
||||
}
|
||||
@@ -361,9 +395,11 @@ fn session_start_sets_plugin_version() {
|
||||
draft_text: None,
|
||||
remote_host: None,
|
||||
custom_command_prefix: None,
|
||||
received_rich_notification: false,
|
||||
};
|
||||
|
||||
let event = CLIAgentEvent {
|
||||
source: CLIAgentEventSource::RichPlugin,
|
||||
v: 1,
|
||||
agent: CLIAgent::Claude,
|
||||
event: CLIAgentEventType::SessionStart,
|
||||
@@ -393,9 +429,11 @@ fn session_start_without_plugin_version_leaves_none() {
|
||||
draft_text: None,
|
||||
remote_host: None,
|
||||
custom_command_prefix: None,
|
||||
received_rich_notification: false,
|
||||
};
|
||||
|
||||
let event = CLIAgentEvent {
|
||||
source: CLIAgentEventSource::RichPlugin,
|
||||
v: 1,
|
||||
agent: CLIAgent::Claude,
|
||||
event: CLIAgentEventType::SessionStart,
|
||||
@@ -408,3 +446,254 @@ fn session_start_without_plugin_version_leaves_none() {
|
||||
session.apply_event(&event);
|
||||
assert_eq!(session.plugin_version, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_session_not_rich_until_rich_notification() {
|
||||
// Codex's OSC 9 fallback never sets `received_rich_notification`, so the
|
||||
// session must not claim rich status even when a fallback listener exists.
|
||||
let mut session = CLIAgentSession {
|
||||
agent: CLIAgent::Codex,
|
||||
status: CLIAgentSessionStatus::InProgress,
|
||||
session_context: CLIAgentSessionContext::default(),
|
||||
input_state: CLIAgentInputState::Closed,
|
||||
should_auto_toggle_input: false,
|
||||
listener: None,
|
||||
plugin_version: None,
|
||||
remote_host: None,
|
||||
draft_text: None,
|
||||
custom_command_prefix: None,
|
||||
received_rich_notification: false,
|
||||
};
|
||||
assert!(!session.supports_rich_status());
|
||||
|
||||
// A structured OSC 777 notification latches the flag -> rich status.
|
||||
session.received_rich_notification = true;
|
||||
assert!(session.supports_rich_status());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_codex_session_rich_after_rich_notification() {
|
||||
let mut session = CLIAgentSession {
|
||||
agent: CLIAgent::Claude,
|
||||
status: CLIAgentSessionStatus::InProgress,
|
||||
session_context: CLIAgentSessionContext::default(),
|
||||
input_state: CLIAgentInputState::Closed,
|
||||
should_auto_toggle_input: false,
|
||||
listener: None,
|
||||
plugin_version: None,
|
||||
remote_host: None,
|
||||
draft_text: None,
|
||||
custom_command_prefix: None,
|
||||
received_rich_notification: false,
|
||||
};
|
||||
// No listener and no rich notification yet.
|
||||
assert!(!session.supports_rich_status());
|
||||
|
||||
session.received_rich_notification = true;
|
||||
assert!(session.supports_rich_status());
|
||||
}
|
||||
|
||||
/// Constructs a session with permission-scoped state already populated, as if
|
||||
/// a `PermissionRequest` had just been received and the agent is now Blocked.
|
||||
/// Used by the GH-9525 regression tests below.
|
||||
fn blocked_claude_session_with_permission_state() -> CLIAgentSession {
|
||||
CLIAgentSession {
|
||||
agent: CLIAgent::Claude,
|
||||
status: CLIAgentSessionStatus::Blocked {
|
||||
message: Some("Wants to run bash: rm -rf /tmp".to_owned()),
|
||||
},
|
||||
session_context: CLIAgentSessionContext {
|
||||
summary: Some("Wants to run bash: rm -rf /tmp".to_owned()),
|
||||
tool_name: Some("Bash".to_owned()),
|
||||
tool_input_preview: Some("rm -rf /tmp".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
input_state: CLIAgentInputState::Closed,
|
||||
should_auto_toggle_input: false,
|
||||
listener: None,
|
||||
plugin_version: None,
|
||||
draft_text: None,
|
||||
remote_host: None,
|
||||
custom_command_prefix: None,
|
||||
received_rich_notification: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_clears_permission_scoped_state() {
|
||||
// GH-9525: after a PermissionRequest sets `summary`, the Stop event must
|
||||
// clear it. Otherwise the tab title falls back to the stale permission
|
||||
// text instead of reflecting the now-completed session.
|
||||
let mut session = blocked_claude_session_with_permission_state();
|
||||
|
||||
let event = CLIAgentEvent {
|
||||
source: CLIAgentEventSource::RichPlugin,
|
||||
v: 1,
|
||||
agent: CLIAgent::Claude,
|
||||
event: CLIAgentEventType::Stop,
|
||||
session_id: Some("abc".to_owned()),
|
||||
cwd: None,
|
||||
project: None,
|
||||
payload: CLIAgentEventPayload {
|
||||
query: Some("write a haiku".to_owned()),
|
||||
response: Some("Memory is safe".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
session.apply_event(&event);
|
||||
|
||||
assert_eq!(session.session_context.summary, None);
|
||||
assert_eq!(session.session_context.tool_name, None);
|
||||
assert_eq!(session.session_context.tool_input_preview, None);
|
||||
assert_eq!(
|
||||
session.session_context.query.as_deref(),
|
||||
Some("write a haiku"),
|
||||
);
|
||||
assert_eq!(
|
||||
session.session_context.response.as_deref(),
|
||||
Some("Memory is safe"),
|
||||
);
|
||||
assert!(matches!(session.status, CLIAgentSessionStatus::Success));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_replied_clears_permission_scoped_state() {
|
||||
// When the user replies to a permission prompt the agent transitions back
|
||||
// to InProgress; the now-stale summary/tool fields must be cleared so they
|
||||
// don't leak into UI surfaces during the next turn.
|
||||
let mut session = blocked_claude_session_with_permission_state();
|
||||
|
||||
let event = CLIAgentEvent {
|
||||
source: CLIAgentEventSource::RichPlugin,
|
||||
v: 1,
|
||||
agent: CLIAgent::Claude,
|
||||
event: CLIAgentEventType::PermissionReplied,
|
||||
session_id: Some("abc".to_owned()),
|
||||
cwd: None,
|
||||
project: None,
|
||||
payload: CLIAgentEventPayload::default(),
|
||||
};
|
||||
|
||||
session.apply_event(&event);
|
||||
|
||||
assert_eq!(session.session_context.summary, None);
|
||||
assert_eq!(session.session_context.tool_name, None);
|
||||
assert_eq!(session.session_context.tool_input_preview, None);
|
||||
assert!(matches!(session.status, CLIAgentSessionStatus::InProgress));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_submit_clears_permission_scoped_state() {
|
||||
// PromptSubmit already clears `response`; clearing the permission-scoped
|
||||
// fields keeps the same hygiene if the user manages to start a new turn
|
||||
// while permission state is still populated (e.g. an abandoned permission
|
||||
// flow that was not closed by an explicit PermissionReplied).
|
||||
let mut session = blocked_claude_session_with_permission_state();
|
||||
session.session_context.response = Some("stale response".to_owned());
|
||||
|
||||
let event = CLIAgentEvent {
|
||||
source: CLIAgentEventSource::RichPlugin,
|
||||
v: 1,
|
||||
agent: CLIAgent::Claude,
|
||||
event: CLIAgentEventType::PromptSubmit,
|
||||
session_id: Some("abc".to_owned()),
|
||||
cwd: None,
|
||||
project: None,
|
||||
payload: CLIAgentEventPayload {
|
||||
query: Some("next prompt".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
session.apply_event(&event);
|
||||
|
||||
assert_eq!(session.session_context.summary, None);
|
||||
assert_eq!(session.session_context.tool_name, None);
|
||||
assert_eq!(session.session_context.tool_input_preview, None);
|
||||
assert_eq!(session.session_context.response, None);
|
||||
assert_eq!(
|
||||
session.session_context.query.as_deref(),
|
||||
Some("next prompt")
|
||||
);
|
||||
assert!(matches!(session.status, CLIAgentSessionStatus::InProgress));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_complete_clears_permission_scoped_state() {
|
||||
// GH-11082: answering an AskUserQuestion emits only ToolComplete (the
|
||||
// plugin sends no PermissionReplied for it), so the Blocked -> InProgress
|
||||
// transition here must also clear the stale summary. Otherwise the tab
|
||||
// title keeps showing "Wants to run AskUserQuestion: ..." until the next
|
||||
// prompt or Stop.
|
||||
let mut session = blocked_claude_session_with_permission_state();
|
||||
|
||||
let event = CLIAgentEvent {
|
||||
source: CLIAgentEventSource::RichPlugin,
|
||||
v: 1,
|
||||
agent: CLIAgent::Claude,
|
||||
event: CLIAgentEventType::ToolComplete,
|
||||
session_id: Some("abc".to_owned()),
|
||||
cwd: None,
|
||||
project: None,
|
||||
payload: CLIAgentEventPayload::default(),
|
||||
};
|
||||
|
||||
session.apply_event(&event);
|
||||
|
||||
assert_eq!(session.session_context.summary, None);
|
||||
assert_eq!(session.session_context.tool_name, None);
|
||||
assert_eq!(session.session_context.tool_input_preview, None);
|
||||
assert!(matches!(session.status, CLIAgentSessionStatus::InProgress));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_request_still_populates_summary_and_tool_fields() {
|
||||
// Sanity: clearing permission-scoped state on Stop/Reply/Submit must not
|
||||
// also break the PermissionRequest path that initially populates them.
|
||||
let mut session = CLIAgentSession {
|
||||
agent: CLIAgent::Claude,
|
||||
status: CLIAgentSessionStatus::InProgress,
|
||||
session_context: CLIAgentSessionContext::default(),
|
||||
input_state: CLIAgentInputState::Closed,
|
||||
should_auto_toggle_input: false,
|
||||
listener: None,
|
||||
plugin_version: None,
|
||||
draft_text: None,
|
||||
remote_host: None,
|
||||
custom_command_prefix: None,
|
||||
received_rich_notification: false,
|
||||
};
|
||||
|
||||
let event = CLIAgentEvent {
|
||||
source: CLIAgentEventSource::RichPlugin,
|
||||
v: 1,
|
||||
agent: CLIAgent::Claude,
|
||||
event: CLIAgentEventType::PermissionRequest,
|
||||
session_id: Some("abc".to_owned()),
|
||||
cwd: None,
|
||||
project: None,
|
||||
payload: CLIAgentEventPayload {
|
||||
summary: Some("Wants to run bash: rm -rf /tmp".to_owned()),
|
||||
tool_name: Some("Bash".to_owned()),
|
||||
tool_input_preview: Some("rm -rf /tmp".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
session.apply_event(&event);
|
||||
|
||||
assert_eq!(
|
||||
session.session_context.summary.as_deref(),
|
||||
Some("Wants to run bash: rm -rf /tmp"),
|
||||
);
|
||||
assert_eq!(session.session_context.tool_name.as_deref(), Some("Bash"));
|
||||
assert_eq!(
|
||||
session.session_context.tool_input_preview.as_deref(),
|
||||
Some("rm -rf /tmp"),
|
||||
);
|
||||
assert!(matches!(
|
||||
session.status,
|
||||
CLIAgentSessionStatus::Blocked { .. },
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::LazyLock;
|
||||
use std::{env, fs, io};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
@@ -16,16 +14,16 @@ use crate::terminal::model::session::LocalCommandExecutor;
|
||||
use crate::terminal::shell::ShellType;
|
||||
|
||||
const PLUGIN_KEY: &str = "warp@claude-code-warp";
|
||||
const PLATFORM_PLUGIN_KEY: &str = "oz-harness-support@claude-code-warp";
|
||||
|
||||
const MARKETPLACE_REPO: &str = "warpdotdev/claude-code-warp";
|
||||
const MARKETPLACE_NAME: &str = "claude-code-warp";
|
||||
|
||||
const PLATFORM_PLUGIN_KEY: &str = "oz-harness-support@claude-code-warp";
|
||||
// Note: we will eventually publish this to the same marketplace repo, but are using the internal one as we build out multi-harness.
|
||||
const PLATFORM_MARKETPLACE_REPO: &str = "warpdotdev/claude-code-warp-internal";
|
||||
|
||||
// Keep in sync with the plugin version in warpdotdev/claude-code-warp.
|
||||
// (See the Versioning section of that repo's README.)
|
||||
const MINIMUM_PLUGIN_VERSION: &str = "2.0.0";
|
||||
const MINIMUM_PLUGIN_VERSION: &str = "2.1.0";
|
||||
// Keep in sync with the oz-harness-support plugin version in warpdotdev/claude-code-warp.
|
||||
const MINIMUM_PLATFORM_PLUGIN_VERSION: &str = "1.1.2";
|
||||
|
||||
pub(super) struct ClaudeCodePluginManager {
|
||||
executor: LocalCommandExecutor,
|
||||
@@ -71,6 +69,31 @@ impl CliAgentPluginManager for ClaudeCodePluginManager {
|
||||
check_installed(&claude_dir)
|
||||
}
|
||||
|
||||
fn is_platform_plugin_installed(&self) -> bool {
|
||||
let Ok(claude_dir) = claude_home_dir() else {
|
||||
return false;
|
||||
};
|
||||
check_platform_plugin_installed(&claude_dir)
|
||||
}
|
||||
|
||||
fn platform_plugin_needs_update(&self) -> bool {
|
||||
let Ok(claude_dir) = claude_home_dir() else {
|
||||
return false;
|
||||
};
|
||||
match installed_platform_plugin_version(&claude_dir) {
|
||||
Some(v) => compare_versions(&v, MINIMUM_PLATFORM_PLUGIN_VERSION).is_lt(),
|
||||
// No version field means very old plugin.
|
||||
None => check_platform_plugin_installed(&claude_dir),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_local_marketplace_override(&self) -> bool {
|
||||
let Ok(claude_dir) = claude_home_dir() else {
|
||||
return false;
|
||||
};
|
||||
claude_code_marketplace_has_local_override(&claude_dir)
|
||||
}
|
||||
|
||||
/// Runs `claude plugin` CLI commands via the session shell.
|
||||
async fn install(&self) -> Result<(), PluginInstallError> {
|
||||
let mut log = String::new();
|
||||
@@ -151,7 +174,7 @@ impl CliAgentPluginManager for ClaudeCodePluginManager {
|
||||
async fn install_platform_plugin(&self) -> Result<(), PluginInstallError> {
|
||||
let mut log = String::new();
|
||||
self.run_logged(
|
||||
&["plugin", "marketplace", "add", PLATFORM_MARKETPLACE_REPO],
|
||||
&["plugin", "marketplace", "add", MARKETPLACE_REPO],
|
||||
&mut log,
|
||||
)
|
||||
.await?;
|
||||
@@ -159,6 +182,31 @@ impl CliAgentPluginManager for ClaudeCodePluginManager {
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_platform_plugin(&self) -> Result<(), PluginInstallError> {
|
||||
let mut log = String::new();
|
||||
self.run_logged(
|
||||
&["plugin", "marketplace", "add", MARKETPLACE_REPO],
|
||||
&mut log,
|
||||
)
|
||||
.await?;
|
||||
self.run_logged(&["plugin", "install", PLATFORM_PLUGIN_KEY], &mut log)
|
||||
.await?;
|
||||
|
||||
let still_outdated = claude_home_dir()
|
||||
.ok()
|
||||
.and_then(|dir| installed_platform_plugin_version(&dir))
|
||||
.map(|v| compare_versions(&v, MINIMUM_PLATFORM_PLUGIN_VERSION).is_lt())
|
||||
.unwrap_or(true);
|
||||
if still_outdated {
|
||||
log.push_str("Post-update version check: platform plugin is still outdated\n");
|
||||
return Err(PluginInstallError {
|
||||
message: "Platform plugin update did not take effect".to_owned(),
|
||||
log,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
static INSTALL_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| {
|
||||
@@ -214,6 +262,14 @@ static UPDATE_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| Plug
|
||||
});
|
||||
|
||||
fn check_installed(claude_dir: &Path) -> bool {
|
||||
check_plugin_installed(claude_dir, PLUGIN_KEY)
|
||||
}
|
||||
|
||||
fn check_platform_plugin_installed(claude_dir: &Path) -> bool {
|
||||
check_plugin_installed(claude_dir, PLATFORM_PLUGIN_KEY)
|
||||
}
|
||||
|
||||
fn check_plugin_installed(claude_dir: &Path, plugin_key: &str) -> bool {
|
||||
let plugins_path = claude_dir.join("plugins").join("installed_plugins.json");
|
||||
let Ok(contents) = fs::read_to_string(plugins_path) else {
|
||||
return false;
|
||||
@@ -223,7 +279,7 @@ fn check_installed(claude_dir: &Path) -> bool {
|
||||
};
|
||||
parsed
|
||||
.get("plugins")
|
||||
.and_then(|p| p.get(PLUGIN_KEY))
|
||||
.and_then(|p| p.get(plugin_key))
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| !arr.is_empty())
|
||||
.unwrap_or(false)
|
||||
@@ -231,12 +287,21 @@ fn check_installed(claude_dir: &Path) -> bool {
|
||||
|
||||
/// Reads the installed version string for the Warp plugin, if present.
|
||||
fn installed_version(claude_dir: &Path) -> Option<String> {
|
||||
installed_plugin_version(claude_dir, PLUGIN_KEY)
|
||||
}
|
||||
|
||||
/// Reads the installed version string for the Oz platform plugin, if present.
|
||||
fn installed_platform_plugin_version(claude_dir: &Path) -> Option<String> {
|
||||
installed_plugin_version(claude_dir, PLATFORM_PLUGIN_KEY)
|
||||
}
|
||||
|
||||
fn installed_plugin_version(claude_dir: &Path, plugin_key: &str) -> Option<String> {
|
||||
let plugins_path = claude_dir.join("plugins").join("installed_plugins.json");
|
||||
let contents = fs::read_to_string(plugins_path).ok()?;
|
||||
let parsed: Value = serde_json::from_str(&contents).ok()?;
|
||||
parsed
|
||||
.get("plugins")?
|
||||
.get(PLUGIN_KEY)?
|
||||
.get(plugin_key)?
|
||||
.as_array()?
|
||||
.first()?
|
||||
.get("version")?
|
||||
@@ -244,10 +309,55 @@ fn installed_version(claude_dir: &Path) -> Option<String> {
|
||||
.map(|s| s.to_owned())
|
||||
}
|
||||
|
||||
/// Checks `CLAUDE_HOME` env var first, falls back to `~/.claude`.
|
||||
fn claude_code_marketplace_has_local_override(claude_dir: &Path) -> bool {
|
||||
let settings_path = claude_dir.join("settings.json");
|
||||
let Ok(contents) = fs::read_to_string(settings_path) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(settings) = serde_json::from_str::<Value>(&contents) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
settings
|
||||
.get("extraKnownMarketplaces")
|
||||
.and_then(|marketplaces| marketplaces.get(MARKETPLACE_NAME))
|
||||
.map(marketplace_entry_has_local_path)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn marketplace_entry_has_local_path(entry: &Value) -> bool {
|
||||
let Some(source) = entry.get("source") else {
|
||||
return false;
|
||||
};
|
||||
match source {
|
||||
Value::Object(source) => {
|
||||
let source_kind = source.get("source").and_then(Value::as_str);
|
||||
let path = source.get("path").and_then(Value::as_str);
|
||||
source_kind == Some("directory") && path.map(is_local_marketplace_path).unwrap_or(false)
|
||||
}
|
||||
Value::String(source) => is_local_marketplace_path(source),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_local_marketplace_path(source: &str) -> bool {
|
||||
source.starts_with('/')
|
||||
|| source.starts_with("~/")
|
||||
|| source.starts_with("./")
|
||||
|| source.starts_with("../")
|
||||
|| source.starts_with("file://")
|
||||
}
|
||||
|
||||
/// Resolves the dir the Claude CLI reads/writes its state from.
|
||||
///
|
||||
/// Honors `CLAUDE_CONFIG_DIR` (respected by the Claude CLI, and set by the Oz
|
||||
/// worker to a per-task dir), falling back to `~/.claude`. Must match where
|
||||
/// `claude plugin install` writes, else install/verify checks read the wrong dir.
|
||||
fn claude_home_dir() -> io::Result<PathBuf> {
|
||||
if let Ok(claude_home) = env::var("CLAUDE_HOME") {
|
||||
return Ok(PathBuf::from(claude_home));
|
||||
if let Ok(dir) = env::var("CLAUDE_CONFIG_DIR") {
|
||||
if !dir.is_empty() {
|
||||
return Ok(PathBuf::from(dir));
|
||||
}
|
||||
}
|
||||
dirs::home_dir()
|
||||
.map(|home| home.join(".claude"))
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
use std::fs;
|
||||
|
||||
use super::{check_installed, installed_version, ClaudeCodePluginManager, CliAgentPluginManager};
|
||||
use super::{
|
||||
check_installed, check_platform_plugin_installed, claude_code_marketplace_has_local_override,
|
||||
installed_platform_plugin_version, installed_version, ClaudeCodePluginManager,
|
||||
CliAgentPluginManager, MINIMUM_PLATFORM_PLUGIN_VERSION,
|
||||
};
|
||||
|
||||
/// A version strictly below `version`, so below-minimum tests track the
|
||||
/// constant instead of a hardcoded literal. Assumes `version` > "0.0.0".
|
||||
fn version_below(version: &str) -> String {
|
||||
let mut parts: Vec<u64> = version.split('.').map(|p| p.parse().unwrap_or(0)).collect();
|
||||
for part in parts.iter_mut().rev() {
|
||||
if *part > 0 {
|
||||
*part -= 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
parts
|
||||
.iter()
|
||||
.map(|p| p.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(".")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_when_plugin_present() {
|
||||
@@ -22,6 +43,212 @@ fn installed_when_plugin_present() {
|
||||
assert!(check_installed(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_marketplace_override_detects_directory_source() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = serde_json::json!({
|
||||
"extraKnownMarketplaces": {
|
||||
"claude-code-warp": {
|
||||
"source": {
|
||||
"path": "/Users/example/Developer/claude-code-warp-internal",
|
||||
"source": "directory"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
fs::write(
|
||||
dir.path().join("settings.json"),
|
||||
serde_json::to_string(&settings).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(claude_code_marketplace_has_local_override(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_marketplace_override_ignores_repo_source() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = serde_json::json!({
|
||||
"extraKnownMarketplaces": {
|
||||
"claude-code-warp": {
|
||||
"source": "warpdotdev/claude-code-warp"
|
||||
}
|
||||
}
|
||||
});
|
||||
fs::write(
|
||||
dir.path().join("settings.json"),
|
||||
serde_json::to_string(&settings).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!claude_code_marketplace_has_local_override(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn local_marketplace_override_via_trait_uses_claude_config_dir() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = serde_json::json!({
|
||||
"extraKnownMarketplaces": {
|
||||
"claude-code-warp": {
|
||||
"source": {
|
||||
"path": "../claude-code-warp-internal",
|
||||
"source": "directory"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
fs::write(
|
||||
dir.path().join("settings.json"),
|
||||
serde_json::to_string(&settings).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
std::env::set_var("CLAUDE_CONFIG_DIR", dir.path());
|
||||
let result = ClaudeCodePluginManager::new(None, None, None).has_local_marketplace_override();
|
||||
std::env::remove_var("CLAUDE_CONFIG_DIR");
|
||||
|
||||
assert!(result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_platform_plugin_version_returns_version_when_present() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let plugins_dir = dir.path().join("plugins");
|
||||
fs::create_dir_all(&plugins_dir).unwrap();
|
||||
|
||||
let json = serde_json::json!({
|
||||
"plugins": {
|
||||
"oz-harness-support@claude-code-warp": [{"version": MINIMUM_PLATFORM_PLUGIN_VERSION}]
|
||||
}
|
||||
});
|
||||
fs::write(
|
||||
plugins_dir.join("installed_plugins.json"),
|
||||
serde_json::to_string(&json).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
installed_platform_plugin_version(dir.path()).as_deref(),
|
||||
Some(MINIMUM_PLATFORM_PLUGIN_VERSION)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_plugin_installed_when_platform_plugin_present() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let plugins_dir = dir.path().join("plugins");
|
||||
fs::create_dir_all(&plugins_dir).unwrap();
|
||||
|
||||
let json = serde_json::json!({
|
||||
"plugins": {
|
||||
"oz-harness-support@claude-code-warp": [{"version": MINIMUM_PLATFORM_PLUGIN_VERSION}]
|
||||
}
|
||||
});
|
||||
fs::write(
|
||||
plugins_dir.join("installed_plugins.json"),
|
||||
serde_json::to_string(&json).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(check_platform_plugin_installed(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn platform_plugin_needs_update_via_trait_when_version_below_minimum() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let plugins_dir = dir.path().join("plugins");
|
||||
fs::create_dir_all(&plugins_dir).unwrap();
|
||||
|
||||
let json = serde_json::json!({
|
||||
"plugins": {
|
||||
"oz-harness-support@claude-code-warp": [{"version": version_below(MINIMUM_PLATFORM_PLUGIN_VERSION)}]
|
||||
}
|
||||
});
|
||||
fs::write(
|
||||
plugins_dir.join("installed_plugins.json"),
|
||||
serde_json::to_string(&json).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
std::env::set_var("CLAUDE_CONFIG_DIR", dir.path());
|
||||
let result = ClaudeCodePluginManager::new(None, None, None).platform_plugin_needs_update();
|
||||
std::env::remove_var("CLAUDE_CONFIG_DIR");
|
||||
|
||||
assert!(result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn platform_plugin_does_not_need_update_via_trait_when_current() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let plugins_dir = dir.path().join("plugins");
|
||||
fs::create_dir_all(&plugins_dir).unwrap();
|
||||
|
||||
let json = serde_json::json!({
|
||||
"plugins": {
|
||||
"oz-harness-support@claude-code-warp": [{"version": MINIMUM_PLATFORM_PLUGIN_VERSION}]
|
||||
}
|
||||
});
|
||||
fs::write(
|
||||
plugins_dir.join("installed_plugins.json"),
|
||||
serde_json::to_string(&json).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
std::env::set_var("CLAUDE_CONFIG_DIR", dir.path());
|
||||
let result = ClaudeCodePluginManager::new(None, None, None).platform_plugin_needs_update();
|
||||
std::env::remove_var("CLAUDE_CONFIG_DIR");
|
||||
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn platform_plugin_needs_update_via_trait_when_installed_without_version() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let plugins_dir = dir.path().join("plugins");
|
||||
fs::create_dir_all(&plugins_dir).unwrap();
|
||||
|
||||
let json = serde_json::json!({
|
||||
"plugins": {
|
||||
"oz-harness-support@claude-code-warp": [{"scope": "user"}]
|
||||
}
|
||||
});
|
||||
fs::write(
|
||||
plugins_dir.join("installed_plugins.json"),
|
||||
serde_json::to_string(&json).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
std::env::set_var("CLAUDE_CONFIG_DIR", dir.path());
|
||||
let result = ClaudeCodePluginManager::new(None, None, None).platform_plugin_needs_update();
|
||||
std::env::remove_var("CLAUDE_CONFIG_DIR");
|
||||
|
||||
assert!(result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_plugin_not_installed_when_only_notification_plugin_present() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let plugins_dir = dir.path().join("plugins");
|
||||
fs::create_dir_all(&plugins_dir).unwrap();
|
||||
|
||||
let json = serde_json::json!({
|
||||
"plugins": {
|
||||
"warp@claude-code-warp": [{"version": "1.0.0"}]
|
||||
}
|
||||
});
|
||||
fs::write(
|
||||
plugins_dir.join("installed_plugins.json"),
|
||||
serde_json::to_string(&json).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!check_platform_plugin_installed(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_installed_when_plugin_key_absent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -95,10 +322,10 @@ fn not_installed_when_plugins_key_missing() {
|
||||
}
|
||||
|
||||
/// Tests `ClaudeCodePluginManager::is_installed` end-to-end by pointing
|
||||
/// `CLAUDE_HOME` at a temp directory with a valid installed_plugins.json.
|
||||
/// `CLAUDE_CONFIG_DIR` at a temp directory with a valid installed_plugins.json.
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn is_installed_via_trait_with_claude_home_env() {
|
||||
fn is_installed_via_trait_with_claude_config_dir_env() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let plugins_dir = dir.path().join("plugins");
|
||||
fs::create_dir_all(&plugins_dir).unwrap();
|
||||
@@ -114,38 +341,25 @@ fn is_installed_via_trait_with_claude_home_env() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
std::env::set_var("CLAUDE_HOME", dir.path());
|
||||
std::env::set_var("CLAUDE_CONFIG_DIR", dir.path());
|
||||
let result = ClaudeCodePluginManager::new(None, None, None).is_installed();
|
||||
std::env::remove_var("CLAUDE_HOME");
|
||||
std::env::remove_var("CLAUDE_CONFIG_DIR");
|
||||
|
||||
assert!(result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn not_installed_via_trait_when_claude_home_empty() {
|
||||
fn not_installed_via_trait_when_claude_config_dir_empty() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
std::env::set_var("CLAUDE_HOME", dir.path());
|
||||
std::env::set_var("CLAUDE_CONFIG_DIR", dir.path());
|
||||
let result = ClaudeCodePluginManager::new(None, None, None).is_installed();
|
||||
std::env::remove_var("CLAUDE_HOME");
|
||||
std::env::remove_var("CLAUDE_CONFIG_DIR");
|
||||
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_auto_install_is_true() {
|
||||
assert!(ClaudeCodePluginManager::new(None, None, None).can_auto_install());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimum_version() {
|
||||
assert_eq!(
|
||||
ClaudeCodePluginManager::new(None, None, None).minimum_plugin_version(),
|
||||
"2.0.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_version_returns_version_when_present() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -1,54 +1,317 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::LazyLock;
|
||||
use std::{env, fs, io};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{CliAgentPluginManager, PluginInstructionStep, PluginInstructions};
|
||||
use super::{
|
||||
compare_versions, run_cli_command_logged, CliAgentPluginManager, PluginInstallError,
|
||||
PluginInstructionStep, PluginInstructions,
|
||||
};
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::terminal::model::session::LocalCommandExecutor;
|
||||
use crate::terminal::shell::ShellType;
|
||||
|
||||
pub(super) struct CodexPluginManager;
|
||||
const PLUGIN_NAME: &str = "warp";
|
||||
const PLUGIN_KEY: &str = "warp@codex-warp";
|
||||
const MARKETPLACE_REPO: &str = "warpdotdev/codex-warp";
|
||||
const MARKETPLACE_NAME: &str = "codex-warp";
|
||||
|
||||
const PLATFORM_PLUGIN_NAME: &str = "orchestration";
|
||||
const PLATFORM_PLUGIN_KEY: &str = "orchestration@codex-warp";
|
||||
|
||||
const CODEX_CONFIG_DIR: &str = ".codex";
|
||||
const CODEX_HOME_ENV: &str = "CODEX_HOME";
|
||||
|
||||
// Keep in sync with the plugin version in warpdotdev/codex-warp.
|
||||
const MINIMUM_PLUGIN_VERSION: &str = "0.4.0";
|
||||
// Keep in sync with the orchestration plugin version in warpdotdev/codex-warp.
|
||||
const MINIMUM_PLATFORM_PLUGIN_VERSION: &str = "0.4.0";
|
||||
|
||||
pub(super) struct CodexPluginManager {
|
||||
executor: LocalCommandExecutor,
|
||||
path_env_var: Option<String>,
|
||||
}
|
||||
|
||||
impl CodexPluginManager {
|
||||
pub(super) fn new(
|
||||
shell_path: Option<PathBuf>,
|
||||
shell_type: Option<ShellType>,
|
||||
path_env_var: Option<String>,
|
||||
) -> Self {
|
||||
let shell_type = shell_type.unwrap_or(ShellType::Bash);
|
||||
Self {
|
||||
executor: LocalCommandExecutor::new(shell_path, shell_type),
|
||||
path_env_var,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_logged(&self, args: &[&str], log: &mut String) -> Result<(), PluginInstallError> {
|
||||
let env_vars = self
|
||||
.path_env_var
|
||||
.as_deref()
|
||||
.map(|path| HashMap::from([("PATH".to_owned(), path.to_owned())]));
|
||||
run_cli_command_logged("codex", args, &self.executor, env_vars, log).await
|
||||
}
|
||||
|
||||
/// Ensures the codex-warp marketplace is registered, while preserving a
|
||||
/// non-Git/local marketplace override. If the marketplace is already a
|
||||
/// Git repo, upgrade it; if it is a non-Git source, leave it alone; otherwise
|
||||
/// add it from the canonical repository.
|
||||
async fn ensure_marketplace(&self, log: &mut String) -> Result<(), PluginInstallError> {
|
||||
match codex_home_dir()
|
||||
.ok()
|
||||
.and_then(|dir| codex_warp_marketplace_config(&dir))
|
||||
{
|
||||
Some(config) if config.is_git() => {
|
||||
self.run_logged(&["plugin", "marketplace", "upgrade", MARKETPLACE_NAME], log)
|
||||
.await
|
||||
}
|
||||
Some(_) => Ok(()),
|
||||
None => {
|
||||
self.run_logged(&["plugin", "marketplace", "add", MARKETPLACE_REPO], log)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CliAgentPluginManager for CodexPluginManager {
|
||||
fn minimum_plugin_version(&self) -> &'static str {
|
||||
"0.0.0"
|
||||
if FeatureFlag::CodexPlugin.is_enabled() {
|
||||
MINIMUM_PLUGIN_VERSION
|
||||
} else {
|
||||
"0.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
fn can_auto_install(&self) -> bool {
|
||||
false
|
||||
FeatureFlag::CodexPlugin.is_enabled()
|
||||
}
|
||||
|
||||
fn supports_update(&self) -> bool {
|
||||
false
|
||||
fn is_installed(&self) -> bool {
|
||||
if !FeatureFlag::CodexPlugin.is_enabled() {
|
||||
return false;
|
||||
}
|
||||
let Ok(codex_dir) = codex_home_dir() else {
|
||||
return false;
|
||||
};
|
||||
check_installed(&codex_dir)
|
||||
}
|
||||
|
||||
fn needs_update(&self) -> bool {
|
||||
if !FeatureFlag::CodexPlugin.is_enabled() {
|
||||
return false;
|
||||
}
|
||||
let Ok(codex_dir) = codex_home_dir() else {
|
||||
return false;
|
||||
};
|
||||
if codex_warp_marketplace_config(&codex_dir).is_some_and(|config| !config.is_git()) {
|
||||
return false;
|
||||
}
|
||||
plugin_needs_update(&codex_dir, PLUGIN_NAME, PLUGIN_KEY, MINIMUM_PLUGIN_VERSION)
|
||||
}
|
||||
|
||||
fn is_platform_plugin_installed(&self) -> bool {
|
||||
if !FeatureFlag::CodexPlugin.is_enabled() {
|
||||
return false;
|
||||
}
|
||||
let Ok(codex_dir) = codex_home_dir() else {
|
||||
return false;
|
||||
};
|
||||
check_platform_plugin_installed(&codex_dir)
|
||||
}
|
||||
|
||||
fn platform_plugin_needs_update(&self) -> bool {
|
||||
if !FeatureFlag::CodexPlugin.is_enabled() {
|
||||
return false;
|
||||
}
|
||||
let Ok(codex_dir) = codex_home_dir() else {
|
||||
return false;
|
||||
};
|
||||
if codex_warp_marketplace_config(&codex_dir).is_some_and(|config| !config.is_git()) {
|
||||
return false;
|
||||
}
|
||||
plugin_needs_update(
|
||||
&codex_dir,
|
||||
PLATFORM_PLUGIN_NAME,
|
||||
PLATFORM_PLUGIN_KEY,
|
||||
MINIMUM_PLATFORM_PLUGIN_VERSION,
|
||||
)
|
||||
}
|
||||
|
||||
fn has_local_marketplace_override(&self) -> bool {
|
||||
let Ok(codex_dir) = codex_home_dir() else {
|
||||
return false;
|
||||
};
|
||||
codex_warp_marketplace_config(&codex_dir).is_some_and(|config| !config.is_git())
|
||||
}
|
||||
|
||||
async fn install(&self) -> Result<(), PluginInstallError> {
|
||||
if !FeatureFlag::CodexPlugin.is_enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
log::info!("[PLUGIN_INSTALL] updating codex plugin");
|
||||
let mut log = String::new();
|
||||
ensure_codex_home_dir()?;
|
||||
self.ensure_marketplace(&mut log).await?;
|
||||
self.run_logged(&["plugin", "add", PLUGIN_KEY], &mut log)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update(&self) -> Result<(), PluginInstallError> {
|
||||
if !FeatureFlag::CodexPlugin.is_enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut log = String::new();
|
||||
ensure_codex_home_dir()?;
|
||||
self.run_logged(
|
||||
&["plugin", "marketplace", "upgrade", MARKETPLACE_NAME],
|
||||
&mut log,
|
||||
)
|
||||
.await?;
|
||||
self.run_logged(&["plugin", "add", PLUGIN_KEY], &mut log)
|
||||
.await?;
|
||||
|
||||
let still_outdated = codex_home_dir()
|
||||
.ok()
|
||||
.and_then(|dir| installed_version(&dir))
|
||||
.map(|v| compare_versions(&v, MINIMUM_PLUGIN_VERSION).is_lt())
|
||||
.unwrap_or(true);
|
||||
if still_outdated {
|
||||
log.push_str("Post-update version check: plugin is still outdated\n");
|
||||
return Err(PluginInstallError {
|
||||
message: "Plugin update did not take effect".to_owned(),
|
||||
log,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_success_message(&self) -> &'static str {
|
||||
"Warp plugin installed. Please restart Codex to activate."
|
||||
}
|
||||
|
||||
fn update_success_message(&self) -> &'static str {
|
||||
"Warp plugin updated. Please restart Codex to activate."
|
||||
}
|
||||
|
||||
fn install_instructions(&self) -> &'static PluginInstructions {
|
||||
&INSTALL_INSTRUCTIONS
|
||||
if FeatureFlag::CodexPlugin.is_enabled() {
|
||||
&PLUGIN_INSTALL_INSTRUCTIONS
|
||||
} else {
|
||||
&NATIVE_INSTALL_INSTRUCTIONS
|
||||
}
|
||||
}
|
||||
|
||||
fn update_instructions(&self) -> &'static PluginInstructions {
|
||||
&EMPTY_INSTRUCTIONS
|
||||
if FeatureFlag::CodexPlugin.is_enabled() {
|
||||
&PLUGIN_UPDATE_INSTRUCTIONS
|
||||
} else {
|
||||
&EMPTY_INSTRUCTIONS
|
||||
}
|
||||
}
|
||||
|
||||
fn supports_update(&self) -> bool {
|
||||
FeatureFlag::CodexPlugin.is_enabled()
|
||||
}
|
||||
|
||||
async fn install_platform_plugin(&self) -> Result<(), PluginInstallError> {
|
||||
if !FeatureFlag::CodexPlugin.is_enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut log = String::new();
|
||||
ensure_codex_home_dir()?;
|
||||
self.ensure_marketplace(&mut log).await?;
|
||||
self.run_logged(&["plugin", "add", PLATFORM_PLUGIN_KEY], &mut log)
|
||||
.await?;
|
||||
let updated = codex_home_dir()
|
||||
.ok()
|
||||
.map(|dir| platform_plugin_version_is_current(&dir))
|
||||
.unwrap_or(false);
|
||||
if !updated {
|
||||
log.push_str("Post-install version check: platform plugin is still outdated\n");
|
||||
return Err(PluginInstallError {
|
||||
message: "Platform plugin installation did not take effect".to_owned(),
|
||||
log,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_platform_plugin(&self) -> Result<(), PluginInstallError> {
|
||||
if !FeatureFlag::CodexPlugin.is_enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut log = String::new();
|
||||
ensure_codex_home_dir()?;
|
||||
self.run_logged(
|
||||
&["plugin", "marketplace", "upgrade", MARKETPLACE_NAME],
|
||||
&mut log,
|
||||
)
|
||||
.await?;
|
||||
self.run_logged(&["plugin", "add", PLATFORM_PLUGIN_KEY], &mut log)
|
||||
.await?;
|
||||
let updated = codex_home_dir()
|
||||
.ok()
|
||||
.map(|dir| platform_plugin_version_is_current(&dir))
|
||||
.unwrap_or(false);
|
||||
if !updated {
|
||||
log.push_str("Post-update version check: platform plugin is still outdated\n");
|
||||
return Err(PluginInstallError {
|
||||
message: "Platform plugin update did not take effect".to_owned(),
|
||||
log,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
static INSTALL_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| {
|
||||
static PLUGIN_INSTALL_INSTRUCTIONS: LazyLock<PluginInstructions> =
|
||||
LazyLock::new(|| PluginInstructions {
|
||||
title: "Install Warp Plugin for Codex",
|
||||
subtitle: "Run the following commands, then restart Codex.",
|
||||
steps: &[
|
||||
PluginInstructionStep {
|
||||
description: "Add the Warp plugin marketplace repository",
|
||||
command: "codex plugin marketplace add warpdotdev/codex-warp",
|
||||
executable: true,
|
||||
link: None,
|
||||
},
|
||||
PluginInstructionStep {
|
||||
description: "Install the Warp plugin",
|
||||
command: "codex plugin add warp@codex-warp",
|
||||
executable: true,
|
||||
link: None,
|
||||
},
|
||||
],
|
||||
post_install_notes: &["Restart Codex to activate the plugin."],
|
||||
});
|
||||
|
||||
static NATIVE_INSTALL_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| {
|
||||
PluginInstructions {
|
||||
title: "Enable Galaxy Notifications for Codex",
|
||||
subtitle: "Update Codex to the latest version, then enable in-focus notifications so Galaxy can display them while you work.",
|
||||
steps: &[
|
||||
PluginInstructionStep {
|
||||
description: "Update Codex to the latest version.",
|
||||
command: "",
|
||||
executable: false,
|
||||
link: Some("https://developers.openai.com/codex/cli#upgrade"),
|
||||
},
|
||||
PluginInstructionStep {
|
||||
description: "Set the notification condition to \"always\" in your Codex config. Open or create ~/.codex/config.toml and add:",
|
||||
command: "[tui]\nnotification_condition = \"always\"",
|
||||
executable: false,
|
||||
link: None,
|
||||
},
|
||||
],
|
||||
post_install_notes: &["Restart Codex to apply the changes."],
|
||||
}
|
||||
title: "Enable Warp Notifications for Codex",
|
||||
subtitle: "Update Codex to the latest version, then enable in-focus notifications so Warp can display them while you work.",
|
||||
steps: &[
|
||||
PluginInstructionStep {
|
||||
description: "Update Codex to the latest version.",
|
||||
command: "",
|
||||
executable: false,
|
||||
link: Some("https://developers.openai.com/codex/cli#upgrade"),
|
||||
},
|
||||
PluginInstructionStep {
|
||||
description: "Set the notification condition to \"always\" in your Codex config. Open or create ~/.codex/config.toml and add:",
|
||||
command: "[tui]\nnotification_condition = \"always\"",
|
||||
executable: false,
|
||||
link: None,
|
||||
},
|
||||
],
|
||||
post_install_notes: &["Restart Codex to apply the changes."],
|
||||
}
|
||||
});
|
||||
|
||||
static EMPTY_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| PluginInstructions {
|
||||
@@ -58,6 +321,172 @@ static EMPTY_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| Plugi
|
||||
post_install_notes: &[],
|
||||
});
|
||||
|
||||
static PLUGIN_UPDATE_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| {
|
||||
PluginInstructions {
|
||||
title: "Update Warp Plugin for Codex",
|
||||
subtitle: "Run the following commands, then restart Codex.",
|
||||
steps: &[
|
||||
PluginInstructionStep {
|
||||
description: "Upgrade the marketplace",
|
||||
command: "codex plugin marketplace upgrade codex-warp",
|
||||
executable: true,
|
||||
link: None,
|
||||
},
|
||||
PluginInstructionStep {
|
||||
description: "Reinstall the Warp plugin",
|
||||
command: "codex plugin add warp@codex-warp",
|
||||
executable: true,
|
||||
link: None,
|
||||
},
|
||||
],
|
||||
post_install_notes: &[
|
||||
"Restart Codex to activate the update.",
|
||||
"If this fails because codex-warp is not configured as a Git marketplace, remove and re-add the marketplace.",
|
||||
],
|
||||
}
|
||||
});
|
||||
|
||||
fn check_installed(codex_dir: &Path) -> bool {
|
||||
check_plugin_enabled(codex_dir, PLUGIN_KEY)
|
||||
}
|
||||
|
||||
fn check_platform_plugin_installed(codex_dir: &Path) -> bool {
|
||||
check_plugin_enabled(codex_dir, PLATFORM_PLUGIN_KEY)
|
||||
}
|
||||
|
||||
/// Whether `config.toml` marks the given plugin key as enabled.
|
||||
fn check_plugin_enabled(codex_dir: &Path, plugin_key: &str) -> bool {
|
||||
let config_path = codex_dir.join("config.toml");
|
||||
let Ok(contents) = fs::read_to_string(config_path) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(parsed) = contents.parse::<toml_edit::DocumentMut>() else {
|
||||
return false;
|
||||
};
|
||||
parsed
|
||||
.get("plugins")
|
||||
.and_then(|plugins| plugins.get(plugin_key))
|
||||
.and_then(|plugin| plugin.get("enabled"))
|
||||
.and_then(|enabled| enabled.as_bool())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Reads the latest cached Warp plugin version, if present.
|
||||
fn installed_version(codex_dir: &Path) -> Option<String> {
|
||||
installed_plugin_version(codex_dir, PLUGIN_NAME)
|
||||
}
|
||||
|
||||
/// Reads the latest cached orchestration plugin version, if present.
|
||||
fn installed_platform_plugin_version(codex_dir: &Path) -> Option<String> {
|
||||
installed_plugin_version(codex_dir, PLATFORM_PLUGIN_NAME)
|
||||
}
|
||||
|
||||
fn platform_plugin_version_is_current(codex_dir: &Path) -> bool {
|
||||
installed_platform_plugin_version(codex_dir)
|
||||
.map(|v| !compare_versions(&v, MINIMUM_PLATFORM_PLUGIN_VERSION).is_lt())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Reads the latest cached version for `plugin_name` from
|
||||
/// `plugins/cache/codex-warp/<plugin_name>/<version>/.codex-plugin/plugin.json`.
|
||||
fn installed_plugin_version(codex_dir: &Path, plugin_name: &str) -> Option<String> {
|
||||
let cache_dir = codex_dir
|
||||
.join("plugins")
|
||||
.join("cache")
|
||||
.join(MARKETPLACE_NAME)
|
||||
.join(plugin_name);
|
||||
let entries = fs::read_dir(cache_dir).ok()?;
|
||||
let mut latest: Option<String> = None;
|
||||
for entry in entries.flatten() {
|
||||
let manifest_path = entry.path().join(".codex-plugin").join("plugin.json");
|
||||
let Some(version) = plugin_manifest_version(manifest_path) else {
|
||||
continue;
|
||||
};
|
||||
if latest
|
||||
.as_deref()
|
||||
.map(|current| compare_versions(&version, current).is_gt())
|
||||
.unwrap_or(true)
|
||||
{
|
||||
latest = Some(version);
|
||||
}
|
||||
}
|
||||
latest
|
||||
}
|
||||
|
||||
fn plugin_manifest_version(manifest_path: impl AsRef<Path>) -> Option<String> {
|
||||
let contents = fs::read_to_string(manifest_path).ok()?;
|
||||
let parsed = serde_json::from_str::<Value>(&contents).ok()?;
|
||||
parsed
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
fn plugin_needs_update(
|
||||
codex_dir: &Path,
|
||||
plugin_name: &str,
|
||||
plugin_key: &str,
|
||||
minimum_version: &str,
|
||||
) -> bool {
|
||||
if !check_plugin_enabled(codex_dir, plugin_key) {
|
||||
return false;
|
||||
}
|
||||
match installed_plugin_version(codex_dir, plugin_name) {
|
||||
Some(v) => compare_versions(&v, minimum_version).is_lt(),
|
||||
// No version field means very old plugin.
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
struct CodexWarpMarketplaceConfig {
|
||||
source_type: Option<String>,
|
||||
}
|
||||
|
||||
impl CodexWarpMarketplaceConfig {
|
||||
fn is_git(&self) -> bool {
|
||||
self.source_type.as_deref() == Some("git")
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_warp_marketplace_config(codex_dir: &Path) -> Option<CodexWarpMarketplaceConfig> {
|
||||
let config_path = codex_dir.join("config.toml");
|
||||
let contents = fs::read_to_string(config_path).ok()?;
|
||||
let parsed = contents.parse::<toml_edit::DocumentMut>().ok()?;
|
||||
let marketplace = parsed.get("marketplaces")?.get(MARKETPLACE_NAME)?;
|
||||
Some(CodexWarpMarketplaceConfig {
|
||||
source_type: marketplace
|
||||
.get("source_type")
|
||||
.and_then(|source_type| source_type.as_str())
|
||||
.map(str::to_owned),
|
||||
})
|
||||
}
|
||||
|
||||
/// Checks `CODEX_HOME` first, falls back to `~/.codex`.
|
||||
fn codex_home_dir() -> io::Result<PathBuf> {
|
||||
if let Ok(codex_home) = env::var(CODEX_HOME_ENV) {
|
||||
if !codex_home.is_empty() {
|
||||
return Ok(PathBuf::from(codex_home));
|
||||
}
|
||||
}
|
||||
dirs::home_dir()
|
||||
.map(|home| home.join(CODEX_CONFIG_DIR))
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"could not determine home directory",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates the resolved Codex home directory if it does not yet exist.
|
||||
/// The Codex CLI expects `CODEX_HOME` to exist before running plugin commands, we need
|
||||
/// this for self-hosted direct backend workers.
|
||||
fn ensure_codex_home_dir() -> io::Result<PathBuf> {
|
||||
let dir = codex_home_dir()?;
|
||||
fs::create_dir_all(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "codex_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,19 +1,483 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use super::CodexPluginManager;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::terminal::cli_agent_sessions::plugin_manager::CliAgentPluginManager;
|
||||
|
||||
#[test]
|
||||
fn can_auto_install_is_false() {
|
||||
assert!(!CodexPluginManager.can_auto_install());
|
||||
fn can_auto_install_is_true() {
|
||||
let _guard = FeatureFlag::CodexPlugin.override_enabled(true);
|
||||
assert!(CodexPluginManager::new(None, None, None).can_auto_install());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_support_update() {
|
||||
assert!(!CodexPluginManager.supports_update());
|
||||
fn can_auto_install_is_false_without_codex_plugin() {
|
||||
let _guard = FeatureFlag::CodexPlugin.override_enabled(false);
|
||||
assert!(!CodexPluginManager::new(None, None, None).can_auto_install());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_instructions_has_steps() {
|
||||
let instructions = CodexPluginManager.install_instructions();
|
||||
assert!(!instructions.steps.is_empty());
|
||||
fn install_instructions_are_native_without_codex_plugin() {
|
||||
let _guard = FeatureFlag::CodexPlugin.override_enabled(false);
|
||||
let instructions = CodexPluginManager::new(None, None, None).install_instructions();
|
||||
assert_eq!(instructions.title, "Enable Warp Notifications for Codex");
|
||||
assert_eq!(
|
||||
instructions.steps[1].command,
|
||||
"[tui]\nnotification_condition = \"always\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_update() {
|
||||
let _guard = FeatureFlag::CodexPlugin.override_enabled(true);
|
||||
assert!(CodexPluginManager::new(None, None, None).supports_update());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_support_update_without_codex_plugin() {
|
||||
let _guard = FeatureFlag::CodexPlugin.override_enabled(false);
|
||||
assert!(!CodexPluginManager::new(None, None, None).supports_update());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimum_version() {
|
||||
let _guard = FeatureFlag::CodexPlugin.override_enabled(true);
|
||||
assert_eq!(
|
||||
CodexPluginManager::new(None, None, None).minimum_plugin_version(),
|
||||
"0.4.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimum_version_is_zero_without_codex_plugin() {
|
||||
let _guard = FeatureFlag::CodexPlugin.override_enabled(false);
|
||||
assert_eq!(
|
||||
CodexPluginManager::new(None, None, None).minimum_plugin_version(),
|
||||
"0.0.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_instructions_has_marketplace_and_plugin_add_steps() {
|
||||
let _guard = FeatureFlag::CodexPlugin.override_enabled(true);
|
||||
let instructions = CodexPluginManager::new(None, None, None).install_instructions();
|
||||
assert_eq!(
|
||||
instructions.steps[0].command,
|
||||
"codex plugin marketplace add warpdotdev/codex-warp"
|
||||
);
|
||||
assert_eq!(
|
||||
instructions.steps[1].command,
|
||||
"codex plugin add warp@codex-warp"
|
||||
);
|
||||
assert_eq!(instructions.steps.len(), 2);
|
||||
assert!(!instructions.title.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_instructions_has_marketplace_and_plugin_add_steps() {
|
||||
let _guard = FeatureFlag::CodexPlugin.override_enabled(true);
|
||||
let instructions = CodexPluginManager::new(None, None, None).update_instructions();
|
||||
assert_eq!(
|
||||
instructions.steps[0].command,
|
||||
"codex plugin marketplace upgrade codex-warp"
|
||||
);
|
||||
assert_eq!(
|
||||
instructions.steps[1].command,
|
||||
"codex plugin add warp@codex-warp"
|
||||
);
|
||||
assert_eq!(instructions.steps.len(), 2);
|
||||
assert!(!instructions.title.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_instructions_are_empty_without_codex_plugin() {
|
||||
let _guard = FeatureFlag::CodexPlugin.override_enabled(false);
|
||||
let instructions = CodexPluginManager::new(None, None, None).update_instructions();
|
||||
assert!(instructions.steps.is_empty());
|
||||
assert!(instructions.title.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_when_plugin_enabled_in_config() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_plugin_config(dir.path(), super::PLUGIN_KEY, true);
|
||||
|
||||
assert!(super::check_installed(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_installed_when_plugin_disabled_in_config() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_plugin_config(dir.path(), super::PLUGIN_KEY, false);
|
||||
|
||||
assert!(!super::check_installed(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_installed_when_only_marketplace_present() {
|
||||
// Marketplace cloned but the plugin was never enabled.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_marketplace_config(dir.path(), "git");
|
||||
|
||||
assert!(!super::check_installed(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_plugin_installed_when_enabled_in_config() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_plugin_config(dir.path(), super::PLATFORM_PLUGIN_KEY, true);
|
||||
|
||||
assert!(super::check_platform_plugin_installed(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_plugin_not_installed_when_disabled_in_config() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_plugin_config(dir.path(), super::PLATFORM_PLUGIN_KEY, false);
|
||||
|
||||
assert!(!super::check_platform_plugin_installed(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_installed_when_config_missing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(!super::check_installed(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_installed_when_config_invalid() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(dir.path().join("config.toml"), "not toml").unwrap();
|
||||
|
||||
assert!(!super::check_installed(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_version_reads_cache_manifest_version() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_cache_manifest(dir.path(), super::PLUGIN_NAME, "0.4.0");
|
||||
|
||||
assert_eq!(
|
||||
super::installed_version(dir.path()).as_deref(),
|
||||
Some("0.4.0")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_platform_plugin_version_reads_cache_manifest_version() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_cache_manifest(dir.path(), super::PLATFORM_PLUGIN_NAME, "0.4.0");
|
||||
|
||||
assert_eq!(
|
||||
super::installed_platform_plugin_version(dir.path()).as_deref(),
|
||||
Some("0.4.0")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_version_picks_latest_cached() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_cache_manifest(dir.path(), super::PLUGIN_NAME, "0.3.0");
|
||||
write_cache_manifest(dir.path(), super::PLUGIN_NAME, "0.5.0");
|
||||
|
||||
assert_eq!(
|
||||
super::installed_version(dir.path()).as_deref(),
|
||||
Some("0.5.0")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_version_returns_none_when_cache_missing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(super::installed_version(dir.path()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_version_returns_none_when_cache_manifest_has_no_version() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_cache_manifest_without_version(dir.path(), super::PLUGIN_NAME, "0.4.0");
|
||||
|
||||
assert_eq!(super::installed_version(dir.path()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_plugin_version_is_current_when_cache_current() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_cache_manifest(dir.path(), super::PLATFORM_PLUGIN_NAME, "0.4.0");
|
||||
|
||||
assert!(super::platform_plugin_version_is_current(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_plugin_version_is_not_current_when_cache_outdated() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_cache_manifest(dir.path(), super::PLATFORM_PLUGIN_NAME, "0.2.0");
|
||||
|
||||
assert!(!super::platform_plugin_version_is_current(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_update_true_when_enabled_and_version_outdated() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_plugin_config(dir.path(), super::PLUGIN_KEY, true);
|
||||
write_cache_manifest(dir.path(), super::PLUGIN_NAME, "0.2.0");
|
||||
|
||||
assert!(super::plugin_needs_update(
|
||||
dir.path(),
|
||||
super::PLUGIN_NAME,
|
||||
super::PLUGIN_KEY,
|
||||
"0.4.0"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_update_false_when_enabled_and_version_current() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_plugin_config(dir.path(), super::PLUGIN_KEY, true);
|
||||
write_cache_manifest(dir.path(), super::PLUGIN_NAME, "0.4.0");
|
||||
|
||||
assert!(!super::plugin_needs_update(
|
||||
dir.path(),
|
||||
super::PLUGIN_NAME,
|
||||
super::PLUGIN_KEY,
|
||||
"0.4.0"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_update_false_when_not_enabled() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_cache_manifest(dir.path(), super::PLUGIN_NAME, "0.2.0");
|
||||
|
||||
assert!(!super::plugin_needs_update(
|
||||
dir.path(),
|
||||
super::PLUGIN_NAME,
|
||||
super::PLUGIN_KEY,
|
||||
"0.4.0"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_update_true_when_enabled_without_cached_version() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_plugin_config(dir.path(), super::PLUGIN_KEY, true);
|
||||
|
||||
assert!(super::plugin_needs_update(
|
||||
dir.path(),
|
||||
super::PLUGIN_NAME,
|
||||
super::PLUGIN_KEY,
|
||||
"0.4.0"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_plugin_needs_update_true_when_enabled_and_outdated() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_plugin_config(dir.path(), super::PLATFORM_PLUGIN_KEY, true);
|
||||
write_cache_manifest(dir.path(), super::PLATFORM_PLUGIN_NAME, "0.2.0");
|
||||
|
||||
assert!(super::plugin_needs_update(
|
||||
dir.path(),
|
||||
super::PLATFORM_PLUGIN_NAME,
|
||||
super::PLATFORM_PLUGIN_KEY,
|
||||
super::MINIMUM_PLATFORM_PLUGIN_VERSION
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_plugin_needs_update_false_when_current() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_plugin_config(dir.path(), super::PLATFORM_PLUGIN_KEY, true);
|
||||
write_cache_manifest(dir.path(), super::PLATFORM_PLUGIN_NAME, "0.4.0");
|
||||
|
||||
assert!(!super::plugin_needs_update(
|
||||
dir.path(),
|
||||
super::PLATFORM_PLUGIN_NAME,
|
||||
super::PLATFORM_PLUGIN_KEY,
|
||||
super::MINIMUM_PLATFORM_PLUGIN_VERSION
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn is_not_installed_via_trait_without_codex_plugin() {
|
||||
let _guard = FeatureFlag::CodexPlugin.override_enabled(false);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_plugin_config(dir.path(), super::PLUGIN_KEY, true);
|
||||
|
||||
std::env::set_var("CODEX_HOME", dir.path());
|
||||
let result = CodexPluginManager::new(None, None, None).is_installed();
|
||||
std::env::remove_var("CODEX_HOME");
|
||||
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn is_installed_via_trait_with_codex_home_env() {
|
||||
let _guard = FeatureFlag::CodexPlugin.override_enabled(true);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_plugin_config(dir.path(), super::PLUGIN_KEY, true);
|
||||
|
||||
std::env::set_var("CODEX_HOME", dir.path());
|
||||
let result = CodexPluginManager::new(None, None, None).is_installed();
|
||||
std::env::remove_var("CODEX_HOME");
|
||||
|
||||
assert!(result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn is_platform_plugin_installed_via_trait_with_codex_home_env() {
|
||||
let _guard = FeatureFlag::CodexPlugin.override_enabled(true);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_plugin_config(dir.path(), super::PLATFORM_PLUGIN_KEY, true);
|
||||
|
||||
std::env::set_var("CODEX_HOME", dir.path());
|
||||
let result = CodexPluginManager::new(None, None, None).is_platform_plugin_installed();
|
||||
std::env::remove_var("CODEX_HOME");
|
||||
|
||||
assert!(result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn is_platform_plugin_not_installed_via_trait_without_codex_plugin() {
|
||||
let _guard = FeatureFlag::CodexPlugin.override_enabled(false);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_plugin_config(dir.path(), super::PLATFORM_PLUGIN_KEY, true);
|
||||
|
||||
std::env::set_var("CODEX_HOME", dir.path());
|
||||
let result = CodexPluginManager::new(None, None, None).is_platform_plugin_installed();
|
||||
std::env::remove_var("CODEX_HOME");
|
||||
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn needs_update_via_trait_with_codex_home_env() {
|
||||
let _guard = FeatureFlag::CodexPlugin.override_enabled(true);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_plugin_config(dir.path(), super::PLUGIN_KEY, true);
|
||||
write_cache_manifest(dir.path(), super::PLUGIN_NAME, "0.2.0");
|
||||
|
||||
std::env::set_var("CODEX_HOME", dir.path());
|
||||
let result = CodexPluginManager::new(None, None, None).needs_update();
|
||||
std::env::remove_var("CODEX_HOME");
|
||||
|
||||
assert!(result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn does_not_need_update_via_trait_when_version_current() {
|
||||
let _guard = FeatureFlag::CodexPlugin.override_enabled(true);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_plugin_config(dir.path(), super::PLUGIN_KEY, true);
|
||||
write_cache_manifest(dir.path(), super::PLUGIN_NAME, "0.4.0");
|
||||
|
||||
std::env::set_var("CODEX_HOME", dir.path());
|
||||
let result = CodexPluginManager::new(None, None, None).needs_update();
|
||||
std::env::remove_var("CODEX_HOME");
|
||||
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn does_not_need_update_without_codex_plugin() {
|
||||
let _guard = FeatureFlag::CodexPlugin.override_enabled(false);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_plugin_config(dir.path(), super::PLUGIN_KEY, true);
|
||||
write_cache_manifest(dir.path(), super::PLUGIN_NAME, "0.2.0");
|
||||
|
||||
std::env::set_var("CODEX_HOME", dir.path());
|
||||
let result = CodexPluginManager::new(None, None, None).needs_update();
|
||||
std::env::remove_var("CODEX_HOME");
|
||||
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn does_not_need_update_when_not_enabled() {
|
||||
let _guard = FeatureFlag::CodexPlugin.override_enabled(true);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
std::env::set_var("CODEX_HOME", dir.path());
|
||||
let result = CodexPluginManager::new(None, None, None).needs_update();
|
||||
std::env::remove_var("CODEX_HOME");
|
||||
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn does_not_need_update_for_non_git_marketplace_override() {
|
||||
let _guard = FeatureFlag::CodexPlugin.override_enabled(true);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_marketplace_config(dir.path(), "directory");
|
||||
|
||||
std::env::set_var("CODEX_HOME", dir.path());
|
||||
let result = CodexPluginManager::new(None, None, None).needs_update();
|
||||
let has_override = CodexPluginManager::new(None, None, None).has_local_marketplace_override();
|
||||
std::env::remove_var("CODEX_HOME");
|
||||
|
||||
assert!(!result);
|
||||
assert!(has_override);
|
||||
}
|
||||
|
||||
fn write_plugin_config(dir: &Path, plugin_key: &str, enabled: bool) {
|
||||
fs::write(
|
||||
dir.join("config.toml"),
|
||||
format!("[plugins.\"{plugin_key}\"]\nenabled = {enabled}\n"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn write_marketplace_config(dir: &Path, source_type: &str) {
|
||||
fs::write(
|
||||
dir.join("config.toml"),
|
||||
format!(
|
||||
"[marketplaces.codex-warp]\nsource_type = \"{source_type}\"\nsource = \"/tmp/codex-warp\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn write_cache_manifest(dir: &Path, plugin_name: &str, version: &str) {
|
||||
write_cache_manifest_json(
|
||||
dir,
|
||||
plugin_name,
|
||||
version,
|
||||
serde_json::json!({ "name": plugin_name, "version": version }),
|
||||
);
|
||||
}
|
||||
|
||||
fn write_cache_manifest_without_version(dir: &Path, plugin_name: &str, version_dir: &str) {
|
||||
write_cache_manifest_json(
|
||||
dir,
|
||||
plugin_name,
|
||||
version_dir,
|
||||
serde_json::json!({ "name": plugin_name }),
|
||||
);
|
||||
}
|
||||
|
||||
fn write_cache_manifest_json(
|
||||
dir: &Path,
|
||||
plugin_name: &str,
|
||||
version_dir: &str,
|
||||
manifest: serde_json::Value,
|
||||
) {
|
||||
let manifest_dir = dir
|
||||
.join("plugins")
|
||||
.join("cache")
|
||||
.join("codex-warp")
|
||||
.join(plugin_name)
|
||||
.join(version_dir)
|
||||
.join(".codex-plugin");
|
||||
fs::create_dir_all(&manifest_dir).unwrap();
|
||||
fs::write(manifest_dir.join("plugin.json"), manifest.to_string()).unwrap();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::LazyLock;
|
||||
use std::{fs, io};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -5,20 +5,19 @@ pub(crate) mod opencode;
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::{fmt, io};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use claude::ClaudeCodePluginManager;
|
||||
use codex::CodexPluginManager;
|
||||
use gemini::GeminiPluginManager;
|
||||
use opencode::OpenCodePluginManager;
|
||||
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::terminal::model::session::LocalCommandExecutor;
|
||||
use crate::terminal::shell::ShellType;
|
||||
use crate::terminal::CLIAgent;
|
||||
use claude::ClaudeCodePluginManager;
|
||||
use codex::CodexPluginManager;
|
||||
use gemini::GeminiPluginManager;
|
||||
use opencode::OpenCodePluginManager;
|
||||
|
||||
/// Distinguishes whether the plugin instructions modal should show install or update steps.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -52,6 +51,7 @@ pub(crate) struct PluginInstructions {
|
||||
/// Error returned when plugin installation fails.
|
||||
/// Carries both a short user-facing message (for the toast) and a detailed
|
||||
/// command log (for the log file the user can inspect).
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct PluginInstallError {
|
||||
/// Short description shown in the toast notification.
|
||||
pub message: String,
|
||||
@@ -65,6 +65,8 @@ impl fmt::Display for PluginInstallError {
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PluginInstallError {}
|
||||
|
||||
impl From<io::Error> for PluginInstallError {
|
||||
fn from(err: io::Error) -> Self {
|
||||
let msg = err.to_string();
|
||||
@@ -160,6 +162,24 @@ pub(crate) trait CliAgentPluginManager: Send + Sync {
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether this agent's Oz platform plugin is already installed.
|
||||
/// Default returns `true` because most agents do not have a platform plugin.
|
||||
fn is_platform_plugin_installed(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// Whether this agent's Oz platform plugin is below the minimum required version.
|
||||
/// Default returns `false` because most agents do not have a platform plugin.
|
||||
fn platform_plugin_needs_update(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether the agent's plugin marketplace is currently overridden to a
|
||||
/// local filesystem path. This is used by local test flows to avoid
|
||||
/// clobbering a developer's marketplace override while still preserving
|
||||
/// normal install/update behavior in staging and production.
|
||||
fn has_local_marketplace_override(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// Install the Warp notification plugin.
|
||||
/// Default returns an error — only agents with `can_auto_install() == true` should override.
|
||||
async fn install(&self) -> Result<(), PluginInstallError> {
|
||||
@@ -207,6 +227,13 @@ pub(crate) trait CliAgentPluginManager: Send + Sync {
|
||||
async fn install_platform_plugin(&self) -> Result<(), PluginInstallError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update the Oz platform plugin for this CLI agent, if one exists.
|
||||
/// Default reuses the install path because most agents do not have a
|
||||
/// platform plugin or need distinct update behavior.
|
||||
async fn update_platform_plugin(&self) -> Result<(), PluginInstallError> {
|
||||
self.install_platform_plugin().await
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a plugin manager for the given CLI agent, or `None` if the agent
|
||||
@@ -242,7 +269,11 @@ pub(crate) fn plugin_manager_for_with_shell(
|
||||
if FeatureFlag::CodexNotifications.is_enabled()
|
||||
&& FeatureFlag::HOANotifications.is_enabled() =>
|
||||
{
|
||||
Some(Box::new(CodexPluginManager))
|
||||
Some(Box::new(CodexPluginManager::new(
|
||||
shell_path,
|
||||
shell_type,
|
||||
path_env_var,
|
||||
)))
|
||||
}
|
||||
CLIAgent::Gemini
|
||||
if FeatureFlag::GeminiNotifications.is_enabled()
|
||||
@@ -263,6 +294,10 @@ pub(crate) fn plugin_manager_for_with_shell(
|
||||
| CLIAgent::Pi
|
||||
| CLIAgent::Auggie
|
||||
| CLIAgent::CursorCli
|
||||
| CLIAgent::Hermes
|
||||
| CLIAgent::Goose
|
||||
| CLIAgent::Vibe
|
||||
| CLIAgent::Antigravity
|
||||
| CLIAgent::Unknown => None,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user