use anyhow::Result;
use lowfat_core::config::RunfConfig;
use lowfat_plugin::discovery::discover_plugins;
pub fn list() -> Result<()> {
let config = RunfConfig::resolve();
let plugins = discover_plugins(&config.plugin_dir);
if plugins.is_empty() {
println!("No community plugins installed.");
println!(" Plugin dir: {}", config.plugin_dir.display());
return Ok(());
}
println!("Community plugins:");
println!();
for plugin in &plugins {
let m = &plugin.manifest;
let name = &m.plugin.name;
let version = m.plugin.version.as_deref().unwrap_or("?");
let rt = format!("{:?}", m.runtime.runtime_type).to_lowercase();
let cmds = m.plugin.commands.join(", ");
let category = &plugin.category;
println!(
" {category}/{name} v{version} ({rt}) — commands: [{cmds}]"
);
}
Ok(())
}
pub fn doctor() -> Result<()> {
let config = RunfConfig::resolve();
let plugins = discover_plugins(&config.plugin_dir);
if plugins.is_empty() {
println!("No community plugins to check.");
return Ok(());
}
let mut ready = 0;
let mut total = 0;
for plugin in &plugins {
total += 1;
let name = &plugin.manifest.plugin.name;
let rt = format!("{:?}", plugin.manifest.runtime.runtime_type).to_lowercase();
let entry_path = plugin.base_dir.join(&plugin.manifest.runtime.entry);
if !entry_path.exists() {
println!(" {name} ({rt}) x entry not found: {}", entry_path.display());
continue;
}
let mut all_ok = true;
if let Some(ref requires) = plugin.manifest.runtime.requires {
if let Some(ref bins) = requires.bins {
for bin in bins {
if which(bin) {
println!(" {name} ({rt}) ok {bin}");
} else {
println!(" {name} ({rt}) x {bin} not found");
all_ok = false;
}
}
}
if let Some(ref optional) = requires.optional_bins {
for bin in optional {
if which(bin) {
println!(" {name} ({rt}) ok {bin} (optional)");
} else {
println!(" {name} ({rt}) - {bin} not found (optional)");
}
}
}
}
if all_ok {
println!(" {name} ({rt}) ok ready");
ready += 1;
}
}
println!();
println!(" {ready}/{total} plugins ready.");
Ok(())
}
pub fn info(name: &str) -> Result<()> {
let config = RunfConfig::resolve();
let plugins = discover_plugins(&config.plugin_dir);
let plugin = plugins
.iter()
.find(|p| p.manifest.plugin.name == name);
match plugin {
Some(p) => {
let m = &p.manifest;
println!("Plugin: {}", m.plugin.name);
println!(" Version: {}", m.plugin.version.as_deref().unwrap_or("?"));
println!(" Description: {}", m.plugin.description.as_deref().unwrap_or("-"));
println!(" Author: {}", m.plugin.author.as_deref().unwrap_or("-"));
println!(" Category: {}", p.category);
println!(" Runtime: {:?}", m.runtime.runtime_type);
println!(" Entry: {}", m.runtime.entry);
println!(" Commands: {}", m.plugin.commands.join(", "));
println!(" Path: {}", p.base_dir.display());
}
None => {
eprintln!("lowfat: plugin not found: {name}");
}
}
Ok(())
}
pub fn trust(name: &str) -> Result<()> {
let config = RunfConfig::resolve();
lowfat_plugin::security::trust_plugin(name, &config.home_dir)?;
println!("lowfat: plugin '{name}' is now trusted");
Ok(())
}
pub fn untrust(name: &str) -> Result<()> {
let config = RunfConfig::resolve();
lowfat_plugin::security::untrust_plugin(name, &config.home_dir)?;
println!("lowfat: trust revoked for plugin '{name}'");
Ok(())
}
pub fn new_plugin(name: &str, runtime: &str, commands: &str) -> Result<()> {
let config = RunfConfig::resolve();
let cmds: Vec<&str> = commands.split(',').map(|s| s.trim()).collect();
let category = cmds.first().unwrap_or(&"misc");
let plugin_dir = config.plugin_dir.join(category).join(name);
if plugin_dir.exists() {
anyhow::bail!("plugin already exists: {}", plugin_dir.display());
}
std::fs::create_dir_all(&plugin_dir)?;
let (entry, filter_code) = match runtime {
"shell" | "sh" => ("filter.sh", scaffold_shell(name, &cmds)),
"node" => ("filter.js", scaffold_node(name, &cmds)),
"python" => ("filter.py", scaffold_python(name, &cmds)),
"deno" => ("filter.ts", scaffold_deno(name, &cmds)),
"ruby" => ("filter.rb", scaffold_ruby(name, &cmds)),
"lua" => ("filter.lua", scaffold_lua(name, &cmds)),
"binary" | "custom" | "wasm" => {
("filter", String::new())
}
_ => anyhow::bail!("unknown runtime: {runtime} (use: shell, node, python, deno, ruby, lua, binary, custom)"),
};
let runtime_toml = match runtime {
"sh" => "shell",
other => other,
};
let cmds_toml: Vec<String> = cmds.iter().map(|c| format!("\"{}\"", c)).collect();
let io_section = if matches!(runtime_toml, "shell" | "ruby" | "lua") {
String::new()
} else {
"\n[input]\nformat = \"json\"\n\n[result]\nformat = \"json\"\n".to_string()
};
let manifest = format!(
r#"[plugin]
name = "{name}"
commands = [{cmds}]
[runtime]
type = "{runtime}"
entry = "{entry}"
{io}"#,
name = name,
cmds = cmds_toml.join(", "),
runtime = runtime_toml,
entry = entry,
io = io_section,
);
std::fs::write(plugin_dir.join("lowfat.toml"), manifest)?;
if !filter_code.is_empty() {
std::fs::write(plugin_dir.join(entry), &filter_code)?;
}
let samples_dir = plugin_dir.join("samples");
std::fs::create_dir_all(&samples_dir)?;
let sample_cmd = cmds.first().unwrap_or(&"cmd");
let sample_file = format!("{sample_cmd}-output-full.txt");
std::fs::write(
samples_dir.join(&sample_file),
"# Paste real command output here.\n# Filename convention: <command>-<subcommand>-<level>.txt\n# Run: lowfat plugin bench <name>\n",
)?;
lowfat_plugin::security::trust_plugin(name, &config.home_dir)?;
println!("lowfat: created plugin '{name}'");
println!(" {}", plugin_dir.display());
println!(" edit: {}", plugin_dir.join(entry).display());
println!(" bench: lowfat plugin bench {name}");
println!(" test: lowfat {} <args>", cmds.first().unwrap_or(&""));
Ok(())
}
fn scaffold_shell(_name: &str, _cmds: &[&str]) -> String {
r#"#!/bin/sh
# lowfat plugin — reads raw output from stdin, writes filtered output to stdout
# env: $LOWFAT_LEVEL (lite|full|ultra), $RUNF_COMMAND, $RUNF_SUBCOMMAND, $RUNF_EXIT_CODE
#
# Level convention (defaults to full if not handled):
# lite — gentle trim, keep most output (~60 lines)
# full — balanced, strip noise (~30 lines)
# ultra — summary only, minimal output (~10 lines)
LEVEL="${LOWFAT_LEVEL:-full}"
case "$LEVEL" in
lite) head -n 60 ;;
ultra) head -n 10 ;;
*) head -n 30 ;;
esac
"#
.to_string()
}
fn scaffold_ruby(_name: &str, _cmds: &[&str]) -> String {
r#"#!/usr/bin/env ruby
# lowfat plugin — reads raw output from stdin, writes filtered output to stdout
# env: LOWFAT_LEVEL (lite|full|ultra), RUNF_COMMAND, RUNF_SUBCOMMAND, RUNF_EXIT_CODE
#
# Level convention (defaults to full if not handled):
# lite — gentle trim, keep most output (~60 lines)
# full — balanced, strip noise (~30 lines)
# ultra — summary only, minimal output (~10 lines)
level = ENV["LOWFAT_LEVEL"] || "full"
lines = $stdin.readlines
limit = case level
when "lite" then 60
when "ultra" then 10
else 30
end
# TODO: filter the output
puts lines.first(limit)
"#
.to_string()
}
fn scaffold_lua(_name: &str, _cmds: &[&str]) -> String {
r#"-- lowfat plugin — reads raw output from stdin, writes filtered output to stdout
-- env: LOWFAT_LEVEL (lite|full|ultra), RUNF_COMMAND, RUNF_SUBCOMMAND, RUNF_EXIT_CODE
--
-- Level convention (defaults to full if not handled):
-- lite — gentle trim, keep most output (~60 lines)
-- full — balanced, strip noise (~30 lines)
-- ultra — summary only, minimal output (~10 lines)
local level = os.getenv("LOWFAT_LEVEL") or "full"
local limits = { lite = 60, full = 30, ultra = 10 }
local limit = limits[level] or 30
local count = 0
for line in io.lines() do
count = count + 1
if count > limit then break end
-- TODO: filter the output
print(line)
end
"#
.to_string()
}
fn scaffold_node(_name: &str, _cmds: &[&str]) -> String {
r#"// lowfat plugin — reads JSON from stdin, writes JSON to stdout
//
// Level convention (defaults to full if not handled):
// lite — gentle trim, keep most output (~60 lines)
// full — balanced, strip noise (~30 lines)
// ultra — summary only, minimal output (~10 lines)
const chunks = [];
process.stdin.on("data", (d) => chunks.push(d));
process.stdin.on("end", () => {
const input = JSON.parse(Buffer.concat(chunks).toString());
// input: { command, subcommand, args, level, exit_code, raw }
const limit = { lite: 60, full: 30, ultra: 10 }[input.level] || 30;
const lines = input.raw.split("\n");
// TODO: filter the output
const filtered = lines.slice(0, limit).join("\n");
process.stdout.write(JSON.stringify({ output: filtered }));
});
"#
.to_string()
}
fn scaffold_python(_name: &str, _cmds: &[&str]) -> String {
r#"#!/usr/bin/env python3
"""lowfat plugin — reads JSON from stdin, writes JSON to stdout"""
#
# Level convention (defaults to full if not handled):
# lite — gentle trim, keep most output (~60 lines)
# full — balanced, strip noise (~30 lines)
# ultra — summary only, minimal output (~10 lines)
import json, sys
input = json.load(sys.stdin)
# input: { command, subcommand, args, level, exit_code, raw }
limit = {"lite": 60, "full": 30, "ultra": 10}.get(input["level"], 30)
lines = input["raw"].split("\n")
# TODO: filter the output
filtered = "\n".join(lines[:limit])
json.dump({"output": filtered}, sys.stdout)
"#
.to_string()
}
fn scaffold_deno(_name: &str, _cmds: &[&str]) -> String {
r#"// lowfat plugin — reads JSON from stdin, writes JSON to stdout
//
// Level convention (defaults to full if not handled):
// lite — gentle trim, keep most output (~60 lines)
// full — balanced, strip noise (~30 lines)
// ultra — summary only, minimal output (~10 lines)
const buf = await Deno.readAll(Deno.stdin);
const input = JSON.parse(new TextDecoder().decode(buf));
// input: { command, subcommand, args, level, exit_code, raw }
const limit = { lite: 60, full: 30, ultra: 10 }[input.level] || 30;
const lines = input.raw.split("\n");
// TODO: filter the output
const filtered = lines.slice(0, limit).join("\n");
await Deno.writeAll(Deno.stdout, new TextEncoder().encode(JSON.stringify({ output: filtered })));
"#
.to_string()
}
pub fn bench(name: &str) -> Result<()> {
let config = RunfConfig::resolve();
let plugins = discover_plugins(&config.plugin_dir);
let plugin = plugins
.iter()
.find(|p| p.manifest.plugin.name == name);
let plugin = match plugin {
Some(p) => p,
None => {
anyhow::bail!("plugin not found: {name} (install it to ~/.lowfat/plugins/ first)");
}
};
let samples_dir = plugin.base_dir.join("samples");
if !samples_dir.is_dir() {
anyhow::bail!("no samples/ directory in plugin '{name}' — add .txt files with sample command output");
}
let mut entries: Vec<_> = std::fs::read_dir(&samples_dir)?
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map_or(false, |ext| ext == "txt"))
.collect();
entries.sort_by_key(|e| e.path());
if entries.is_empty() {
anyhow::bail!("no .txt sample files in {}", samples_dir.display());
}
let process_filter = lowfat_runner::process::ProcessFilter {
info: lowfat_plugin::plugin::PluginInfo {
name: plugin.manifest.plugin.name.clone(),
version: plugin.manifest.plugin.version.clone().unwrap_or_default(),
commands: plugin.manifest.plugin.commands.clone(),
subcommands: plugin.manifest.plugin.subcommands.clone().unwrap_or_default(),
},
runtime_type: plugin.manifest.runtime.runtime_type,
entry: plugin.base_dir.join(&plugin.manifest.runtime.entry),
base_dir: plugin.base_dir.clone(),
custom_command: plugin.manifest.runtime.command.clone(),
input_format: plugin.manifest.input.as_ref()
.and_then(|i| i.format.clone()).unwrap_or_else(|| "raw".into()),
result_format: plugin.manifest.result.as_ref()
.and_then(|r| r.format.clone()).unwrap_or_else(|| "raw".into()),
};
println!("Benchmark: {name}");
println!();
let mut total_raw = 0usize;
let mut total_filtered = 0usize;
for entry in &entries {
let path = entry.path();
let sample_name = path.file_stem().unwrap_or_default().to_string_lossy();
let parts: Vec<&str> = sample_name.split('-').collect();
let (command, subcommand, level_str) = match parts.len() {
1 => (parts[0], "", "full"),
2 => (parts[0], parts[1], "full"),
_ => (parts[0], parts[1], parts[parts.len() - 1]),
};
let level = match level_str {
"lite" => lowfat_core::level::Level::Lite,
"ultra" => lowfat_core::level::Level::Ultra,
_ => lowfat_core::level::Level::Full,
};
let raw = std::fs::read_to_string(&path)?;
let raw_tokens = lowfat_core::tokens::estimate_tokens(&raw);
let input = lowfat_plugin::plugin::FilterInput {
raw: raw.clone(),
command: command.to_string(),
subcommand: subcommand.to_string(),
args: vec![],
level,
head_limit: level.head_limit(40),
exit_code: 0,
};
use lowfat_plugin::plugin::FilterPlugin;
let result = process_filter.filter(&input)?;
let filtered_tokens = lowfat_core::tokens::estimate_tokens(&result.text);
let pct = if raw_tokens > 0 {
(1.0 - filtered_tokens as f64 / raw_tokens as f64) * 100.0
} else {
0.0
};
total_raw += raw_tokens;
total_filtered += filtered_tokens;
println!(
" {:<30} {:>6} → {:>6} tokens ({:>-3.0}%)",
format!("{sample_name} ({level})"), raw_tokens, filtered_tokens, -pct
);
}
if total_raw > 0 {
let total_pct = (1.0 - total_filtered as f64 / total_raw as f64) * 100.0;
println!();
println!(
" {:<30} {:>6} → {:>6} tokens ({:>-3.0}%)",
"TOTAL", total_raw, total_filtered, -total_pct
);
}
Ok(())
}
fn which(bin: &str) -> bool {
std::process::Command::new("which")
.arg(bin)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}