Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
@@ -0,0 +1,264 @@
use std::collections::HashMap;
use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use async_trait::async_trait;
use serde_json::Value;
use super::{
compare_versions, run_cli_command_logged, CliAgentPluginManager, PluginInstallError,
PluginInstructionStep, PluginInstructions,
};
use crate::terminal::model::session::LocalCommandExecutor;
use crate::terminal::shell::ShellType;
const PLUGIN_KEY: &str = "warp@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";
pub(super) struct ClaudeCodePluginManager {
executor: LocalCommandExecutor,
path_env_var: Option<String>,
}
impl ClaudeCodePluginManager {
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("claude", args, &self.executor, env_vars, log).await
}
}
#[async_trait]
impl CliAgentPluginManager for ClaudeCodePluginManager {
fn minimum_plugin_version(&self) -> &'static str {
MINIMUM_PLUGIN_VERSION
}
fn can_auto_install(&self) -> bool {
true
}
fn is_installed(&self) -> bool {
let Ok(claude_dir) = claude_home_dir() else {
return false;
};
check_installed(&claude_dir)
}
/// Runs `claude plugin` CLI commands via the session shell.
async fn install(&self) -> Result<(), PluginInstallError> {
let mut log = String::new();
self.run_logged(
&["plugin", "marketplace", "add", MARKETPLACE_REPO],
&mut log,
)
.await?;
self.run_logged(&["plugin", "install", PLUGIN_KEY], &mut log)
.await?;
Ok(())
}
async fn update(&self) -> Result<(), PluginInstallError> {
let mut log = String::new();
// Remove/re-add the marketplace to ensure the local clone is fresh, then
// reinstall the plugin.
// We use `plugin install` (not `plugin update`) because `marketplace
// remove` unlinks the plugin, so `plugin update` would fail with
// "Plugin is not installed".
let _ = self
.run_logged(
&["plugin", "marketplace", "remove", MARKETPLACE_NAME],
&mut log,
)
.await;
self.run_logged(
&["plugin", "marketplace", "add", MARKETPLACE_REPO],
&mut log,
)
.await?;
self.run_logged(&["plugin", "install", PLUGIN_KEY], &mut log)
.await?;
// Sanity check: verify the on-disk version actually changed.
let still_outdated = claude_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 run /reload-plugins to activate."
}
fn update_success_message(&self) -> &'static str {
"Warp plugin updated. Please run /reload-plugins to activate."
}
fn install_instructions(&self) -> &'static PluginInstructions {
&INSTALL_INSTRUCTIONS
}
fn update_instructions(&self) -> &'static PluginInstructions {
&UPDATE_INSTRUCTIONS
}
fn needs_update(&self) -> bool {
let Ok(claude_dir) = claude_home_dir() else {
return false;
};
match installed_version(&claude_dir) {
Some(v) => compare_versions(&v, MINIMUM_PLUGIN_VERSION).is_lt(),
// No version field means very old plugin.
None => check_installed(&claude_dir),
}
}
async fn install_platform_plugin(&self) -> Result<(), PluginInstallError> {
let mut log = String::new();
self.run_logged(
&["plugin", "marketplace", "add", PLATFORM_MARKETPLACE_REPO],
&mut log,
)
.await?;
self.run_logged(&["plugin", "install", PLATFORM_PLUGIN_KEY], &mut log)
.await?;
Ok(())
}
}
static INSTALL_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| {
PluginInstructions {
title: "Install Warp Plugin for Claude Code",
subtitle: "Ensure that jq is installed on your machine. Then, run these commands.",
steps: &[
PluginInstructionStep {
description: "Add the Warp plugin marketplace repository",
command: "claude plugin marketplace add warpdotdev/claude-code-warp",
executable: true,
link: None,
},
PluginInstructionStep {
description: "Install the Warp plugin",
command: "claude plugin install warp@claude-code-warp",
executable: true,
link: None,
},
],
post_install_notes: &[
"Restart Claude Code to activate the plugin.",
"There are some known issues with Claude Code's plugin system. \
If the plugin is not found after step 1, you can try manually adding an \"extraKnownMarketplaces\" entry to ~/.claude/settings.json.",
],
}
});
static UPDATE_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| PluginInstructions {
title: "Update Warp Plugin for Claude Code",
subtitle: "Run the following commands.",
steps: &[
PluginInstructionStep {
description: "Remove the existing marketplace (if present)",
command: "claude plugin marketplace remove claude-code-warp",
executable: true,
link: None,
},
PluginInstructionStep {
description: "Re-add the marketplace",
command: "claude plugin marketplace add warpdotdev/claude-code-warp",
executable: true,
link: None,
},
PluginInstructionStep {
description: "Install the latest plugin version",
command: "claude plugin install warp@claude-code-warp",
executable: true,
link: None,
},
],
post_install_notes: &["Restart Claude Code to activate the update."],
});
fn check_installed(claude_dir: &Path) -> bool {
let plugins_path = claude_dir.join("plugins").join("installed_plugins.json");
let Ok(contents) = fs::read_to_string(plugins_path) else {
return false;
};
let Ok(parsed) = serde_json::from_str::<Value>(&contents) else {
return false;
};
parsed
.get("plugins")
.and_then(|p| p.get(PLUGIN_KEY))
.and_then(|v| v.as_array())
.map(|arr| !arr.is_empty())
.unwrap_or(false)
}
/// Reads the installed version string for the Warp plugin, if present.
fn installed_version(claude_dir: &Path) -> 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)?
.as_array()?
.first()?
.get("version")?
.as_str()
.map(|s| s.to_owned())
}
/// Checks `CLAUDE_HOME` env var first, falls back to `~/.claude`.
fn claude_home_dir() -> io::Result<PathBuf> {
if let Ok(claude_home) = env::var("CLAUDE_HOME") {
return Ok(PathBuf::from(claude_home));
}
dirs::home_dir()
.map(|home| home.join(".claude"))
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"could not determine home directory",
)
})
}
#[cfg(test)]
#[path = "claude_tests.rs"]
mod tests;
@@ -0,0 +1,193 @@
use std::fs;
use super::{check_installed, installed_version, ClaudeCodePluginManager, CliAgentPluginManager};
#[test]
fn installed_when_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_installed(dir.path()));
}
#[test]
fn not_installed_when_plugin_key_absent() {
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": {
"some-other-plugin": [{"version": "1.0.0"}]
}
});
fs::write(
plugins_dir.join("installed_plugins.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
assert!(!check_installed(dir.path()));
}
#[test]
fn not_installed_when_plugin_array_empty() {
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": []
}
});
fs::write(
plugins_dir.join("installed_plugins.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
assert!(!check_installed(dir.path()));
}
#[test]
fn not_installed_when_file_missing() {
let dir = tempfile::tempdir().unwrap();
assert!(!check_installed(dir.path()));
}
#[test]
fn not_installed_when_json_invalid() {
let dir = tempfile::tempdir().unwrap();
let plugins_dir = dir.path().join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
fs::write(plugins_dir.join("installed_plugins.json"), "not json").unwrap();
assert!(!check_installed(dir.path()));
}
#[test]
fn not_installed_when_plugins_key_missing() {
let dir = tempfile::tempdir().unwrap();
let plugins_dir = dir.path().join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
let json = serde_json::json!({"other_key": "value"});
fs::write(
plugins_dir.join("installed_plugins.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
assert!(!check_installed(dir.path()));
}
/// Tests `ClaudeCodePluginManager::is_installed` end-to-end by pointing
/// `CLAUDE_HOME` at a temp directory with a valid installed_plugins.json.
#[test]
#[serial_test::serial]
fn is_installed_via_trait_with_claude_home_env() {
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();
std::env::set_var("CLAUDE_HOME", dir.path());
let result = ClaudeCodePluginManager::new(None, None, None).is_installed();
std::env::remove_var("CLAUDE_HOME");
assert!(result);
}
#[test]
#[serial_test::serial]
fn not_installed_via_trait_when_claude_home_empty() {
let dir = tempfile::tempdir().unwrap();
std::env::set_var("CLAUDE_HOME", dir.path());
let result = ClaudeCodePluginManager::new(None, None, None).is_installed();
std::env::remove_var("CLAUDE_HOME");
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();
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.5.0"}]
}
});
fs::write(
plugins_dir.join("installed_plugins.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
assert_eq!(installed_version(dir.path()).as_deref(), Some("1.5.0"));
}
#[test]
fn installed_version_returns_none_when_no_version_field() {
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": [{"scope": "user"}]
}
});
fs::write(
plugins_dir.join("installed_plugins.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
assert_eq!(installed_version(dir.path()), None);
}
#[test]
fn installed_version_returns_none_when_file_missing() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(installed_version(dir.path()), None);
}
@@ -0,0 +1,63 @@
use std::sync::LazyLock;
use async_trait::async_trait;
use super::{CliAgentPluginManager, PluginInstructionStep, PluginInstructions};
pub(super) struct CodexPluginManager;
#[async_trait]
impl CliAgentPluginManager for CodexPluginManager {
fn minimum_plugin_version(&self) -> &'static str {
"0.0.0"
}
fn can_auto_install(&self) -> bool {
false
}
fn supports_update(&self) -> bool {
false
}
fn install_instructions(&self) -> &'static PluginInstructions {
&INSTALL_INSTRUCTIONS
}
fn update_instructions(&self) -> &'static PluginInstructions {
&EMPTY_INSTRUCTIONS
}
}
static INSTALL_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| {
PluginInstructions {
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 {
title: "",
subtitle: "",
steps: &[],
post_install_notes: &[],
});
#[cfg(test)]
#[path = "codex_tests.rs"]
mod tests;
@@ -0,0 +1,19 @@
use super::CodexPluginManager;
use crate::terminal::cli_agent_sessions::plugin_manager::CliAgentPluginManager;
#[test]
fn can_auto_install_is_false() {
assert!(!CodexPluginManager.can_auto_install());
}
#[test]
fn does_not_support_update() {
assert!(!CodexPluginManager.supports_update());
}
#[test]
fn install_instructions_has_steps() {
let instructions = CodexPluginManager.install_instructions();
assert!(!instructions.steps.is_empty());
assert!(!instructions.title.is_empty());
}
@@ -0,0 +1,185 @@
use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use async_trait::async_trait;
use serde_json::Value;
use super::{
compare_versions, run_cli_command_logged, CliAgentPluginManager, PluginInstallError,
PluginInstructionStep, PluginInstructions,
};
use crate::terminal::model::session::LocalCommandExecutor;
use crate::terminal::shell::ShellType;
const EXTENSION_REPO: &str = "https://github.com/warpdotdev/gemini-cli-warp";
const EXTENSION_NAME: &str = "gemini-warp";
// Keep in sync with the plugin version in warpdotdev/gemini-warp.
const MINIMUM_PLUGIN_VERSION: &str = "1.0.0";
pub(super) struct GeminiPluginManager {
executor: LocalCommandExecutor,
path_env_var: Option<String>,
}
impl GeminiPluginManager {
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("gemini", args, &self.executor, env_vars, log).await
}
}
#[async_trait]
impl CliAgentPluginManager for GeminiPluginManager {
fn minimum_plugin_version(&self) -> &'static str {
MINIMUM_PLUGIN_VERSION
}
fn can_auto_install(&self) -> bool {
true
}
fn is_installed(&self) -> bool {
let Ok(extensions_dir) = gemini_extensions_dir() else {
return false;
};
check_installed(&extensions_dir)
}
fn needs_update(&self) -> bool {
let Ok(extensions_dir) = gemini_extensions_dir() else {
return false;
};
match installed_version(&extensions_dir) {
Some(v) => compare_versions(&v, MINIMUM_PLUGIN_VERSION).is_lt(),
// No version field means very old or malformed extension.
None => check_installed(&extensions_dir),
}
}
async fn install(&self) -> Result<(), PluginInstallError> {
let mut log = String::new();
self.run_logged(
&["extensions", "install", EXTENSION_REPO, "--consent"],
&mut log,
)
.await?;
Ok(())
}
async fn update(&self) -> Result<(), PluginInstallError> {
let mut log = String::new();
self.run_logged(&["extensions", "update", EXTENSION_NAME], &mut log)
.await?;
// Sanity check: verify the on-disk version actually changed.
let still_outdated = gemini_extensions_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 Gemini CLI to activate."
}
fn update_success_message(&self) -> &'static str {
"Warp plugin updated. Please restart Gemini CLI to activate."
}
fn install_instructions(&self) -> &'static PluginInstructions {
&INSTALL_INSTRUCTIONS
}
fn update_instructions(&self) -> &'static PluginInstructions {
&UPDATE_INSTRUCTIONS
}
}
static INSTALL_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| PluginInstructions {
title: "Install Warp Plugin for Gemini CLI",
subtitle: "Run the following command, then restart Gemini CLI.",
steps: &[PluginInstructionStep {
description: "Install the Warp extension",
command:
"gemini extensions install https://github.com/warpdotdev/gemini-cli-warp --consent",
executable: true,
link: None,
}],
post_install_notes: &["Restart Gemini CLI to activate the plugin."],
});
static UPDATE_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| PluginInstructions {
title: "Update Warp Plugin for Gemini CLI",
subtitle: "Run the following command, then restart Gemini CLI.",
steps: &[PluginInstructionStep {
description: "Update the Warp extension",
command: "gemini extensions update gemini-warp",
executable: true,
link: None,
}],
post_install_notes: &["Restart Gemini CLI to activate the update."],
});
fn check_installed(extensions_dir: &Path) -> bool {
let manifest_path = extensions_dir
.join(EXTENSION_NAME)
.join("gemini-extension.json");
let Ok(contents) = fs::read_to_string(manifest_path) else {
return false;
};
serde_json::from_str::<Value>(&contents).is_ok()
}
/// Reads the installed version string for the Warp extension, if present.
fn installed_version(extensions_dir: &Path) -> Option<String> {
let manifest_path = extensions_dir
.join(EXTENSION_NAME)
.join("gemini-extension.json");
let contents = fs::read_to_string(manifest_path).ok()?;
let parsed: Value = serde_json::from_str(&contents).ok()?;
parsed.get("version")?.as_str().map(|s| s.to_owned())
}
/// Returns the path to `~/.gemini/extensions`.
fn gemini_extensions_dir() -> io::Result<PathBuf> {
dirs::home_dir()
.map(|home| home.join(".gemini").join("extensions"))
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"could not determine home directory",
)
})
}
#[cfg(test)]
#[path = "gemini_tests.rs"]
mod tests;
@@ -0,0 +1,158 @@
use std::fs;
use super::{
check_installed, compare_versions, installed_version, CliAgentPluginManager,
GeminiPluginManager, MINIMUM_PLUGIN_VERSION,
};
#[test]
fn can_auto_install_is_true() {
assert!(GeminiPluginManager::new(None, None, None).can_auto_install());
}
#[test]
fn minimum_version() {
assert_eq!(
GeminiPluginManager::new(None, None, None).minimum_plugin_version(),
"1.0.0"
);
}
#[test]
fn install_instructions_has_steps() {
let instructions = GeminiPluginManager::new(None, None, None).install_instructions();
assert!(!instructions.steps.is_empty());
assert!(!instructions.title.is_empty());
}
#[test]
fn update_instructions_has_steps() {
let instructions = GeminiPluginManager::new(None, None, None).update_instructions();
assert!(!instructions.steps.is_empty());
assert!(!instructions.title.is_empty());
}
#[test]
fn installed_when_extension_present() {
let dir = tempfile::tempdir().unwrap();
let ext_dir = dir.path().join("gemini-warp");
fs::create_dir_all(&ext_dir).unwrap();
let json = serde_json::json!({
"name": "warp",
"version": "1.0.0",
"description": "Warp terminal integration for Gemini CLI"
});
fs::write(
ext_dir.join("gemini-extension.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
assert!(check_installed(dir.path()));
}
#[test]
fn not_installed_when_extension_missing() {
let dir = tempfile::tempdir().unwrap();
assert!(!check_installed(dir.path()));
}
#[test]
fn not_installed_when_json_invalid() {
let dir = tempfile::tempdir().unwrap();
let ext_dir = dir.path().join("gemini-warp");
fs::create_dir_all(&ext_dir).unwrap();
fs::write(ext_dir.join("gemini-extension.json"), "not json").unwrap();
assert!(!check_installed(dir.path()));
}
#[test]
fn installed_version_returns_version_when_present() {
let dir = tempfile::tempdir().unwrap();
let ext_dir = dir.path().join("gemini-warp");
fs::create_dir_all(&ext_dir).unwrap();
let json = serde_json::json!({
"name": "warp",
"version": "1.5.0"
});
fs::write(
ext_dir.join("gemini-extension.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
assert_eq!(installed_version(dir.path()).as_deref(), Some("1.5.0"));
}
#[test]
fn installed_version_returns_none_when_no_version_field() {
let dir = tempfile::tempdir().unwrap();
let ext_dir = dir.path().join("gemini-warp");
fs::create_dir_all(&ext_dir).unwrap();
let json = serde_json::json!({
"name": "warp"
});
fs::write(
ext_dir.join("gemini-extension.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
assert_eq!(installed_version(dir.path()), None);
}
#[test]
fn installed_version_returns_none_when_file_missing() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(installed_version(dir.path()), None);
}
#[test]
fn needs_update_logic_true_when_version_outdated() {
let dir = tempfile::tempdir().unwrap();
let ext_dir = dir.path().join("gemini-warp");
fs::create_dir_all(&ext_dir).unwrap();
let json = serde_json::json!({
"name": "warp",
"version": "0.9.0"
});
fs::write(
ext_dir.join("gemini-extension.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
let needs_update = match installed_version(dir.path()) {
Some(v) => compare_versions(&v, MINIMUM_PLUGIN_VERSION).is_lt(),
None => check_installed(dir.path()),
};
assert!(needs_update);
}
#[test]
fn needs_update_logic_false_when_version_current() {
let dir = tempfile::tempdir().unwrap();
let ext_dir = dir.path().join("gemini-warp");
fs::create_dir_all(&ext_dir).unwrap();
let json = serde_json::json!({
"name": "warp",
"version": "1.0.0"
});
fs::write(
ext_dir.join("gemini-extension.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
let needs_update = match installed_version(dir.path()) {
Some(v) => compare_versions(&v, MINIMUM_PLUGIN_VERSION).is_lt(),
None => check_installed(dir.path()),
};
assert!(!needs_update);
}
@@ -0,0 +1,272 @@
pub(crate) mod claude;
pub(crate) mod codex;
pub(crate) mod gemini;
pub(crate) mod opencode;
use std::cmp::Ordering;
use std::collections::HashMap;
use std::fmt;
use std::io;
use std::path::PathBuf;
use async_trait::async_trait;
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)]
pub enum PluginModalKind {
Install,
Update,
}
/// A single step in the plugin install/update instructions pane.
pub(crate) struct PluginInstructionStep {
pub description: &'static str,
pub command: &'static str,
/// When true, the code block shows a "Run" button that inserts the command into the terminal.
/// Defaults-by-convention to `true`; set to `false` for steps that are not runnable
/// (e.g. config file snippets).
pub executable: bool,
/// Optional URL rendered as a clickable "Learn more" link after the description.
/// When set with an empty `command`, the code block is omitted entirely.
pub link: Option<&'static str>,
}
/// All content needed to render the plugin instructions pane for a given agent.
pub(crate) struct PluginInstructions {
pub title: &'static str,
pub subtitle: &'static str,
pub steps: &'static [PluginInstructionStep],
/// Displayed after the steps in the same style as the subtitle, one per paragraph.
pub post_install_notes: &'static [&'static str],
}
/// 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).
pub(crate) struct PluginInstallError {
/// Short description shown in the toast notification.
pub message: String,
/// Detailed log of every command/step that was attempted.
pub log: String,
}
impl fmt::Display for PluginInstallError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl From<io::Error> for PluginInstallError {
fn from(err: io::Error) -> Self {
let msg = err.to_string();
Self {
message: msg.clone(),
log: msg,
}
}
}
/// Compares two `X.Y.Z` version strings.
/// Returns `Ordering::Less` if `a < b`, etc.
/// Unparseable components are treated as 0.
pub(crate) fn compare_versions(a: &str, b: &str) -> Ordering {
let parse = |s: &str| -> [u64; 3] {
let mut parts = s.splitn(3, '.');
let major = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
[major, minor, patch]
};
parse(a).cmp(&parse(b))
}
/// Runs a CLI subcommand through [`LocalCommandExecutor`], appending the
/// command and its output to `log`.
pub(crate) async fn run_cli_command_logged(
cli_name: &str,
args: &[&str],
executor: &LocalCommandExecutor,
env_vars: Option<HashMap<String, String>>,
log: &mut String,
) -> Result<(), PluginInstallError> {
let display_cmd = format!("{cli_name} {}", args.join(" "));
log.push_str(&format!("$ {display_cmd}\n"));
let result = executor
.execute_local_command_in_login_shell(&display_cmd, None, env_vars)
.await;
match result {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
for stream in [&stdout, &stderr] {
if stream.is_empty() {
continue;
}
log.push_str(stream);
if !stream.ends_with('\n') {
log.push('\n');
}
}
if output.success() {
log.push('\n');
return Ok(());
}
Err(PluginInstallError {
message: format!("'{display_cmd}' failed"),
log: log.to_owned(),
})
}
Err(err) => {
log.push_str(&format!("error: {err}\n"));
Err(PluginInstallError {
message: format!("failed to run '{display_cmd}'"),
log: log.clone(),
})
}
}
}
/// Manages the Warp notification plugin for a specific CLI agent.
///
/// Each supported CLI agent has its own implementation that knows how to
/// check installation state and perform install/update operations.
#[async_trait]
pub(crate) trait CliAgentPluginManager: Send + Sync {
/// The minimum plugin version required by this Warp build.
fn minimum_plugin_version(&self) -> &'static str;
/// Whether this agent supports one-click auto-install/update.
/// When `false`, the footer always opens the manual instructions modal.
fn can_auto_install(&self) -> bool;
/// Whether the Warp notification plugin is installed.
/// Default returns `false` (no filesystem check).
fn is_installed(&self) -> bool {
false
}
/// Whether the on-disk plugin version is below the minimum required.
/// Default returns `false` (no filesystem check).
fn needs_update(&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> {
Err(PluginInstallError {
message: "Auto-install not supported for this agent".to_owned(),
log: String::new(),
})
}
/// Update the Warp notification plugin to the latest version.
/// Default returns an error — only agents with `can_auto_install() == true` should override.
async fn update(&self) -> Result<(), PluginInstallError> {
Err(PluginInstallError {
message: "Auto-update not supported for this agent".to_owned(),
log: String::new(),
})
}
/// Toast message shown after a successful auto-install.
fn install_success_message(&self) -> &'static str {
"Warp plugin installed. Please restart the session to activate."
}
/// Toast message shown after a successful auto-update.
fn update_success_message(&self) -> &'static str {
"Warp plugin updated. Please restart the session to activate."
}
/// Manual installation instructions for the modal UI.
fn install_instructions(&self) -> &'static PluginInstructions;
/// Whether this agent supports version-based update checking.
/// When `false`, the update chip is never shown; only the install chip appears.
fn supports_update(&self) -> bool {
true
}
/// Manual update instructions for the modal UI.
fn update_instructions(&self) -> &'static PluginInstructions;
/// Install the Oz platform plugin for this CLI agent, if one exists,
/// which provides skills that third-party harnesses can use to interact with
/// the Oz platform.
/// Default is a no-op — only agents with a platform plugin should override.
async fn install_platform_plugin(&self) -> Result<(), PluginInstallError> {
Ok(())
}
}
/// Returns a plugin manager for the given CLI agent, or `None` if the agent
/// doesn't have Warp notification plugin support.
pub(crate) fn plugin_manager_for(agent: CLIAgent) -> Option<Box<dyn CliAgentPluginManager>> {
plugin_manager_for_with_shell(agent, None, None, None)
}
/// Returns a plugin manager for the given CLI agent, or `None` if the agent
/// doesn't have Warp notification plugin support.
///
/// When a shell path and type are provided, plugin commands run through that shell.
/// When `path_env_var` is provided, it is set as the PATH for plugin commands
/// (needed for nvm-installed tools that are only on PATH in interactive shells).
pub(crate) fn plugin_manager_for_with_shell(
agent: CLIAgent,
shell_path: Option<PathBuf>,
shell_type: Option<ShellType>,
path_env_var: Option<String>,
) -> Option<Box<dyn CliAgentPluginManager>> {
match agent {
CLIAgent::Claude => Some(Box::new(ClaudeCodePluginManager::new(
shell_path,
shell_type,
path_env_var,
))),
CLIAgent::OpenCode
if FeatureFlag::OpenCodeNotifications.is_enabled()
&& FeatureFlag::HOANotifications.is_enabled() =>
{
Some(Box::new(OpenCodePluginManager))
}
CLIAgent::Codex
if FeatureFlag::CodexNotifications.is_enabled()
&& FeatureFlag::HOANotifications.is_enabled() =>
{
Some(Box::new(CodexPluginManager))
}
CLIAgent::Gemini
if FeatureFlag::GeminiNotifications.is_enabled()
&& FeatureFlag::HOANotifications.is_enabled() =>
{
Some(Box::new(GeminiPluginManager::new(
shell_path,
shell_type,
path_env_var,
)))
}
CLIAgent::OpenCode
| CLIAgent::Codex
| CLIAgent::Gemini
| CLIAgent::Amp
| CLIAgent::Droid
| CLIAgent::Copilot
| CLIAgent::Pi
| CLIAgent::Auggie
| CLIAgent::CursorCli
| CLIAgent::Unknown => None,
}
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;
@@ -0,0 +1,80 @@
use std::cmp::Ordering;
use super::{compare_versions, plugin_manager_for};
use crate::terminal::CLIAgent;
#[test]
fn returns_manager_for_claude() {
assert!(plugin_manager_for(CLIAgent::Claude).is_some());
}
#[test]
fn returns_manager_for_opencode() {
let _oc_guard = crate::features::FeatureFlag::OpenCodeNotifications.override_enabled(true);
let _hoa_guard = crate::features::FeatureFlag::HOANotifications.override_enabled(true);
assert!(plugin_manager_for(CLIAgent::OpenCode).is_some());
}
#[test]
fn returns_manager_for_codex() {
let _codex_guard = crate::features::FeatureFlag::CodexNotifications.override_enabled(true);
let _hoa_guard = crate::features::FeatureFlag::HOANotifications.override_enabled(true);
assert!(plugin_manager_for(CLIAgent::Codex).is_some());
}
#[test]
fn returns_manager_for_gemini() {
let _gemini_guard = crate::features::FeatureFlag::GeminiNotifications.override_enabled(true);
let _hoa_guard = crate::features::FeatureFlag::HOANotifications.override_enabled(true);
assert!(plugin_manager_for(CLIAgent::Gemini).is_some());
}
#[test]
fn returns_none_for_unsupported_agents() {
assert!(plugin_manager_for(CLIAgent::Amp).is_none());
assert!(plugin_manager_for(CLIAgent::Droid).is_none());
assert!(plugin_manager_for(CLIAgent::Copilot).is_none());
assert!(plugin_manager_for(CLIAgent::Unknown).is_none());
}
#[test]
fn compare_versions_equal() {
assert_eq!(compare_versions("1.2.3", "1.2.3"), Ordering::Equal);
}
#[test]
fn compare_versions_less_than_major() {
assert_eq!(compare_versions("1.0.0", "2.0.0"), Ordering::Less);
}
#[test]
fn compare_versions_less_than_minor() {
assert_eq!(compare_versions("1.1.0", "1.2.0"), Ordering::Less);
}
#[test]
fn compare_versions_less_than_patch() {
assert_eq!(compare_versions("1.1.0", "1.1.1"), Ordering::Less);
}
#[test]
fn compare_versions_greater_than() {
assert_eq!(compare_versions("3.0.0", "2.0.0"), Ordering::Greater);
}
#[test]
fn compare_versions_unparseable_treated_as_zero() {
assert_eq!(compare_versions("abc", "0.0.0"), Ordering::Equal);
assert_eq!(compare_versions("abc", "1.0.0"), Ordering::Less);
}
#[test]
fn compare_versions_partial_version_string() {
assert_eq!(compare_versions("2", "2.0.0"), Ordering::Equal);
assert_eq!(compare_versions("2.1", "2.1.0"), Ordering::Equal);
}
#[test]
fn compare_versions_empty_string() {
assert_eq!(compare_versions("", "2.0.0"), Ordering::Less);
}
@@ -0,0 +1,81 @@
use std::sync::LazyLock;
use async_trait::async_trait;
use super::{CliAgentPluginManager, PluginInstructionStep, PluginInstructions};
// Keep in sync with the opencode-warp npm package version.
// This version is also hardcoded into UPDATE_INSTRUCTIONS below (so the update
// instructions tell users to pin to this specific version to force OpenCode's
// plugin cache to re-fetch). Update both together.
const MINIMUM_PLUGIN_VERSION: &str = "0.1.5";
pub(super) struct OpenCodePluginManager;
#[async_trait]
impl CliAgentPluginManager for OpenCodePluginManager {
fn minimum_plugin_version(&self) -> &'static str {
MINIMUM_PLUGIN_VERSION
}
fn can_auto_install(&self) -> bool {
false
}
fn install_instructions(&self) -> &'static PluginInstructions {
&INSTALL_INSTRUCTIONS
}
fn update_instructions(&self) -> &'static PluginInstructions {
&UPDATE_INSTRUCTIONS
}
}
static INSTALL_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| {
PluginInstructions {
title: "Install Warp Plugin for OpenCode",
subtitle:
"Add the Warp plugin to your OpenCode configuration, then restart OpenCode.",
steps: &[
PluginInstructionStep {
description: "Open or create your opencode.json. This can be in your project root, or the global config path:",
command: "~/.config/opencode/opencode.json",
executable: false,
link: None,
},
PluginInstructionStep {
description: "Add \"@warp-dot-dev/opencode-warp\" to the \"plugin\" array in the top-level JSON object:",
command: "\"plugin\": [\"@warp-dot-dev/opencode-warp\"]",
executable: false,
link: None,
},
],
post_install_notes: &["Restart OpenCode to activate the plugin."],
}
});
static UPDATE_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| {
PluginInstructions {
title: "Update Warp Plugin for OpenCode",
subtitle: "Pin the plugin to the latest version in your opencode.json. OpenCode caches plugins per version spec, so changing the pin forces it to re-fetch on restart.",
steps: &[
PluginInstructionStep {
description: "Open or create your opencode.json. This can be in your project root, or the global config path:",
command: "~/.config/opencode/opencode.json",
executable: false,
link: None,
},
PluginInstructionStep {
description: "Replace the existing \"@warp-dot-dev/opencode-warp\" entry in the \"plugin\" array with the explicit version:",
command: "\"plugin\": [\"@warp-dot-dev/opencode-warp@0.1.5\"]",
executable: false,
link: None,
},
],
post_install_notes: &["Restart OpenCode to load the updated plugin."],
}
});
#[cfg(test)]
#[path = "opencode_tests.rs"]
mod tests;
@@ -0,0 +1,21 @@
use super::OpenCodePluginManager;
use crate::terminal::cli_agent_sessions::plugin_manager::CliAgentPluginManager;
#[test]
fn can_auto_install_is_false() {
assert!(!OpenCodePluginManager.can_auto_install());
}
#[test]
fn install_instructions_has_steps() {
let instructions = OpenCodePluginManager.install_instructions();
assert!(!instructions.steps.is_empty());
assert!(!instructions.title.is_empty());
}
#[test]
fn update_instructions_has_steps() {
let instructions = OpenCodePluginManager.update_instructions();
assert!(!instructions.steps.is_empty());
assert!(!instructions.title.is_empty());
}