Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user