use clap::Subcommand;
use objectiveai_cli_sdk::output::{Handle, Installed, Notification, Output, Plugin, Plugins};
use objectiveai_cli_sdk::plugins::PluginOutput;
use tokio::io::AsyncBufReadExt;
use tokio::task::JoinHandle;
#[derive(Subcommand)]
pub enum Commands {
Get {
name: String,
},
Install {
#[arg(long)]
owner: String,
#[arg(long)]
repository: String,
#[arg(long)]
commit_sha: Option<String>,
#[arg(long)]
allow_untrusted: bool,
#[arg(long)]
upgrade: bool,
},
List {
#[arg(long, default_value_t = 0)]
offset: usize,
#[arg(long, default_value_t = 100)]
limit: usize,
},
#[command(external_subcommand)]
Run(Vec<String>),
}
impl Commands {
pub async fn handle(
self,
cli_config: &crate::Config,
handle: &Handle,
) -> Result<(), crate::error::Error> {
match self {
Commands::Get { name } => get(cli_config, handle, &name).await,
Commands::Install { owner, repository, commit_sha, allow_untrusted, upgrade } => {
install(cli_config, handle, &owner, &repository, commit_sha.as_deref(), allow_untrusted, upgrade).await
}
Commands::List { offset, limit } => list(cli_config, handle, offset, limit).await,
Commands::Run(args) => dispatch_external(args, cli_config, handle).await,
}
}
}
async fn install(
cli_config: &crate::Config,
handle: &Handle,
owner: &str,
repository: &str,
commit_sha: Option<&str>,
allow_untrusted: bool,
upgrade: bool,
) -> Result<(), crate::error::Error> {
let fs_client = objectiveai_sdk::filesystem::Client::new(
cli_config.config_base_dir.as_deref(),
cli_config.commit_author_name.as_deref(),
cli_config.commit_author_email.as_deref(),
);
let manifest = fs_client
.fetch_plugin_manifest(owner, repository, commit_sha, None)
.await?;
let effective_sha = commit_sha.unwrap_or("HEAD");
let whitelist = objectiveai_sdk::filesystem::plugins::default_whitelist();
let allowed = objectiveai_sdk::filesystem::plugins::check_plugin_whitelist(
owner,
repository,
effective_sha,
&manifest.version,
&whitelist,
)
.map_err(crate::error::Error::WhitelistRegex)?;
if !allowed {
if !allow_untrusted {
return Err(crate::error::Error::PluginNotWhitelisted {
owner: owner.to_string(),
repository: repository.to_string(),
commit_sha: effective_sha.to_string(),
version: manifest.version.clone(),
});
}
emit_untrusted_warning(handle, owner, repository, effective_sha, &manifest.version).await;
}
let source = objectiveai_sdk::filesystem::plugins::raw_manifest_url(owner, repository, commit_sha);
let installed = fs_client
.install_plugin_from_manifest(owner, repository, &manifest, &source, None, upgrade)
.await?;
Output::<Installed>::Notification(Notification { value: Installed { installed } })
.emit(handle)
.await;
Ok(())
}
async fn emit_untrusted_warning(
handle: &Handle,
owner: &str,
repository: &str,
commit_sha: &str,
version: &str,
) {
use objectiveai_cli_sdk::output::{Error as OutputError, Level};
let message = format!(
"installing untrusted plugin {owner}/{repository} (commit: {commit_sha}, version: {version}); \
this plugin is not in the whitelist and is being installed because --allow-untrusted was passed"
);
Output::<serde_json::Value>::Error(OutputError {
level: Level::Warn,
fatal: false,
message: message.into(),
})
.emit(handle)
.await;
}
async fn get(
cli_config: &crate::Config,
handle: &Handle,
name: &str,
) -> Result<(), crate::error::Error> {
let fs_client = objectiveai_sdk::filesystem::Client::new(
cli_config.config_base_dir.as_deref(),
cli_config.commit_author_name.as_deref(),
cli_config.commit_author_email.as_deref(),
);
let plugin = fs_client.get_plugin(name).await;
Output::<Plugin>::Notification(Notification { value: Plugin { plugin } })
.emit(handle)
.await;
Ok(())
}
async fn list(
cli_config: &crate::Config,
handle: &Handle,
offset: usize,
limit: usize,
) -> Result<(), crate::error::Error> {
let fs_client = objectiveai_sdk::filesystem::Client::new(
cli_config.config_base_dir.as_deref(),
cli_config.commit_author_name.as_deref(),
cli_config.commit_author_email.as_deref(),
);
let plugins = fs_client.list_plugins(offset, limit).await;
Output::<Plugins>::Notification(Notification { value: Plugins { plugins } })
.emit(handle)
.await;
Ok(())
}
pub async fn dispatch_external(
args: Vec<String>,
cli_config: &crate::Config,
handle: &Handle,
) -> Result<(), crate::error::Error> {
let mut iter = args.into_iter();
let name_str = iter
.next()
.ok_or(crate::error::Error::MissingArgs("plugin name"))?;
let rest: Vec<String> = iter.collect();
let fs_client = objectiveai_sdk::filesystem::Client::new(
cli_config.config_base_dir.as_deref(),
cli_config.commit_author_name.as_deref(),
cli_config.commit_author_email.as_deref(),
);
let exe = match fs_client.resolve_plugin(&name_str).await {
Some(p) => p,
None => return Err(crate::error::Error::PluginNotFound(name_str)),
};
let mut child = tokio::process::Command::new(&exe)
.args(&rest)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(crate::error::Error::PluginSpawn)?;
let stdout = child.stdout.take().unwrap();
let stderr = child.stderr.take().unwrap();
let stderr_task = tokio::spawn(forward_stderr(stderr));
let mut command_tasks: Vec<JoinHandle<i32>> = Vec::new();
let mut reader = tokio::io::BufReader::new(stdout);
let mut line = String::new();
loop {
line.clear();
let n = reader
.read_line(&mut line)
.await
.map_err(crate::error::Error::PluginRead)?;
if n == 0 {
break;
}
let trimmed = line.trim_end_matches(['\r', '\n']);
match serde_json::from_str::<PluginOutput>(trimmed) {
Ok(PluginOutput::Error(e)) => {
Output::<serde_json::Value>::Error(e).emit(handle).await;
}
Ok(PluginOutput::Notification(value)) => {
Output::<serde_json::Value>::Notification(Notification { value })
.emit(handle)
.await;
}
Ok(PluginOutput::Command { command }) => {
command_tasks.push(spawn_command(command, cli_config, handle));
}
Err(_) => {
let value = serde_json::Value::String(trimmed.to_string());
Output::<serde_json::Value>::Notification(Notification { value })
.emit(handle)
.await;
}
}
}
for t in command_tasks {
let _ = t.await;
}
let _ = stderr_task.await;
let status = child
.wait()
.await
.map_err(crate::error::Error::PluginRead)?;
if status.success() {
Ok(())
} else {
Err(crate::error::Error::PluginExit(status.code().unwrap_or(1)))
}
}
fn spawn_command(
command: String,
cli_config: &crate::Config,
handle: &Handle,
) -> JoinHandle<i32> {
let tokens: Vec<String> = command.split_whitespace().map(String::from).collect();
let mut argv: Vec<String> = Vec::with_capacity(tokens.len() + 1);
argv.push(String::from("objectiveai"));
argv.extend(tokens);
let cfg = cli_config.clone();
let handle = handle.clone();
tokio::spawn(async move { crate::run::run(argv, &cfg, handle).await })
}
async fn forward_stderr(mut stderr: tokio::process::ChildStderr) {
use tokio::io::AsyncReadExt;
let mut buf = [0u8; 4096];
loop {
match stderr.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(n) => {
use std::io::Write;
let _ = std::io::stderr().write_all(&buf[..n]);
}
}
}
}