Frais is a traditional CLI tool enhanced with LLM, not an AI agent. It cannot orchestrate
multi-step tasks, call external tools autonomously, or maintain conversational context.
The --json output provides structured, machine-readable data that can be consumed
by external tools — build a GUI, feed results to another program, or integrate with
an LLM-based workflow.
Built in Rust with clap, reqwest, rayon, and serde. macOS only.
# Build
cargo build --release
# Run
./target/release/frais doctor
./target/release/frais config manageOr install to ~/.frais/bin/:
bash scripts/install_frais.sh
frais doctorLLM features require user-owned configuration in ~/.frais/config/config.toml. The project never ships or creates a server-side API key. Run frais config manage for interactive setup — no manual file editing needed.
External ──▶ frais doctor --json (system readiness)
tools / frais config show --json (redacted config)
GUI / frais config test --json (connection validation)
scripts frais config path --json (config file location)
frais plugins list --json (plugin inventory)
frais ignore list --json (exclusion list)
frais scan --json (structured scan output)
frais summarize <id> --json (single AI summary)
User ──▶ frais advise (scan + summarize + terminal UI)
frais update (interactive execution)
frais config manage (interactive setup)
frais plugins enable/disable (plugin management)
Internal:
cli/ assembles clap commands
cli/ ──▶ coordinator.rs ──▶ plugins/
store/ handles config, plugin state, ignore list, and scan cache
web_tools.rs handles search (DDGS + provider web_search strategy) and fetch for application research
Scan layer — each plugin discovers installed software via its scan() / scan_all() methods. Homebrew and NPM plugins can directly identify outdated packages from their package managers. Plugins scan concurrently via std::thread::scope.
Research layer (plugin-private) — ApplicationsPlugin.scan() runs a structured 3-step LLM pipeline: generate search queries → pick best URLs → extract version. Web search uses web_search_strategy() which calls the provider's server-side search when available (DeepSeek Anthropic), falling back to DDGS otherwise. App Store apps use the iTunes API directly (~1s per item). The iTunes fast path (app_store.rs) and the LLM pipeline (research/) are private to the applications plugin. Research runs in parallel via rayon::ThreadPool.
Update layer — each plugin provides its own update() method. Homebrew runs brew upgrade, NPM runs npm install -g, and Applications resolve App Store deep links or prompt to open the .app bundle.
All commands that accept --json follow a unified LLM Agent Contract: success {"ok": true, ...}, error {"ok": false, "error": "...", "reason": "<enum>", "hint": "..."}. The reason field is a stable machine-readable enum that an LLM agent can branch on deterministically; the hint field tells the agent what action to take next. IDs round-trip between commands (scan → summarize → update). Without --json, commands emit console-styled terminal output. See CLAUDE.md for the full contract specification.
frais doctor
frais doctor --jsonShows system info, plugin availability, and LLM configuration status.
frais config manage # Interactive setup wizard
frais config show --json # Show provider/model/key_source
frais config test --json # Validate connection with a test chat
frais config path --json # Print config file locationfrais plugins list --json
frais plugins enable <name>
frais plugins disable <name>frais ignore list --json
frais ignore add <app_id>
frais ignore remove <app_id>frais scan --json
frais scan --json --plugins applications --allStructured scan output for LLM agent consumption. Includes system, plugin_results with items and candidates. Saves cache to ~/.frais/cache/last_advice.json for summarize and update.
frais advise
frais advise --all
frais advise --json
frais advise --plugins homebrew,npm -j 5Full pipeline: scan → ignore filter → AI summaries → display. Requires configured LLM provider.
frais summarize com.google.Chrome
frais summarize brew:node --jsonGenerate an AI summary for a single candidate from a previous scan/advise.
frais update
frais update brew:nodeInteractive update execution. Displays candidates, AI analysis, then prompts for confirmation.
// Success:
{"ok": true, ...command-specific fields}
// Error:
{"ok": false, "error": "<what happened>", "reason": "<stable enum>", "hint": "<what to do next>", ...context}reason |
Meaning | Commands |
|---|---|---|
config_missing |
No LLM provider configured | advise, summarize, config test |
connection_error |
LLM connection failed | config test, summarize |
no_plugins_matched |
All requested plugins unavailable or disabled | scan, advise |
unknown_plugin |
Plugin name not in registry | plugins enable/disable |
plugin_not_found |
Plugin from cache no longer installed | summarize |
no_cache |
No scan cache file exists | summarize |
cache_read_error |
Cache file exists but is corrupt | summarize |
candidate_not_found |
ID not in cached scan results | summarize |
{
"ok": true,
"version": "0.1.0",
"system": {
"os_name": "macOS",
"os_version": "26.5",
"arch": "arm64"
},
"plugins": {
"<name>": {
"available": "yes|no",
"default": "enabled|disabled"
}
},
"llm": null | {
"configured": true,
"provider": "DeepSeek",
"model": "deepseek-v4-flash",
"protocol": "openai",
"url": "https://api.deepseek.com/anthropic",
"key_suffix": "***abcd"
}
}{"ok": true, "configured": false}
// When configured:
{
"ok": true,
"configured": true,
"provider": "DeepSeek",
"model": "deepseek-v4-flash",
"key_suffix": "***abcd",
"key_source": "env:FRAIS_LLM_API_KEY"
}// Success:
{"ok": true, "provider": "DeepSeek", "model": "deepseek-v4-flash", "url": "https://api.deepseek.com", "response": "..."}
// Error:
{"ok": false, "error": "...", "reason": "config_missing|connection_error", "hint": "..."}{"ok": true, "path": "/Users/.../.frais/config/config.toml"}{
"ok": true,
"plugins": [
{
"name": "applications",
"available": "yes|no",
"default": "enabled|disabled",
"effective": "enabled|disabled"
}
]
}// Success:
{"ok": true, "plugin": "homebrew", "action": "enabled|disabled"}
// Error:
{"ok": false, "error": "Unknown plugin: <name>", "reason": "unknown_plugin", "hint": "..."}{
"ok": true,
"ignored": ["com.example.app", "npm:example"],
"count": 2
}// add:
{"ok": true, "app_id": "com.example.app", "action": "added"}
{"ok": true, "app_id": "com.example.app", "action": "already_ignored"}
// remove:
{"ok": true, "app_id": "com.example.app", "action": "removed"}
{"ok": true, "app_id": "com.example.app", "action": "not_in_list"}Both output a serialized ScanResult in the success envelope:
{
"ok": true,
"system": {
"os_name": "macOS",
"os_version": "26.5",
"arch": "arm64",
"applications_paths": ["/Applications", "~/Applications"]
},
"plugin_results": {
"<plugin_name>": {
"items": [
{
"id": "com.google.Chrome",
"name": "Google Chrome",
"kind": "application",
"source": "network download",
"current_version": "131.0.6778.265",
"path": "/Applications/Google Chrome.app",
"metadata": {}
}
],
"candidates": [
{
"item": { "...": "..." },
"latest_version": "132.0.6834.210",
"release_notes": null,
"dependency_impact": {
"used_by": [],
"depends_on": [],
"impact_level": "unknown"
},
"risk_level": "low",
"ai_summary": "## What's New\n...",
"recommended_action": "Update",
"can_auto_update": true,
"command": ["brew", "upgrade", "google-chrome"],
"evidence": ["https://..."]
}
],
"skipped": ["reason"]
}
}
}// Success:
{"ok": true, "item_id": "com.google.Chrome", "ai_summary": "## What's New\n..."}
// Error:
{"ok": false, "error": "No scan cache found.", "reason": "no_cache", "hint": "..."}
{"ok": false, "error": "No candidate found for: <id>", "reason": "candidate_not_found", "hint": "...", "item_id": "<id>"}Plugins implement the ScannerPlugin trait and register via linkme::distributed_slice.
use frais_lib::plugins::ScannerPlugin;
use frais_lib::models::{PluginScanResult, SoftwareItem, SystemProfile, UpdateCandidate};
struct MyPlugin;
impl ScannerPlugin for MyPlugin {
fn name(&self) -> &'static str { "my-manager" }
fn enabled_by_default(&self) -> bool { true }
fn scan_steps(&self) -> &'static [&'static str] { &["checking outdated packages"] }
fn is_available(&self) -> bool { true }
fn scan(
&self,
system: &SystemProfile,
on_progress: Option<&(dyn Fn(usize, usize, usize) + Sync)>,
max_workers: usize,
) -> PluginScanResult {
// Discover and build items + candidates
PluginScanResult { items, candidates, skipped: vec![] }
}
}
// Register:
#[linkme::distributed_slice(frais_lib::plugins::registry::PLUGINS)]
static PLUGIN: fn() -> (&'static str, Box<dyn ScannerPlugin>) = || {
("my-manager", Box::new(MyPlugin))
};Plugins are discovered at compile time via linkme::distributed_slice. Add your plugin module to the build, and its static registration will be picked up automatically. No runtime discovery or config files needed — frais plugins list will show it.
frais --debug advise
frais --log-file ./my.log advise
frais --no-log adviseLogs are written to ~/.frais/log/frais.log by default. Log files auto-rotate at 50 MB.
All data (config, logs, cache) lives under ~/.frais/. Set FRAIS_HOME to override:
FRAIS_HOME=/custom/path frais doctorcargo test
cargo test test_doctor_json_output24 integration tests via assert_cmd + predicates, plus inline unit tests in each module.
cargo build --release
./target/release/frais doctorInstall to ~/.frais/bin/:
bash scripts/install_frais.sh
frais doctorThe binary contains no API keys or secrets. LLM access uses the provider config in ~/.frais/config/config.toml.