Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,8 @@ pub struct App {
terminal_focused: bool,
pub tool_permissions: crate::tools::ToolPermissions,
pub skills_dirs: Vec<std::path::PathBuf>,
pub plugin_specs: Vec<crate::config::configuration::PluginSpec>,
pub project_root: std::path::PathBuf,
pub is_streaming: bool,
pending_session_title: Option<String>,
session_view_states: std::collections::HashMap<String, ClientSessionState>,
Expand Down Expand Up @@ -1011,6 +1013,8 @@ impl App {
};

let loaded_config = crate::config::ConfigLoader::load()?;
let plugin_specs = loaded_config.merged_config.plugins.clone();
let project_root = loaded_config.project_root.clone();
let mut mcp_config = loaded_config.merged_config.mcp.clone();
crate::remote_mcp::apply_mcp_overrides(&mut mcp_config, prefs_dao.as_ref());
input.set_image_open_config(loaded_config.merged_config.images.clone());
Expand Down Expand Up @@ -1240,6 +1244,8 @@ impl App {
terminal_focused: true,
tool_permissions,
skills_dirs: loaded_config.inventory.opencode_skills_dirs,
plugin_specs,
project_root,
// Note: skills_dirs is legacy; skill loading is now handled by src/skill/mod.rs
is_streaming: false,
pending_session_title: None,
Expand Down Expand Up @@ -11304,6 +11310,8 @@ mod tests {
terminal_focused: true,
tool_permissions: crate::tools::ToolPermissions::new(".".to_string()),
skills_dirs: Vec::new(),
plugin_specs: Vec::new(),
project_root: std::path::PathBuf::from("."),
is_streaming: false,
pending_session_title: None,
session_view_states: std::collections::HashMap::new(),
Expand Down
5 changes: 3 additions & 2 deletions src/command/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1243,8 +1243,8 @@ mod tests {
let _ = crate::model::discovery::Discovery::cleanup_test();
let parsed = ParsedCommand {
name: "models".to_string(),
args: vec![],
raw: "/models".to_string(),
args: vec!["ollama".to_string()],
raw: "/models ollama".to_string(),
prefs_data: None,
active_model_id: None,
};
Expand Down Expand Up @@ -1347,6 +1347,7 @@ mod tests {

#[tokio::test]
async fn test_handle_refreshmodels() {
let _guard = crate::model::extensions::ollama::test_cache_lock();
let _ = crate::model::discovery::Discovery::cleanup_test();
let parsed = ParsedCommand {
name: "refreshmodels".to_string(),
Expand Down
207 changes: 206 additions & 1 deletion src/config/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,18 @@ fn list_json_files(dir: &Path) -> Vec<PathBuf> {
out
}

fn append_discovered_plugins(plugins: &mut Vec<PluginSpec>, plugin_files: &[PathBuf]) {
for path in plugin_files {
let source = path.to_string_lossy().into_owned();
if !plugins.iter().any(|plugin| plugin.source == source) {
plugins.push(PluginSpec {
source,
options: Value::Null,
});
}
}
}

fn parse_provider_id_set(
value: Option<&Value>,
diagnostics: &mut ConfigDiagnostics,
Expand Down Expand Up @@ -171,6 +183,13 @@ pub struct ConfigInventory {
pub opencode_agents: Vec<PathBuf>,
pub opencode_skills_dirs: Vec<PathBuf>,
pub command_files: Vec<PathBuf>,
pub plugin_files: Vec<PathBuf>,
}

#[derive(Debug, Clone, PartialEq)]
pub struct PluginSpec {
pub source: String,
pub options: Value,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -479,6 +498,7 @@ pub struct MergedConfig {
pub compaction: CompactionConfig,
pub watcher: WatcherConfig,
pub formatter: HashMap<String, FormatterConfig>,
pub plugins: Vec<PluginSpec>,
}

impl MergedConfig {
Expand Down Expand Up @@ -627,6 +647,7 @@ impl ConfigLoader {
&mut diagnostics,
);
let mut merged_config = parse_merged_config(&merged, &mut diagnostics);
append_discovered_plugins(&mut merged_config.plugins, &inventory.plugin_files);
merged_config.instructions =
load_instruction_files(&merged_config.instructions, &project_root, &mut diagnostics);
let mut agent_definitions = crate::agent::definition::load_markdown_agent_definitions(
Expand Down Expand Up @@ -747,6 +768,43 @@ fn discover_opencode_inventory(
));
}
inventory.opencode_skills_dirs = skills_dirs;

let mut plugin_files = Vec::new();
for dir in [
global_opencode.join("plugins"),
global_opencode.join("plugin"),
local_opencode.join("plugins"),
local_opencode.join("plugin"),
] {
plugin_files.extend(list_plugin_files(&dir));
}
plugin_files.sort();
plugin_files.dedup();
if !plugin_files.is_empty() {
diagnostics.info.push(format!(
"Discovered {} OpenCode plugin files",
plugin_files.len()
));
}
inventory.plugin_files = plugin_files;
}

fn list_plugin_files(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let Ok(entries) = fs::read_dir(dir) else {
return out;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let extension = path.extension().and_then(|value| value.to_str());
if matches!(extension, Some("js" | "mjs" | "cjs" | "ts")) {
out.push(path);
}
}
out
}

fn load_custom_commands(
Expand Down Expand Up @@ -1032,6 +1090,7 @@ fn opencode_allowed_keys() -> BTreeSet<&'static str> {
[
"$schema",
"agent",
"plugin",
"instructions",
"tools",
"mcp",
Expand Down Expand Up @@ -1070,7 +1129,6 @@ fn opencode_ignored_keys() -> BTreeSet<&'static str> {
"share",
"tui",
"server",
"plugin",
"tool",
"custom tools",
"custom_tools",
Expand Down Expand Up @@ -1301,6 +1359,50 @@ fn expand_path(arg: &str, base_dir: &Path) -> PathBuf {
}
}

fn parse_plugin_specs(
value: Option<&Value>,
diagnostics: &mut ConfigDiagnostics,
) -> Vec<PluginSpec> {
let Some(Value::Array(entries)) = value else {
if value.is_some() {
diagnostics
.warnings
.push("plugin must be an array".to_string());
}
return Vec::new();
};

entries
.iter()
.enumerate()
.filter_map(|(index, entry)| match entry {
Value::String(source) if !source.trim().is_empty() => Some(PluginSpec {
source: source.trim().to_string(),
options: Value::Null,
}),
Value::Array(tuple) if tuple.len() == 2 => {
let Some(source) = tuple[0].as_str().filter(|value| !value.trim().is_empty())
else {
diagnostics.warnings.push(format!(
"plugin[{index}] must start with a non-empty plugin source"
));
return None;
};
Some(PluginSpec {
source: source.trim().to_string(),
options: tuple[1].clone(),
})
}
_ => {
diagnostics.warnings.push(format!(
"plugin[{index}] must be a source string or [source, options]"
));
None
}
})
.collect()
}

fn parse_merged_config(merged: &Value, diagnostics: &mut ConfigDiagnostics) -> MergedConfig {
let mut out = MergedConfig::default();
let obj = match merged.as_object() {
Expand Down Expand Up @@ -1342,6 +1444,7 @@ fn parse_merged_config(merged: &Value, diagnostics: &mut ConfigDiagnostics) -> M
json_agents,
);
out.sync_agent_derived_fields();
out.plugins = parse_plugin_specs(obj.get("plugin"), diagnostics);
out.provider_timeouts = parse_provider_timeouts(obj.get("provider"), diagnostics);
out.enabled_providers = parse_provider_id_set(
obj.get("enabled_providers")
Expand Down Expand Up @@ -2596,6 +2699,7 @@ fn collect_unimplemented_keys(merged: &Value) -> Vec<String> {
"enabled_providers",
"permission",
"mcp",
"plugin",
]
.into_iter()
.collect();
Expand All @@ -2619,6 +2723,107 @@ mod tests {
use super::*;
use serde_json::json;

#[test]
fn parses_plugin_sources_and_options() {
let mut diagnostics = ConfigDiagnostics::default();
let config = parse_merged_config(
&json!({
"plugin": [
"./.opencode/plugins/one.mjs",
["@scope/two", { "enabled": true }],
42
]
}),
&mut diagnostics,
);

assert_eq!(config.plugins.len(), 2);
assert_eq!(config.plugins[0].source, "./.opencode/plugins/one.mjs");
assert_eq!(config.plugins[0].options, Value::Null);
assert_eq!(config.plugins[1].source, "@scope/two");
assert_eq!(config.plugins[1].options, json!({ "enabled": true }));
assert_eq!(diagnostics.warnings.len(), 1);
}

#[test]
fn opencode_plugin_key_is_parsed_and_not_reported_unimplemented() {
let filtered = filter_top_level(
json!({
"plugin": ["./plugin.mjs"],
"unknown": true
}),
SourceKind::OpenCode,
);

let mut diagnostics = ConfigDiagnostics::default();
let config = parse_merged_config(&filtered, &mut diagnostics);

assert_eq!(config.plugins.len(), 1);
assert_eq!(config.plugins[0].source, "./plugin.mjs");
assert!(collect_unimplemented_keys(&filtered).is_empty());
}

#[test]
fn discovers_supported_plugin_files() {
let temp = tempfile::tempdir().expect("temp dir");
std::fs::write(temp.path().join("a.mjs"), "export default {};").unwrap();
std::fs::write(temp.path().join("b.ts"), "export default {};").unwrap();
std::fs::write(temp.path().join("ignored.txt"), "ignored").unwrap();

let mut files = list_plugin_files(temp.path());
files.sort();

assert_eq!(files.len(), 2);
assert!(files.iter().any(|path| path.ends_with("a.mjs")));
assert!(files.iter().any(|path| path.ends_with("b.ts")));
}

#[test]
fn plugin_discovery_is_sorted_across_singular_and_plural_directories() {
let project = tempfile::tempdir().expect("project temp dir");
let xdg = tempfile::tempdir().expect("xdg temp dir");
let singular = project.path().join(".opencode/plugin");
let plural = project.path().join(".opencode/plugins");
std::fs::create_dir_all(&singular).unwrap();
std::fs::create_dir_all(&plural).unwrap();
std::fs::write(singular.join("z.mjs"), "export default {};").unwrap();
std::fs::write(plural.join("a.js"), "export default {};").unwrap();

let mut inventory = ConfigInventory::default();
let mut diagnostics = ConfigDiagnostics::default();
discover_opencode_inventory(xdg.path(), project.path(), &mut inventory, &mut diagnostics);

let mut expected = vec![plural.join("a.js"), singular.join("z.mjs")];
expected.sort();

assert_eq!(inventory.plugin_files, expected);
}

#[test]
fn explicit_plugins_stay_first_and_dedupe_discovered_paths() {
let project = tempfile::tempdir().expect("project temp dir");
let plugin_dir = project.path().join(".opencode/plugins");
std::fs::create_dir_all(&plugin_dir).unwrap();
let discovered = plugin_dir.join("local.mjs");
std::fs::write(&discovered, "export default {};").unwrap();

let mut plugins = vec![
PluginSpec {
source: "@scope/package".to_string(),
options: json!({ "mode": "strict" }),
},
PluginSpec {
source: discovered.to_string_lossy().into_owned(),
options: Value::Null,
},
];
append_discovered_plugins(&mut plugins, &[discovered]);

assert_eq!(plugins.len(), 2);
assert_eq!(plugins[0].source, "@scope/package");
assert_eq!(plugins[0].options, json!({ "mode": "strict" }));
}

#[test]
fn parses_and_applies_top_level_runtime_configuration() {
let mut diagnostics = ConfigDiagnostics::default();
Expand Down
33 changes: 33 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ mod mcp;
mod model;
mod notify;
mod persistence;
mod plugin;
mod prompt;
mod remote;
mod remote_mcp;
Expand Down Expand Up @@ -861,6 +862,32 @@ async fn main() -> Result<()> {
}

let mut app = App::new_with_model_override(args.model.as_deref())?;
let plugins_enabled = std::env::var("CRABCODE_ENABLE_OPENCODE_PLUGINS")
.is_ok_and(|value| value == "1" || value.eq_ignore_ascii_case("true"));
let mut plugin_host = None;
if plugins_enabled && !app.plugin_specs.is_empty() {
let cache_dir = crate::persistence::get_data_dir().join("cache");
match crate::plugin::PluginHost::start(&cache_dir, &app.project_root).await {
Ok(mut host) => match host.load_plugins(&app.plugin_specs).await {
Ok(result) => {
crate::startup_diag!("Plugins: {}", result);
plugin_host = Some(host);
}
Err(error) => {
crate::startup_diag!("Plugin warning: failed to load plugins: {}", error);
let _ = host.shutdown().await;
}
},
Err(error) => {
crate::startup_diag!("Plugin warning: failed to start Bun sidecar: {}", error);
}
}
} else if !app.plugin_specs.is_empty() {
crate::startup_diag!(
"Plugins: {} discovered but disabled; set CRABCODE_ENABLE_OPENCODE_PLUGINS=1 to enable the experimental host",
app.plugin_specs.len()
);
}
// Keep herdr authority until this guard drops (normal exit or panic).
let _herdr = crate::herdr::Session::start();

Expand Down Expand Up @@ -931,6 +958,12 @@ async fn main() -> Result<()> {
restore_terminal_modes(terminal.backend_mut(), keyboard_enhancement)?;
terminal.show_cursor()?;

if let Some(host) = plugin_host {
if let Err(error) = host.shutdown().await {
eprintln!("Plugin warning: failed to stop sidecar: {error}");
}
}

if let Some(request) = remote_launch_request {
if let Err(err) = result {
return Err(err);
Expand Down
Loading
Loading