use crate::{
core::{
extensions::{
Extension, ExtensionMode, ExtensionTool, ForkProjectInfo, ToolAnnotations, ToolExecCtx,
ToolExposure,
},
tools::{ToolError, ToolResult},
},
extensions::{EXTENSION_FACTORIES, ExtensionFactory, PRIORITY_OPEN},
};
use futures_util::future::BoxFuture;
use serde_json::{Value, json};
use std::{path::Path, process::Stdio, sync::Arc, time::Duration};
const EXT: &str = "open";
const TOOL_OPEN: &str = "open";
const OPENER_TIMEOUT: Duration = Duration::from_secs(10);
#[linkme::distributed_slice(EXTENSION_FACTORIES)]
static OPEN_FACTORY: ExtensionFactory = ExtensionFactory {
priority: PRIORITY_OPEN,
make: || -> Arc<dyn Extension> { Arc::new(Open) },
};
pub struct Open;
impl Extension for Open {
fn name(&self) -> &str {
EXT
}
fn description(&self) -> &str {
"Open files or URLs with the system default application (open)."
}
fn fork_project(&self) -> Option<ForkProjectInfo> {
Some(ForkProjectInfo {
plugin_name: "jcode".to_string(),
plugin_version: "0.94.0".to_string(),
url: "https://github.com/1jehuang/jcode".to_string(),
})
}
fn default_enabled(&self) -> bool {
false
}
fn modes(&self) -> Vec<ExtensionMode> {
vec![ExtensionMode::Dev, ExtensionMode::Creator]
}
fn tools(&self) -> Vec<ExtensionTool> {
vec![open_tool()]
}
fn execute_tool_async(
&self,
name: String,
args: Value,
ctx: ToolExecCtx,
) -> BoxFuture<'static, Result<ToolResult, ToolError>> {
Box::pin(async move {
match name.as_str() {
TOOL_OPEN => run_open(&args, &ctx).await,
other => Err(ToolError(format!("extension {EXT}: unknown tool: {other}"))),
}
})
}
}
fn open_tool() -> ExtensionTool {
ExtensionTool {
exposure: ToolExposure::Direct,
namespace: None,
annotations: ToolAnnotations {
read_only: false,
destructive: false,
idempotent: true,
},
output_schema: None,
name: TOOL_OPEN.to_string(),
description: "Open a file or URL with the system default application on the user's machine (viewer, editor, or browser). Use it to show something to the user; it does not return the file's content.".to_string(),
label: Some("Open".to_string()),
parameters: json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File path (relative paths resolve against the working directory) or an http/https/file URL."
}
},
"required": ["path"]
}),
snippet: "Hand a file or URL to the user's default application (opens a visible window); does not read content back.".to_string(),
prompt_guidelines: vec![
"`open` launches an external program on the user's machine; use `read` / `fetch_content` when the content itself is what you need.".to_string(),
],
constrained_sampling: false,
render_shell: None,
execution_mode: None,
prepare_arguments: None,
grammar_sampling: None,
}
}
async fn run_open(args: &Value, ctx: &ToolExecCtx) -> Result<ToolResult, ToolError> {
let raw = args
.get("path")
.and_then(Value::as_str)
.ok_or_else(|| ToolError("missing required parameter: path".to_string()))?;
let target = resolve_target(raw, &ctx.cwd).map_err(ToolError)?;
let (value, kind) = match &target {
Target::Url(url) => (url.clone(), "URL"),
Target::Path(path) => (path.clone(), "file"),
};
let (program, argv) = opener_command(&value);
let outcome = spawn_opener(&program, &argv)
.await
.map_err(|e| ToolError(format!("failed to launch `{program}`: {e}")))?;
let mut message = if outcome.success {
format!("Opened {kind} {value} with `{program}`.")
} else if let Some(secs) = outcome.timed_out_secs {
format!(
"`{program}` did not exit within {secs}s while opening {kind} {value}; it was killed."
)
} else {
format!("`{program}` exited with a non-zero status while opening {kind} {value}.")
};
if !outcome.stderr.is_empty() {
message.push_str(&format!(
"\n`{program}` wrote to stderr:\n{}",
outcome.stderr
));
}
if outcome.success {
Ok(ToolResult::text(message))
} else {
Err(ToolError(message))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct OpenerOutcome {
success: bool,
stderr: String,
timed_out_secs: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Target {
Url(String),
Path(String),
}
fn is_url(value: &str) -> bool {
let lower = value.to_ascii_lowercase();
lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("file://")
}
fn resolve_target(raw: &str, cwd: &str) -> Result<Target, String> {
let raw = raw.trim();
if raw.is_empty() {
return Err("`path` must not be empty.".to_string());
}
if is_url(raw) {
return Ok(Target::Url(raw.to_string()));
}
let path = Path::new(raw);
let resolved = if path.is_absolute() {
path.to_path_buf()
} else {
Path::new(cwd).join(path)
};
if !resolved.exists() {
return Err(format!("path not found: {}", resolved.display()));
}
Ok(Target::Path(resolved.to_string_lossy().to_string()))
}
fn opener_command(target: &str) -> (String, Vec<String>) {
if cfg!(target_os = "macos") {
("open".to_string(), vec![target.to_string()])
} else if cfg!(target_os = "windows") {
(
"cmd".to_string(),
vec![
"/C".to_string(),
"start".to_string(),
String::new(),
target.to_string(),
],
)
} else {
("xdg-open".to_string(), vec![target.to_string()])
}
}
async fn spawn_opener(program: &str, args: &[String]) -> std::io::Result<OpenerOutcome> {
let mut cmd = tokio::process::Command::new(program);
cmd.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.kill_on_drop(true);
let child = cmd.spawn()?;
let output = match tokio::time::timeout(OPENER_TIMEOUT, child.wait_with_output()).await {
Ok(result) => result?,
Err(_) => {
return Ok(OpenerOutcome {
success: false,
stderr: String::new(),
timed_out_secs: Some(OPENER_TIMEOUT.as_secs()),
});
}
};
Ok(OpenerOutcome {
success: output.status.success(),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
timed_out_secs: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::extensions::ToolExecCtx;
use serde_json::json;
fn ctx(cwd: &str) -> ToolExecCtx {
ToolExecCtx {
cwd: cwd.to_string(),
make_sub_agent: Arc::new(|_spec| Err("subagents unavailable in tests".to_string())),
parent_abort: Arc::new(std::sync::atomic::AtomicBool::new(false)),
agent_id: None,
depth: 0,
parent_model: None,
execute_tool: crate::core::extensions::unavailable_tool_exec(),
parent_tool_call_id: None,
nested_calls: Default::default(),
script_tools: Default::default(),
session_branch_entries: Default::default(),
script_call: false,
}
}
#[test]
fn recognizes_only_supported_url_schemes() {
assert!(is_url("http://example.com"));
assert!(is_url("HTTPS://example.com"));
assert!(is_url("file:///tmp/a.txt"));
assert!(!is_url("ftp://example.com"));
assert!(!is_url("./local.txt"));
assert!(!is_url("example.com"));
}
#[test]
fn resolve_passes_urls_through_without_touching_disk() {
assert_eq!(
resolve_target("https://example.com/a?b=1", "/nonexistent"),
Ok(Target::Url("https://example.com/a?b=1".to_string()))
);
}
#[test]
fn resolve_expands_relative_paths_against_cwd() {
let dir = std::env::temp_dir().join(format!("prux-open-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("note.txt");
std::fs::write(&file, "hi").unwrap();
assert_eq!(
resolve_target("note.txt", &dir.to_string_lossy()),
Ok(Target::Path(file.to_string_lossy().to_string()))
);
assert_eq!(
resolve_target(&file.to_string_lossy(), "/nonexistent"),
Ok(Target::Path(file.to_string_lossy().to_string())),
"绝对路径不看 cwd"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn resolve_rejects_empty_and_missing_paths() {
assert!(resolve_target(" ", "/tmp").is_err());
let err = resolve_target("definitely-missing.txt", "/tmp").unwrap_err();
assert!(err.contains("path not found"), "{err}");
}
#[test]
fn opener_command_matches_platform() {
let (program, argv) = opener_command("/tmp/a.txt");
if cfg!(target_os = "macos") {
assert_eq!(program, "open");
assert_eq!(argv, vec!["/tmp/a.txt"]);
} else if cfg!(target_os = "windows") {
assert_eq!(program, "cmd");
assert_eq!(argv, vec!["/C", "start", "", "/tmp/a.txt"]);
} else {
assert_eq!(program, "xdg-open");
assert_eq!(argv, vec!["/tmp/a.txt"]);
}
}
#[cfg(unix)]
#[tokio::test]
async fn spawn_opener_captures_stderr_and_drops_stdout() {
let outcome = spawn_opener(
"sh",
&[
"-c".to_string(),
"printf out; printf 'boom\\n' >&2".to_string(),
],
)
.await
.unwrap();
assert!(outcome.success);
assert_eq!(outcome.stderr, "boom");
assert_eq!(outcome.timed_out_secs, None);
}
#[cfg(unix)]
#[tokio::test]
async fn spawn_opener_reports_non_zero_exit_with_stderr() {
let outcome = spawn_opener(
"sh",
&["-c".to_string(), "printf 'nope\\n' >&2; exit 3".to_string()],
)
.await
.unwrap();
assert!(!outcome.success);
assert_eq!(outcome.stderr, "nope");
}
#[tokio::test]
async fn spawn_opener_rejects_missing_program() {
assert!(spawn_opener("prux-no-such-opener", &[]).await.is_err());
}
#[test]
fn fork_project_reports_jcode_source() {
let info = Open.fork_project().expect("open 应声明 fork 来源");
assert_eq!(info.plugin_name, "jcode");
assert_eq!(info.plugin_version, "0.94.0");
assert_eq!(info.url, "https://github.com/1jehuang/jcode");
}
#[test]
fn tool_declares_required_path_parameter() {
let tool = open_tool();
assert_eq!(tool.name, TOOL_OPEN);
assert_eq!(tool.parameters["required"], json!(["path"]));
assert_eq!(tool.parameters["properties"]["path"]["type"], "string");
assert!(tool.annotations.idempotent);
assert!(!tool.annotations.read_only);
}
#[tokio::test]
async fn missing_parameter_is_an_error() {
let err = run_open(&json!({}), &ctx("/tmp")).await.unwrap_err();
assert!(err.0.contains("missing required parameter"), "{}", err.0);
}
#[tokio::test]
async fn missing_file_is_an_error() {
let err = run_open(&json!({ "path": "no-such-file.txt" }), &ctx("/tmp"))
.await
.unwrap_err();
assert!(err.0.contains("path not found"), "{}", err.0);
}
#[tokio::test]
async fn unknown_tool_name_is_rejected() {
let err = Open
.execute_tool_async("nope".to_string(), json!({}), ctx("/tmp"))
.await
.unwrap_err();
assert!(err.0.contains("unknown tool: nope"), "{}", err.0);
}
}