[refactor] streamline crate structure, update dependencies, and integrate CLI functionalities
This commit is contained in:
@@ -1,168 +1,118 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use cliclack as ui; // reuse for minimal logging
|
||||
use directories::BaseDirs;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use polyscribe_protocol as psp;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
env,
|
||||
fs,
|
||||
os::unix::fs::PermissionsExt,
|
||||
path::Path,
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, BufReader},
|
||||
process::{Child as TokioChild, Command},
|
||||
};
|
||||
use std::process::Stdio;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Plugin {
|
||||
pub struct PluginInfo {
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Discover plugins on PATH and in the user's data dir (XDG) under polyscribe/plugins.
|
||||
pub fn discover() -> Result<Vec<Plugin>> {
|
||||
let mut found: BTreeMap<String, PathBuf> = BTreeMap::new();
|
||||
#[derive(Debug, Default)]
|
||||
pub struct PluginManager;
|
||||
|
||||
// Scan PATH directories
|
||||
if let Some(path_var) = std::env::var_os("PATH") {
|
||||
for dir in std::env::split_paths(&path_var) {
|
||||
if dir.as_os_str().is_empty() { continue; }
|
||||
if let Ok(rd) = fs::read_dir(&dir) {
|
||||
for ent in rd.flatten() {
|
||||
let p = ent.path();
|
||||
if !is_executable(&p) { continue; }
|
||||
if let Some(fname) = p.file_name().and_then(OsStr::to_str) {
|
||||
if let Some(name) = fname.strip_prefix("polyscribe-plugin-") {
|
||||
found.entry(name.to_string()).or_insert(p);
|
||||
impl PluginManager {
|
||||
pub fn list(&self) -> Result<Vec<PluginInfo>> {
|
||||
let mut plugins = Vec::new();
|
||||
|
||||
// Scan PATH entries for executables starting with "polyscribe-plugin-"
|
||||
if let Ok(path) = env::var("PATH") {
|
||||
for dir in env::split_paths(&path) {
|
||||
if let Ok(read_dir) = fs::read_dir(&dir) {
|
||||
for entry in read_dir.flatten() {
|
||||
let path = entry.path();
|
||||
if let Some(fname) = path.file_name().and_then(|s| s.to_str()) {
|
||||
if fname.starts_with("polyscribe-plugin-") && is_executable(&path) {
|
||||
let name = fname.trim_start_matches("polyscribe-plugin-").to_string();
|
||||
plugins.push(PluginInfo {
|
||||
name,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: also scan XDG data plugins dir for symlinks/binaries
|
||||
Ok(plugins)
|
||||
}
|
||||
|
||||
// Scan user data dir
|
||||
if let Some(base) = BaseDirs::new() {
|
||||
let user_plugins = PathBuf::from(base.data_dir()).join("polyscribe").join("plugins");
|
||||
if let Ok(rd) = fs::read_dir(&user_plugins) {
|
||||
for ent in rd.flatten() {
|
||||
let p = ent.path();
|
||||
if !is_executable(&p) { continue; }
|
||||
if let Some(fname) = p.file_name().and_then(OsStr::to_str) {
|
||||
let name = fname.strip_prefix("polyscribe-plugin-")
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| Some(fname.to_string()))
|
||||
.unwrap();
|
||||
found.entry(name).or_insert(p);
|
||||
}
|
||||
pub fn info(&self, name: &str) -> Result<serde_json::Value> {
|
||||
let bin = self.resolve(name)?;
|
||||
let out = std::process::Command::new(&bin)
|
||||
.arg("info")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.context("spawning plugin info")?
|
||||
.wait_with_output()
|
||||
.context("waiting for plugin info")?;
|
||||
|
||||
let val: serde_json::Value =
|
||||
serde_json::from_slice(&out.stdout).context("parsing plugin info JSON")?;
|
||||
Ok(val)
|
||||
}
|
||||
|
||||
pub fn spawn(&self, name: &str, command: &str) -> Result<TokioChild> {
|
||||
let bin = self.resolve(name)?;
|
||||
let mut cmd = Command::new(&bin);
|
||||
cmd.arg("run")
|
||||
.arg(command)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit());
|
||||
let child = cmd.spawn().context("spawning plugin run")?;
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
pub async fn forward_stdio(&self, child: &mut TokioChild) -> Result<std::process::ExitStatus> {
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
let mut reader = BufReader::new(stdout).lines();
|
||||
while let Some(line) = reader.next_line().await? {
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
Ok(child.wait().await?)
|
||||
}
|
||||
|
||||
Ok(found
|
||||
.into_iter()
|
||||
.map(|(name, path)| Plugin { name, path })
|
||||
.collect())
|
||||
fn resolve(&self, name: &str) -> Result<String> {
|
||||
let bin = format!("polyscribe-plugin-{name}");
|
||||
let path = which::which(&bin).with_context(|| format!("plugin not found in PATH: {bin}"))?;
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn is_executable(p: &Path) -> bool {
|
||||
if !p.is_file() { return false; }
|
||||
fn is_executable(path: &Path) -> bool {
|
||||
if !path.is_file() {
|
||||
return false;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Ok(md) = fs::metadata(p) {
|
||||
let mode = md.permissions().mode();
|
||||
return (mode & 0o111) != 0;
|
||||
}
|
||||
false
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
// On Windows, consider .exe, .bat, .cmd
|
||||
matches!(p.extension().and_then(|s| s.to_str()).map(|s| s.to_lowercase()), Some(ext) if matches!(ext.as_str(), "exe"|"bat"|"cmd"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Query plugin capabilities by invoking `--capabilities`.
|
||||
pub fn capabilities(plugin_path: &Path) -> Result<psp::Capabilities> {
|
||||
let out = Command::new(plugin_path)
|
||||
.arg("--capabilities")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.output()
|
||||
.with_context(|| format!("Failed to execute plugin: {}", plugin_path.display()))?;
|
||||
if !out.status.success() {
|
||||
return Err(anyhow!("Plugin --capabilities failed: {}", plugin_path.display()));
|
||||
}
|
||||
let s = String::from_utf8(out.stdout).context("capabilities stdout not utf-8")?;
|
||||
let caps: psp::Capabilities = serde_json::from_str(s.trim()).context("invalid capabilities JSON")?;
|
||||
Ok(caps)
|
||||
}
|
||||
|
||||
/// Run a single method via `--serve`, writing one JSON-RPC request and streaming until result.
|
||||
pub fn run_method<F>(plugin_path: &Path, method: &str, params: Value, mut on_progress: F) -> Result<Value>
|
||||
where
|
||||
F: FnMut(psp::Progress),
|
||||
{
|
||||
let mut child = Command::new(plugin_path)
|
||||
.arg("--serve")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.with_context(|| format!("Failed to spawn plugin: {}", plugin_path.display()))?;
|
||||
|
||||
let mut stdin = child.stdin.take().ok_or_else(|| anyhow!("failed to open plugin stdin"))?;
|
||||
let stdout = child.stdout.take().ok_or_else(|| anyhow!("failed to open plugin stdout"))?;
|
||||
|
||||
// Send request line
|
||||
let req = psp::JsonRpcRequest { jsonrpc: "2.0".into(), id: "1".into(), method: method.to_string(), params: Some(params) };
|
||||
let line = serde_json::to_string(&req)? + "\n";
|
||||
stdin.write_all(line.as_bytes())?;
|
||||
stdin.flush()?;
|
||||
|
||||
// Read response lines
|
||||
let reader = BufReader::new(stdout);
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
if line.trim().is_empty() { continue; }
|
||||
// Try parse StreamItem; if that fails, try parse JsonRpcResponse directly
|
||||
if let Ok(item) = serde_json::from_str::<psp::StreamItem>(&line) {
|
||||
match item {
|
||||
psp::StreamItem::Progress(p) => {
|
||||
on_progress(p);
|
||||
}
|
||||
psp::StreamItem::Result(resp) => {
|
||||
match resp.outcome {
|
||||
psp::JsonRpcOutcome::Ok { result } => return Ok(result),
|
||||
psp::JsonRpcOutcome::Err { error } => return Err(anyhow!("{} ({})", error.message, error.code)),
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Ok(resp) = serde_json::from_str::<psp::JsonRpcResponse>(&line) {
|
||||
match resp.outcome {
|
||||
psp::JsonRpcOutcome::Ok { result } => return Ok(result),
|
||||
psp::JsonRpcOutcome::Err { error } => return Err(anyhow!("{} ({})", error.message, error.code)),
|
||||
}
|
||||
} else {
|
||||
let _ = ui::log::warning(format!("Unrecognized plugin output: {}", line));
|
||||
if let Ok(meta) = fs::metadata(path) {
|
||||
let mode = meta.permissions().mode();
|
||||
// if any execute bit is set
|
||||
return mode & 0o111 != 0;
|
||||
}
|
||||
}
|
||||
|
||||
// If we exited loop without returning, wait for child
|
||||
let status = child.wait()?;
|
||||
if status.success() {
|
||||
Err(anyhow!("Plugin terminated without sending a result"))
|
||||
} else {
|
||||
Err(anyhow!("Plugin exited with status: {:?}", status))
|
||||
}
|
||||
// Fallback for non-unix (treat files as candidates)
|
||||
true
|
||||
}
|
||||
|
||||
/// Helper: find a plugin by name using discovery
|
||||
pub fn find_plugin_by_name(name: &str) -> Result<Plugin> {
|
||||
let plugins = discover()?;
|
||||
plugins
|
||||
.into_iter()
|
||||
.find(|p| p.name == name)
|
||||
.ok_or_else(|| anyhow!("Plugin '{}' not found", name))
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Capability {
|
||||
command: String,
|
||||
summary: String,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user