mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-07-30 23:05:08 +00:00
6951 lines
237 KiB
Rust
6951 lines
237 KiB
Rust
//! OpenFang CLI — command-line interface for the OpenFang Agent OS.
|
|
//!
|
|
//! When a daemon is running (`openfang start`), the CLI talks to it over HTTP.
|
|
//! Otherwise, commands boot an in-process kernel (single-shot mode).
|
|
|
|
mod bundled_agents;
|
|
mod dotenv;
|
|
mod launcher;
|
|
mod mcp;
|
|
pub mod progress;
|
|
pub mod table;
|
|
mod templates;
|
|
mod tui;
|
|
mod ui;
|
|
|
|
use clap::{Parser, Subcommand};
|
|
use colored::Colorize;
|
|
use openfang_api::server::read_daemon_info;
|
|
use openfang_kernel::OpenFangKernel;
|
|
use openfang_types::agent::{AgentId, AgentManifest};
|
|
use std::io::{self, BufRead, Write};
|
|
use std::path::PathBuf;
|
|
use std::sync::atomic::AtomicBool;
|
|
#[cfg(windows)]
|
|
use std::sync::atomic::Ordering;
|
|
|
|
/// Global flag set by the Ctrl+C handler.
|
|
static CTRLC_PRESSED: AtomicBool = AtomicBool::new(false);
|
|
|
|
/// Install a Ctrl+C handler that force-exits the process.
|
|
/// On Windows/MINGW, the default handler doesn't reliably interrupt blocking
|
|
/// `read_line` calls, so we explicitly call `process::exit`.
|
|
fn install_ctrlc_handler() {
|
|
#[cfg(windows)]
|
|
{
|
|
extern "system" {
|
|
fn SetConsoleCtrlHandler(
|
|
handler: Option<unsafe extern "system" fn(u32) -> i32>,
|
|
add: i32,
|
|
) -> i32;
|
|
}
|
|
unsafe extern "system" fn handler(_ctrl_type: u32) -> i32 {
|
|
if CTRLC_PRESSED.swap(true, Ordering::SeqCst) {
|
|
// Second press: hard exit
|
|
std::process::exit(130);
|
|
}
|
|
// First press: print message and exit cleanly
|
|
let _ = std::io::Write::write_all(&mut std::io::stderr(), b"\nInterrupted.\n");
|
|
std::process::exit(0);
|
|
}
|
|
unsafe { SetConsoleCtrlHandler(Some(handler), 1) };
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
{
|
|
// On Unix, the default SIGINT handler already interrupts read_line
|
|
// and terminates the process.
|
|
let _ = &CTRLC_PRESSED;
|
|
}
|
|
}
|
|
|
|
const AFTER_HELP: &str = "\
|
|
\x1b[1mHint:\x1b[0m Commands suffixed with [*] have subcommands. Run `<command> --help` for details.
|
|
|
|
\x1b[1;36mExamples:\x1b[0m
|
|
openfang init Initialize config and data directories
|
|
openfang start Start the kernel daemon
|
|
openfang tui Launch the interactive terminal dashboard
|
|
openfang chat Quick chat with the default agent
|
|
openfang agent new coder Spawn a new agent from a template
|
|
openfang models list Browse available LLM models
|
|
openfang add github Install the GitHub integration
|
|
openfang doctor Run diagnostic health checks
|
|
openfang channel setup Interactive channel setup wizard
|
|
openfang cron list List scheduled jobs
|
|
openfang uninstall Completely remove OpenFang from your system
|
|
|
|
\x1b[1;36mQuick Start:\x1b[0m
|
|
1. openfang init Set up config + API key
|
|
2. openfang start Launch the daemon
|
|
3. openfang chat Start chatting!
|
|
|
|
\x1b[1;36mMore:\x1b[0m
|
|
Docs: https://github.com/RightNow-AI/openfang
|
|
Dashboard: http://127.0.0.1:4200/ (when daemon is running)";
|
|
|
|
/// OpenFang — the open-source Agent Operating System.
|
|
#[derive(Parser)]
|
|
#[command(
|
|
name = "openfang",
|
|
version,
|
|
about = "\u{1F40D} OpenFang \u{2014} Open-source Agent Operating System",
|
|
long_about = "\u{1F40D} OpenFang \u{2014} Open-source Agent Operating System\n\n\
|
|
Deploy, manage, and orchestrate AI agents from your terminal.\n\
|
|
40 channels \u{00b7} 60 skills \u{00b7} 50+ models \u{00b7} infinite possibilities.",
|
|
after_help = AFTER_HELP,
|
|
)]
|
|
struct Cli {
|
|
/// Path to config file.
|
|
#[arg(long, global = true)]
|
|
config: Option<PathBuf>,
|
|
|
|
#[command(subcommand)]
|
|
command: Option<Commands>,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum Commands {
|
|
/// Initialize OpenFang (create ~/.openfang/ and default config).
|
|
Init {
|
|
/// Quick mode: no prompts, just write config + .env (for CI/scripts).
|
|
#[arg(long)]
|
|
quick: bool,
|
|
},
|
|
/// Start the OpenFang kernel daemon (API server + kernel).
|
|
Start {
|
|
/// Auto-approve all tool calls (no confirmation prompts).
|
|
#[arg(long)]
|
|
yolo: bool,
|
|
},
|
|
/// Stop the running daemon.
|
|
Stop,
|
|
/// Manage agents (new, list, chat, kill, spawn) [*].
|
|
#[command(subcommand)]
|
|
Agent(AgentCommands),
|
|
/// Manage workflows (list, create, run) [*].
|
|
#[command(subcommand)]
|
|
Workflow(WorkflowCommands),
|
|
/// Manage event triggers (list, create, delete) [*].
|
|
#[command(subcommand)]
|
|
Trigger(TriggerCommands),
|
|
/// Migrate from another agent framework to OpenFang.
|
|
Migrate(MigrateArgs),
|
|
/// Manage skills (install, list, search, create, remove) [*].
|
|
#[command(subcommand)]
|
|
Skill(SkillCommands),
|
|
/// Manage channel integrations (setup, test, enable, disable) [*].
|
|
#[command(subcommand)]
|
|
Channel(ChannelCommands),
|
|
/// Manage hands (list, activate, deactivate, info) [*].
|
|
#[command(subcommand)]
|
|
Hand(HandCommands),
|
|
/// Show or edit configuration (show, edit, get, set, keys) [*].
|
|
#[command(subcommand)]
|
|
Config(ConfigCommands),
|
|
/// Quick chat with the default agent.
|
|
Chat {
|
|
/// Optional agent name or ID to chat with.
|
|
agent: Option<String>,
|
|
},
|
|
/// Show kernel status.
|
|
Status {
|
|
/// Output as JSON for scripting.
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Run diagnostic health checks.
|
|
Doctor {
|
|
/// Output as JSON for scripting.
|
|
#[arg(long)]
|
|
json: bool,
|
|
/// Attempt to auto-fix issues (create missing dirs/config).
|
|
#[arg(long)]
|
|
repair: bool,
|
|
},
|
|
/// Open the web dashboard in the default browser.
|
|
Dashboard,
|
|
/// Generate shell completion scripts.
|
|
Completion {
|
|
/// Shell to generate completions for.
|
|
#[arg(value_enum)]
|
|
shell: clap_complete::Shell,
|
|
},
|
|
/// Start MCP (Model Context Protocol) server over stdio.
|
|
Mcp,
|
|
/// Add an integration (one-click MCP server setup).
|
|
Add {
|
|
/// Integration name (e.g., "github", "slack", "notion").
|
|
name: String,
|
|
/// API key or token to store in the vault.
|
|
#[arg(long)]
|
|
key: Option<String>,
|
|
},
|
|
/// Remove an installed integration.
|
|
Remove {
|
|
/// Integration name.
|
|
name: String,
|
|
},
|
|
/// List or search integrations.
|
|
Integrations {
|
|
/// Search query (optional — lists all if omitted).
|
|
query: Option<String>,
|
|
},
|
|
/// Manage the credential vault (init, set, list, remove) [*].
|
|
#[command(subcommand)]
|
|
Vault(VaultCommands),
|
|
/// Scaffold a new skill or integration template.
|
|
New {
|
|
/// What to scaffold.
|
|
#[arg(value_enum)]
|
|
kind: ScaffoldKind,
|
|
},
|
|
/// Launch the interactive terminal dashboard.
|
|
Tui,
|
|
/// Browse models, aliases, and providers [*].
|
|
#[command(subcommand)]
|
|
Models(ModelsCommands),
|
|
/// Daemon control (start, stop, status) [*].
|
|
#[command(subcommand)]
|
|
Gateway(GatewayCommands),
|
|
/// Manage execution approvals (list, approve, reject) [*].
|
|
#[command(subcommand)]
|
|
Approvals(ApprovalsCommands),
|
|
/// Manage scheduled jobs (list, create, delete, enable, disable) [*].
|
|
#[command(subcommand)]
|
|
Cron(CronCommands),
|
|
/// List conversation sessions.
|
|
Sessions {
|
|
/// Optional agent name or ID to filter by.
|
|
agent: Option<String>,
|
|
/// Output as JSON for scripting.
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Tail the OpenFang log file.
|
|
Logs {
|
|
/// Number of lines to show.
|
|
#[arg(long, default_value = "50")]
|
|
lines: usize,
|
|
/// Follow log output in real time.
|
|
#[arg(long, short)]
|
|
follow: bool,
|
|
},
|
|
/// Quick daemon health check.
|
|
Health {
|
|
/// Output as JSON for scripting.
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Security tools and audit trail [*].
|
|
#[command(subcommand)]
|
|
Security(SecurityCommands),
|
|
/// Search and manage agent memory (KV store) [*].
|
|
#[command(subcommand)]
|
|
Memory(MemoryCommands),
|
|
/// Device pairing and token management [*].
|
|
#[command(subcommand)]
|
|
Devices(DevicesCommands),
|
|
/// Generate device pairing QR code.
|
|
Qr,
|
|
/// Webhook helpers and trigger management [*].
|
|
#[command(subcommand)]
|
|
Webhooks(WebhooksCommands),
|
|
/// Interactive onboarding wizard.
|
|
Onboard {
|
|
/// Quick non-interactive mode.
|
|
#[arg(long)]
|
|
quick: bool,
|
|
},
|
|
/// Quick non-interactive initialization.
|
|
Setup {
|
|
/// Quick mode (same as `init --quick`).
|
|
#[arg(long)]
|
|
quick: bool,
|
|
},
|
|
/// Interactive setup wizard for credentials and channels.
|
|
Configure,
|
|
/// Send a one-shot message to an agent.
|
|
Message {
|
|
/// Agent name or ID.
|
|
agent: String,
|
|
/// Message text.
|
|
text: String,
|
|
/// Output as JSON for scripting.
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// System info and version [*].
|
|
#[command(subcommand)]
|
|
System(SystemCommands),
|
|
/// Reset local config and state.
|
|
Reset {
|
|
/// Skip confirmation prompt.
|
|
#[arg(long)]
|
|
confirm: bool,
|
|
},
|
|
/// Completely uninstall OpenFang from your system.
|
|
Uninstall {
|
|
/// Skip confirmation prompt (also --yes).
|
|
#[arg(long, alias = "yes")]
|
|
confirm: bool,
|
|
/// Keep config files (config.toml, .env, secrets.env).
|
|
#[arg(long)]
|
|
keep_config: bool,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum VaultCommands {
|
|
/// Initialize the credential vault.
|
|
Init,
|
|
/// Store a credential in the vault.
|
|
Set {
|
|
/// Credential key (env var name).
|
|
key: String,
|
|
},
|
|
/// List all keys in the vault (values are hidden).
|
|
List,
|
|
/// Remove a credential from the vault.
|
|
Remove {
|
|
/// Credential key.
|
|
key: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Clone, clap::ValueEnum)]
|
|
enum ScaffoldKind {
|
|
Skill,
|
|
Integration,
|
|
}
|
|
|
|
#[derive(clap::Args)]
|
|
struct MigrateArgs {
|
|
/// Source framework to migrate from.
|
|
#[arg(long, value_enum)]
|
|
from: MigrateSourceArg,
|
|
/// Path to the source workspace (auto-detected if not set).
|
|
#[arg(long)]
|
|
source_dir: Option<PathBuf>,
|
|
/// Dry run — show what would be imported without making changes.
|
|
#[arg(long)]
|
|
dry_run: bool,
|
|
}
|
|
|
|
#[derive(Clone, clap::ValueEnum)]
|
|
enum MigrateSourceArg {
|
|
Openclaw,
|
|
Langchain,
|
|
Autogpt,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum SkillCommands {
|
|
/// Install a skill from FangHub or a local directory.
|
|
Install {
|
|
/// Skill name, local path, or git URL.
|
|
source: String,
|
|
},
|
|
/// List installed skills.
|
|
List,
|
|
/// Remove an installed skill.
|
|
Remove {
|
|
/// Skill name.
|
|
name: String,
|
|
},
|
|
/// Search FangHub for skills.
|
|
Search {
|
|
/// Search query.
|
|
query: String,
|
|
},
|
|
/// Create a new skill scaffold.
|
|
Create,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum ChannelCommands {
|
|
/// List configured channels and their status.
|
|
List,
|
|
/// Interactive setup wizard for a channel.
|
|
Setup {
|
|
/// Channel name (telegram, discord, slack, whatsapp, etc.). Shows picker if omitted.
|
|
channel: Option<String>,
|
|
},
|
|
/// Test a channel by sending a test message.
|
|
Test {
|
|
/// Channel name.
|
|
channel: String,
|
|
},
|
|
/// Enable a channel.
|
|
Enable {
|
|
/// Channel name.
|
|
channel: String,
|
|
},
|
|
/// Disable a channel without removing its configuration.
|
|
Disable {
|
|
/// Channel name.
|
|
channel: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum HandCommands {
|
|
/// List all available hands.
|
|
List,
|
|
/// Show currently active hand instances.
|
|
Active,
|
|
/// Install a hand from a local directory containing HAND.toml.
|
|
Install {
|
|
/// Path to the hand directory (must contain HAND.toml).
|
|
path: String,
|
|
},
|
|
/// Activate a hand by ID.
|
|
Activate {
|
|
/// Hand ID (e.g. "clip", "lead", "researcher").
|
|
id: String,
|
|
},
|
|
/// Deactivate an active hand instance.
|
|
Deactivate {
|
|
/// Hand ID.
|
|
id: String,
|
|
},
|
|
/// Show detailed info about a hand.
|
|
Info {
|
|
/// Hand ID.
|
|
id: String,
|
|
},
|
|
/// Check dependency status for a hand.
|
|
CheckDeps {
|
|
/// Hand ID.
|
|
id: String,
|
|
},
|
|
/// Install missing dependencies for a hand.
|
|
InstallDeps {
|
|
/// Hand ID.
|
|
id: String,
|
|
},
|
|
/// Pause a running hand instance.
|
|
Pause {
|
|
/// Instance ID (from `hand active`).
|
|
id: String,
|
|
},
|
|
/// Resume a paused hand instance.
|
|
Resume {
|
|
/// Instance ID (from `hand active`).
|
|
id: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum ConfigCommands {
|
|
/// Show the current configuration.
|
|
Show,
|
|
/// Open the configuration file in your editor.
|
|
Edit,
|
|
/// Get a config value by dotted key path (e.g. "default_model.provider").
|
|
Get {
|
|
/// Dotted key path (e.g. "default_model.provider", "api_listen").
|
|
key: String,
|
|
},
|
|
/// Set a config value (warning: strips TOML comments).
|
|
Set {
|
|
/// Dotted key path.
|
|
key: String,
|
|
/// New value.
|
|
value: String,
|
|
},
|
|
/// Remove a config key (warning: strips TOML comments).
|
|
Unset {
|
|
/// Dotted key path to remove (e.g. "api.cors_origin").
|
|
key: String,
|
|
},
|
|
/// Save an API key to ~/.openfang/.env (prompts interactively).
|
|
SetKey {
|
|
/// Provider name (groq, anthropic, openai, gemini, deepseek, etc.).
|
|
provider: String,
|
|
},
|
|
/// Remove an API key from ~/.openfang/.env.
|
|
DeleteKey {
|
|
/// Provider name.
|
|
provider: String,
|
|
},
|
|
/// Test provider connectivity with the stored API key.
|
|
TestKey {
|
|
/// Provider name.
|
|
provider: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum AgentCommands {
|
|
/// Spawn a new agent from a template (interactive or by name).
|
|
New {
|
|
/// Template name (e.g., "coder", "assistant"). Interactive picker if omitted.
|
|
template: Option<String>,
|
|
},
|
|
/// Spawn a new agent from a manifest file.
|
|
Spawn {
|
|
/// Path to the agent manifest TOML file.
|
|
manifest: PathBuf,
|
|
},
|
|
/// List all running agents.
|
|
List {
|
|
/// Output as JSON for scripting.
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Interactive chat with an agent.
|
|
Chat {
|
|
/// Agent ID (UUID).
|
|
agent_id: String,
|
|
},
|
|
/// Kill an agent.
|
|
Kill {
|
|
/// Agent ID (UUID).
|
|
agent_id: String,
|
|
},
|
|
/// Set an agent property (e.g., model).
|
|
Set {
|
|
/// Agent ID (UUID).
|
|
agent_id: String,
|
|
/// Field to set (model).
|
|
field: String,
|
|
/// New value.
|
|
value: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum WorkflowCommands {
|
|
/// List all registered workflows.
|
|
List,
|
|
/// Create a workflow from a JSON file.
|
|
Create {
|
|
/// Path to a JSON file describing the workflow.
|
|
file: PathBuf,
|
|
},
|
|
/// Get a workflow by ID.
|
|
Get {
|
|
/// Workflow ID (UUID).
|
|
workflow_id: String,
|
|
},
|
|
/// Update a workflow from a JSON file.
|
|
Update {
|
|
/// Workflow ID (UUID).
|
|
workflow_id: String,
|
|
/// Path to a JSON file with the updated workflow definition.
|
|
file: PathBuf,
|
|
},
|
|
/// Delete a workflow by ID.
|
|
Delete {
|
|
/// Workflow ID (UUID).
|
|
workflow_id: String,
|
|
},
|
|
/// Run a workflow by ID.
|
|
Run {
|
|
/// Workflow ID (UUID).
|
|
workflow_id: String,
|
|
/// Input text for the workflow.
|
|
input: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum TriggerCommands {
|
|
/// List all triggers (optionally filtered by agent).
|
|
List {
|
|
/// Optional agent ID to filter by.
|
|
#[arg(long)]
|
|
agent_id: Option<String>,
|
|
},
|
|
/// Create a trigger for an agent.
|
|
Create {
|
|
/// Agent ID (UUID) that owns the trigger.
|
|
agent_id: String,
|
|
/// Trigger pattern as JSON (e.g. '{"lifecycle":{}}' or '{"agent_spawned":{"name_pattern":"*"}}').
|
|
pattern_json: String,
|
|
/// Prompt template (use {{event}} placeholder).
|
|
#[arg(long, default_value = "Event: {{event}}")]
|
|
prompt: String,
|
|
/// Maximum number of times to fire (0 = unlimited).
|
|
#[arg(long, default_value = "0")]
|
|
max_fires: u64,
|
|
},
|
|
/// Delete a trigger by ID.
|
|
Delete {
|
|
/// Trigger ID (UUID).
|
|
trigger_id: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum ModelsCommands {
|
|
/// List available models (optionally filter by provider).
|
|
List {
|
|
/// Filter by provider name.
|
|
#[arg(long)]
|
|
provider: Option<String>,
|
|
/// Output as JSON for scripting.
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Show model aliases (shorthand names).
|
|
Aliases {
|
|
/// Output as JSON for scripting.
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// List known LLM providers and their auth status.
|
|
Providers {
|
|
/// Output as JSON for scripting.
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Set the default model for the daemon.
|
|
Set {
|
|
/// Model ID or alias (e.g. "gpt-4o", "claude-sonnet"). Interactive picker if omitted.
|
|
model: Option<String>,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum GatewayCommands {
|
|
/// Start the kernel daemon.
|
|
Start,
|
|
/// Stop the running daemon.
|
|
Stop,
|
|
/// Show daemon status.
|
|
Status {
|
|
/// Output as JSON for scripting.
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum ApprovalsCommands {
|
|
/// List pending approvals.
|
|
List {
|
|
/// Output as JSON for scripting.
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Approve a pending request.
|
|
Approve {
|
|
/// Approval ID.
|
|
id: String,
|
|
},
|
|
/// Reject a pending request.
|
|
Reject {
|
|
/// Approval ID.
|
|
id: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum CronCommands {
|
|
/// List scheduled jobs.
|
|
List {
|
|
/// Output as JSON for scripting.
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Create a new scheduled job.
|
|
Create {
|
|
/// Agent name or ID to run.
|
|
agent: String,
|
|
/// Cron expression (e.g. "0 */6 * * *").
|
|
spec: String,
|
|
/// Prompt to send when the job fires.
|
|
prompt: String,
|
|
/// Optional job name (auto-generated if omitted).
|
|
#[arg(long)]
|
|
name: Option<String>,
|
|
},
|
|
/// Delete a scheduled job.
|
|
Delete {
|
|
/// Job ID.
|
|
id: String,
|
|
},
|
|
/// Enable a disabled job.
|
|
Enable {
|
|
/// Job ID.
|
|
id: String,
|
|
},
|
|
/// Disable a job without deleting it.
|
|
Disable {
|
|
/// Job ID.
|
|
id: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum SecurityCommands {
|
|
/// Show security status summary.
|
|
Status {
|
|
/// Output as JSON for scripting.
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Show recent audit trail entries.
|
|
Audit {
|
|
/// Maximum number of entries to show.
|
|
#[arg(long, default_value = "20")]
|
|
limit: usize,
|
|
/// Output as JSON for scripting.
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Verify audit trail integrity (Merkle chain).
|
|
Verify,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum MemoryCommands {
|
|
/// List KV pairs for an agent.
|
|
List {
|
|
/// Agent name or ID.
|
|
agent: String,
|
|
/// Output as JSON for scripting.
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Get a specific KV value.
|
|
Get {
|
|
/// Agent name or ID.
|
|
agent: String,
|
|
/// Key name.
|
|
key: String,
|
|
/// Output as JSON for scripting.
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Set a KV value.
|
|
Set {
|
|
/// Agent name or ID.
|
|
agent: String,
|
|
/// Key name.
|
|
key: String,
|
|
/// Value to store.
|
|
value: String,
|
|
},
|
|
/// Delete a KV pair.
|
|
Delete {
|
|
/// Agent name or ID.
|
|
agent: String,
|
|
/// Key name.
|
|
key: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum DevicesCommands {
|
|
/// List paired devices.
|
|
List {
|
|
/// Output as JSON for scripting.
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Start a new device pairing flow.
|
|
Pair,
|
|
/// Remove a paired device.
|
|
Remove {
|
|
/// Device ID.
|
|
id: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum WebhooksCommands {
|
|
/// List configured webhooks.
|
|
List {
|
|
/// Output as JSON for scripting.
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Create a new webhook trigger.
|
|
Create {
|
|
/// Agent name or ID.
|
|
agent: String,
|
|
/// Webhook callback URL.
|
|
url: String,
|
|
},
|
|
/// Delete a webhook.
|
|
Delete {
|
|
/// Webhook ID.
|
|
id: String,
|
|
},
|
|
/// Send a test payload to a webhook.
|
|
Test {
|
|
/// Webhook ID.
|
|
id: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum SystemCommands {
|
|
/// Show detailed system info.
|
|
Info {
|
|
/// Output as JSON for scripting.
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Show version information.
|
|
Version {
|
|
/// Output as JSON for scripting.
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
}
|
|
|
|
fn config_log_level() -> String {
|
|
let config_path = if let Ok(home) = std::env::var("OPENFANG_HOME") {
|
|
std::path::PathBuf::from(home).join("config.toml")
|
|
} else {
|
|
dirs::home_dir()
|
|
.unwrap_or_else(std::env::temp_dir)
|
|
.join(".openfang")
|
|
.join("config.toml")
|
|
};
|
|
if let Ok(content) = std::fs::read_to_string(config_path) {
|
|
for line in content.lines() {
|
|
let trimmed = line.trim();
|
|
if trimmed.starts_with("log_level") {
|
|
if let Some(val) = trimmed.split('=').nth(1) {
|
|
let level = val.trim().trim_matches('"').trim_matches('\'');
|
|
if !level.is_empty() {
|
|
return level.to_string();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"info".to_string()
|
|
}
|
|
|
|
fn init_tracing_stderr() {
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(config_log_level())),
|
|
)
|
|
.with_writer(std::io::stderr)
|
|
.init();
|
|
}
|
|
|
|
/// Get the OpenFang home directory, respecting OPENFANG_HOME env var.
|
|
fn cli_openfang_home() -> std::path::PathBuf {
|
|
if let Ok(home) = std::env::var("OPENFANG_HOME") {
|
|
return std::path::PathBuf::from(home);
|
|
}
|
|
dirs::home_dir()
|
|
.unwrap_or_else(std::env::temp_dir)
|
|
.join(".openfang")
|
|
}
|
|
|
|
/// Redirect tracing to a log file so it doesn't corrupt the ratatui TUI.
|
|
fn init_tracing_file() {
|
|
let log_dir = cli_openfang_home();
|
|
let _ = std::fs::create_dir_all(&log_dir);
|
|
let log_path = log_dir.join("tui.log");
|
|
|
|
match std::fs::File::create(&log_path) {
|
|
Ok(file) => {
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(config_log_level())),
|
|
)
|
|
.with_writer(std::sync::Mutex::new(file))
|
|
.with_ansi(false)
|
|
.init();
|
|
}
|
|
Err(_) => {
|
|
// Fallback: suppress all output rather than corrupt the TUI
|
|
tracing_subscriber::fmt()
|
|
.with_max_level(tracing::Level::ERROR)
|
|
.with_writer(std::io::sink)
|
|
.init();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Write `msg` to stdout, silently exiting with code 0 on BrokenPipe.
|
|
/// Use this instead of `println!` for machine-readable (JSON) output that is
|
|
/// commonly piped into other tools.
|
|
fn write_stdout_safe(msg: &str) {
|
|
let out = std::io::stdout();
|
|
let mut lock = out.lock();
|
|
if let Err(e) = writeln!(lock, "{}", msg) {
|
|
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
|
std::process::exit(0);
|
|
}
|
|
eprintln!("error: failed writing to stdout: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
fn main() {
|
|
|
|
// Load ~/.openfang/.env into process environment (system env takes priority).
|
|
dotenv::load_dotenv();
|
|
|
|
let cli = Cli::parse();
|
|
|
|
// Determine if this invocation launches a ratatui TUI.
|
|
// TUI modes must NOT install the Ctrl+C handler (it calls process::exit
|
|
// which bypasses ratatui::restore and leaves the terminal in raw mode).
|
|
// TUI modes also need file-based tracing (stderr output corrupts the TUI).
|
|
let is_launcher = cli.command.is_none() && std::io::IsTerminal::is_terminal(&std::io::stdout());
|
|
let is_tui_mode = is_launcher
|
|
|| matches!(cli.command, Some(Commands::Tui))
|
|
|| matches!(cli.command, Some(Commands::Chat { .. }))
|
|
|| matches!(
|
|
cli.command,
|
|
Some(Commands::Agent(AgentCommands::Chat { .. }))
|
|
);
|
|
|
|
if is_tui_mode {
|
|
init_tracing_file();
|
|
} else {
|
|
// CLI subcommands: install Ctrl+C handler for clean interrupt of
|
|
// blocking read_line calls, and trace to stderr.
|
|
install_ctrlc_handler();
|
|
init_tracing_stderr();
|
|
}
|
|
|
|
match cli.command {
|
|
None => {
|
|
if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
|
|
// Piped: fall back to text help
|
|
use clap::CommandFactory;
|
|
Cli::command().print_help().unwrap();
|
|
println!();
|
|
return;
|
|
}
|
|
match launcher::run(cli.config.clone()) {
|
|
launcher::LauncherChoice::GetStarted => cmd_init(false),
|
|
launcher::LauncherChoice::Chat => cmd_quick_chat(cli.config, None),
|
|
launcher::LauncherChoice::Dashboard => cmd_dashboard(),
|
|
launcher::LauncherChoice::DesktopApp => launcher::launch_desktop_app(),
|
|
launcher::LauncherChoice::TerminalUI => tui::run(cli.config),
|
|
launcher::LauncherChoice::ShowHelp => {
|
|
use clap::CommandFactory;
|
|
Cli::command().print_help().unwrap();
|
|
println!();
|
|
}
|
|
launcher::LauncherChoice::Quit => {}
|
|
}
|
|
}
|
|
Some(Commands::Tui) => tui::run(cli.config),
|
|
Some(Commands::Init { quick }) => cmd_init(quick),
|
|
Some(Commands::Start { yolo }) => cmd_start(cli.config, yolo),
|
|
Some(Commands::Stop) => cmd_stop(),
|
|
Some(Commands::Agent(sub)) => match sub {
|
|
AgentCommands::New { template } => cmd_agent_new(cli.config, template),
|
|
AgentCommands::Spawn { manifest } => cmd_agent_spawn(cli.config, manifest),
|
|
AgentCommands::List { json } => cmd_agent_list(cli.config, json),
|
|
AgentCommands::Chat { agent_id } => cmd_agent_chat(cli.config, &agent_id),
|
|
AgentCommands::Kill { agent_id } => cmd_agent_kill(cli.config, &agent_id),
|
|
AgentCommands::Set {
|
|
agent_id,
|
|
field,
|
|
value,
|
|
} => cmd_agent_set(&agent_id, &field, &value),
|
|
},
|
|
Some(Commands::Workflow(sub)) => match sub {
|
|
WorkflowCommands::List => cmd_workflow_list(),
|
|
WorkflowCommands::Create { file } => cmd_workflow_create(file),
|
|
WorkflowCommands::Get { workflow_id } => cmd_workflow_get(&workflow_id),
|
|
WorkflowCommands::Update { workflow_id, file } => {
|
|
cmd_workflow_update(&workflow_id, file)
|
|
}
|
|
WorkflowCommands::Delete { workflow_id } => cmd_workflow_delete(&workflow_id),
|
|
WorkflowCommands::Run { workflow_id, input } => cmd_workflow_run(&workflow_id, &input),
|
|
},
|
|
Some(Commands::Trigger(sub)) => match sub {
|
|
TriggerCommands::List { agent_id } => cmd_trigger_list(agent_id.as_deref()),
|
|
TriggerCommands::Create {
|
|
agent_id,
|
|
pattern_json,
|
|
prompt,
|
|
max_fires,
|
|
} => cmd_trigger_create(&agent_id, &pattern_json, &prompt, max_fires),
|
|
TriggerCommands::Delete { trigger_id } => cmd_trigger_delete(&trigger_id),
|
|
},
|
|
Some(Commands::Migrate(args)) => cmd_migrate(args),
|
|
Some(Commands::Skill(sub)) => match sub {
|
|
SkillCommands::Install { source } => cmd_skill_install(&source),
|
|
SkillCommands::List => cmd_skill_list(),
|
|
SkillCommands::Remove { name } => cmd_skill_remove(&name),
|
|
SkillCommands::Search { query } => cmd_skill_search(&query),
|
|
SkillCommands::Create => cmd_skill_create(),
|
|
},
|
|
Some(Commands::Channel(sub)) => match sub {
|
|
ChannelCommands::List => cmd_channel_list(),
|
|
ChannelCommands::Setup { channel } => cmd_channel_setup(channel.as_deref()),
|
|
ChannelCommands::Test { channel } => cmd_channel_test(&channel),
|
|
ChannelCommands::Enable { channel } => cmd_channel_toggle(&channel, true),
|
|
ChannelCommands::Disable { channel } => cmd_channel_toggle(&channel, false),
|
|
},
|
|
Some(Commands::Hand(sub)) => match sub {
|
|
HandCommands::List => cmd_hand_list(),
|
|
HandCommands::Active => cmd_hand_active(),
|
|
HandCommands::Install { path } => cmd_hand_install(&path),
|
|
HandCommands::Activate { id } => cmd_hand_activate(&id),
|
|
HandCommands::Deactivate { id } => cmd_hand_deactivate(&id),
|
|
HandCommands::Info { id } => cmd_hand_info(&id),
|
|
HandCommands::CheckDeps { id } => cmd_hand_check_deps(&id),
|
|
HandCommands::InstallDeps { id } => cmd_hand_install_deps(&id),
|
|
HandCommands::Pause { id } => cmd_hand_pause(&id),
|
|
HandCommands::Resume { id } => cmd_hand_resume(&id),
|
|
},
|
|
Some(Commands::Config(sub)) => match sub {
|
|
ConfigCommands::Show => cmd_config_show(),
|
|
ConfigCommands::Edit => cmd_config_edit(),
|
|
ConfigCommands::Get { key } => cmd_config_get(&key),
|
|
ConfigCommands::Set { key, value } => cmd_config_set(&key, &value),
|
|
ConfigCommands::Unset { key } => cmd_config_unset(&key),
|
|
ConfigCommands::SetKey { provider } => cmd_config_set_key(&provider),
|
|
ConfigCommands::DeleteKey { provider } => cmd_config_delete_key(&provider),
|
|
ConfigCommands::TestKey { provider } => cmd_config_test_key(&provider),
|
|
},
|
|
Some(Commands::Chat { agent }) => cmd_quick_chat(cli.config, agent),
|
|
Some(Commands::Status { json }) => cmd_status(cli.config, json),
|
|
Some(Commands::Doctor { json, repair }) => cmd_doctor(json, repair),
|
|
Some(Commands::Dashboard) => cmd_dashboard(),
|
|
Some(Commands::Completion { shell }) => cmd_completion(shell),
|
|
Some(Commands::Mcp) => mcp::run_mcp_server(cli.config),
|
|
Some(Commands::Add { name, key }) => cmd_integration_add(&name, key.as_deref()),
|
|
Some(Commands::Remove { name }) => cmd_integration_remove(&name),
|
|
Some(Commands::Integrations { query }) => cmd_integrations_list(query.as_deref()),
|
|
Some(Commands::Vault(sub)) => match sub {
|
|
VaultCommands::Init => cmd_vault_init(),
|
|
VaultCommands::Set { key } => cmd_vault_set(&key),
|
|
VaultCommands::List => cmd_vault_list(),
|
|
VaultCommands::Remove { key } => cmd_vault_remove(&key),
|
|
},
|
|
Some(Commands::New { kind }) => cmd_scaffold(kind),
|
|
// ── New commands ────────────────────────────────────────────────
|
|
Some(Commands::Models(sub)) => match sub {
|
|
ModelsCommands::List { provider, json } => cmd_models_list(provider.as_deref(), json),
|
|
ModelsCommands::Aliases { json } => cmd_models_aliases(json),
|
|
ModelsCommands::Providers { json } => cmd_models_providers(json),
|
|
ModelsCommands::Set { model } => cmd_models_set(model),
|
|
},
|
|
Some(Commands::Gateway(sub)) => match sub {
|
|
GatewayCommands::Start => cmd_start(cli.config, false),
|
|
GatewayCommands::Stop => cmd_stop(),
|
|
GatewayCommands::Status { json } => cmd_status(cli.config, json),
|
|
},
|
|
Some(Commands::Approvals(sub)) => match sub {
|
|
ApprovalsCommands::List { json } => cmd_approvals_list(json),
|
|
ApprovalsCommands::Approve { id } => cmd_approvals_respond(&id, true),
|
|
ApprovalsCommands::Reject { id } => cmd_approvals_respond(&id, false),
|
|
},
|
|
Some(Commands::Cron(sub)) => match sub {
|
|
CronCommands::List { json } => cmd_cron_list(json),
|
|
CronCommands::Create {
|
|
agent,
|
|
spec,
|
|
prompt,
|
|
name,
|
|
} => cmd_cron_create(&agent, &spec, &prompt, name.as_deref()),
|
|
CronCommands::Delete { id } => cmd_cron_delete(&id),
|
|
CronCommands::Enable { id } => cmd_cron_toggle(&id, true),
|
|
CronCommands::Disable { id } => cmd_cron_toggle(&id, false),
|
|
},
|
|
Some(Commands::Sessions { agent, json }) => cmd_sessions(agent.as_deref(), json),
|
|
Some(Commands::Logs { lines, follow }) => cmd_logs(lines, follow),
|
|
Some(Commands::Health { json }) => cmd_health(json),
|
|
Some(Commands::Security(sub)) => match sub {
|
|
SecurityCommands::Status { json } => cmd_security_status(json),
|
|
SecurityCommands::Audit { limit, json } => cmd_security_audit(limit, json),
|
|
SecurityCommands::Verify => cmd_security_verify(),
|
|
},
|
|
Some(Commands::Memory(sub)) => match sub {
|
|
MemoryCommands::List { agent, json } => cmd_memory_list(&agent, json),
|
|
MemoryCommands::Get { agent, key, json } => cmd_memory_get(&agent, &key, json),
|
|
MemoryCommands::Set { agent, key, value } => cmd_memory_set(&agent, &key, &value),
|
|
MemoryCommands::Delete { agent, key } => cmd_memory_delete(&agent, &key),
|
|
},
|
|
Some(Commands::Devices(sub)) => match sub {
|
|
DevicesCommands::List { json } => cmd_devices_list(json),
|
|
DevicesCommands::Pair => cmd_devices_pair(),
|
|
DevicesCommands::Remove { id } => cmd_devices_remove(&id),
|
|
},
|
|
Some(Commands::Qr) => cmd_devices_pair(),
|
|
Some(Commands::Webhooks(sub)) => match sub {
|
|
WebhooksCommands::List { json } => cmd_webhooks_list(json),
|
|
WebhooksCommands::Create { agent, url } => cmd_webhooks_create(&agent, &url),
|
|
WebhooksCommands::Delete { id } => cmd_webhooks_delete(&id),
|
|
WebhooksCommands::Test { id } => cmd_webhooks_test(&id),
|
|
},
|
|
Some(Commands::Onboard { quick }) | Some(Commands::Setup { quick }) => cmd_init(quick),
|
|
Some(Commands::Configure) => cmd_init(false),
|
|
Some(Commands::Message { agent, text, json }) => cmd_message(&agent, &text, json),
|
|
Some(Commands::System(sub)) => match sub {
|
|
SystemCommands::Info { json } => cmd_system_info(json),
|
|
SystemCommands::Version { json } => cmd_system_version(json),
|
|
},
|
|
Some(Commands::Reset { confirm }) => cmd_reset(confirm),
|
|
Some(Commands::Uninstall {
|
|
confirm,
|
|
keep_config,
|
|
}) => cmd_uninstall(confirm, keep_config),
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Daemon detection helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Try to find a running daemon. Returns its base URL if found.
|
|
/// SECURITY: Restrict file permissions to owner-only (0600) on Unix.
|
|
#[cfg(unix)]
|
|
pub(crate) fn restrict_file_permissions(path: &std::path::Path) {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
pub(crate) fn restrict_file_permissions(_path: &std::path::Path) {}
|
|
|
|
/// SECURITY: Restrict directory permissions to owner-only (0700) on Unix.
|
|
#[cfg(unix)]
|
|
pub(crate) fn restrict_dir_permissions(path: &std::path::Path) {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700));
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
pub(crate) fn restrict_dir_permissions(_path: &std::path::Path) {}
|
|
|
|
pub(crate) fn find_daemon() -> Option<String> {
|
|
let home_dir = cli_openfang_home();
|
|
let info = read_daemon_info(&home_dir)?;
|
|
|
|
// Normalize listen address: replace 0.0.0.0 with 127.0.0.1 to avoid
|
|
// DNS/connectivity issues on macOS where 0.0.0.0 can hang.
|
|
let addr = info.listen_addr.replace("0.0.0.0", "127.0.0.1");
|
|
let url = format!("http://{addr}/api/health");
|
|
|
|
let client = reqwest::blocking::Client::builder()
|
|
.connect_timeout(std::time::Duration::from_secs(1))
|
|
.timeout(std::time::Duration::from_secs(2))
|
|
.build()
|
|
.ok()?;
|
|
let resp = client.get(&url).send().ok()?;
|
|
if resp.status().is_success() {
|
|
Some(format!("http://{addr}"))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Build an HTTP client for daemon calls.
|
|
///
|
|
/// When api_key is configured in config.toml, the client automatically
|
|
/// includes a `Authorization: Bearer <key>` header on every request.
|
|
/// When api_key is empty or missing, no auth header is sent.
|
|
pub(crate) fn daemon_client() -> reqwest::blocking::Client {
|
|
let mut builder =
|
|
reqwest::blocking::Client::builder().timeout(std::time::Duration::from_secs(120));
|
|
|
|
if let Some(key) = read_api_key() {
|
|
let mut headers = reqwest::header::HeaderMap::new();
|
|
if let Ok(val) = reqwest::header::HeaderValue::from_str(&format!("Bearer {key}")) {
|
|
headers.insert(reqwest::header::AUTHORIZATION, val);
|
|
}
|
|
builder = builder.default_headers(headers);
|
|
}
|
|
|
|
builder.build().expect("Failed to build HTTP client")
|
|
}
|
|
|
|
/// Helper: send a request to the daemon and parse the JSON body.
|
|
/// Exits with error on connection failure.
|
|
pub(crate) fn daemon_json(
|
|
resp: Result<reqwest::blocking::Response, reqwest::Error>,
|
|
) -> serde_json::Value {
|
|
match resp {
|
|
Ok(r) => {
|
|
let status = r.status();
|
|
let body = r.json::<serde_json::Value>().unwrap_or_default();
|
|
if status.is_server_error() {
|
|
ui::error_with_fix(
|
|
&format!("Daemon returned error ({})", status),
|
|
"Check daemon logs: ~/.openfang/tui.log",
|
|
);
|
|
}
|
|
body
|
|
}
|
|
Err(e) => {
|
|
let msg = e.to_string();
|
|
if msg.contains("timed out") || msg.contains("Timeout") {
|
|
ui::error_with_fix(
|
|
"Request timed out",
|
|
"The agent may be processing a complex request. Try again, or check `openfang status`",
|
|
);
|
|
} else if msg.contains("Connection refused") || msg.contains("connect") {
|
|
ui::error_with_fix(
|
|
"Cannot connect to daemon",
|
|
"Is the daemon running? Start it with: openfang start",
|
|
);
|
|
} else {
|
|
ui::error_with_fix(
|
|
&format!("Daemon communication error: {msg}"),
|
|
"Check `openfang status` or restart: openfang start",
|
|
);
|
|
}
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Commands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn cmd_init(quick: bool) {
|
|
let home = match dirs::home_dir() {
|
|
Some(h) => h,
|
|
None => {
|
|
ui::error("Could not determine home directory");
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
|
|
let openfang_dir = cli_openfang_home();
|
|
|
|
// --- Ensure directories exist ---
|
|
if !openfang_dir.exists() {
|
|
std::fs::create_dir_all(&openfang_dir).unwrap_or_else(|e| {
|
|
ui::error_with_fix(
|
|
&format!("Failed to create {}", openfang_dir.display()),
|
|
&format!("Check permissions on {}", home.display()),
|
|
);
|
|
eprintln!(" {e}");
|
|
std::process::exit(1);
|
|
});
|
|
restrict_dir_permissions(&openfang_dir);
|
|
}
|
|
|
|
for sub in ["data", "agents"] {
|
|
let dir = openfang_dir.join(sub);
|
|
if !dir.exists() {
|
|
std::fs::create_dir_all(&dir).unwrap_or_else(|e| {
|
|
eprintln!("Error creating {sub} dir: {e}");
|
|
std::process::exit(1);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Install bundled agent templates (skips existing ones to preserve user edits)
|
|
bundled_agents::install_bundled_agents(&openfang_dir.join("agents"));
|
|
|
|
if quick {
|
|
cmd_init_quick(&openfang_dir);
|
|
} else if !std::io::IsTerminal::is_terminal(&std::io::stdin())
|
|
|| !std::io::IsTerminal::is_terminal(&std::io::stdout())
|
|
{
|
|
ui::hint("Non-interactive terminal detected — running in quick mode");
|
|
ui::hint("For the interactive wizard, run: openfang init (in a terminal)");
|
|
cmd_init_quick(&openfang_dir);
|
|
} else {
|
|
cmd_init_interactive(&openfang_dir);
|
|
}
|
|
}
|
|
|
|
/// Quick init: no prompts, auto-detect, write config + .env, print next steps.
|
|
fn cmd_init_quick(openfang_dir: &std::path::Path) {
|
|
ui::banner();
|
|
ui::blank();
|
|
|
|
let (provider, api_key_env, model) = detect_best_provider();
|
|
|
|
write_config_if_missing(openfang_dir, provider, model, api_key_env);
|
|
|
|
ui::blank();
|
|
ui::success("OpenFang initialized (quick mode)");
|
|
ui::kv("Provider", provider);
|
|
ui::kv("Model", model);
|
|
ui::blank();
|
|
ui::next_steps(&[
|
|
"Start the daemon: openfang start",
|
|
"Chat: openfang chat",
|
|
]);
|
|
}
|
|
|
|
/// Interactive 5-step onboarding wizard (ratatui TUI).
|
|
fn cmd_init_interactive(openfang_dir: &std::path::Path) {
|
|
use tui::screens::init_wizard::{self, InitResult, LaunchChoice};
|
|
|
|
match init_wizard::run() {
|
|
InitResult::Completed {
|
|
provider,
|
|
model,
|
|
daemon_started,
|
|
launch,
|
|
} => {
|
|
// Print summary after TUI restores terminal
|
|
ui::blank();
|
|
ui::success("OpenFang initialized!");
|
|
ui::kv("Provider", &provider);
|
|
ui::kv("Model", &model);
|
|
|
|
if daemon_started {
|
|
ui::kv_ok("Daemon", "running");
|
|
}
|
|
ui::blank();
|
|
|
|
// Execute the user's chosen launch action.
|
|
match launch {
|
|
LaunchChoice::Desktop => {
|
|
launch_desktop_app(openfang_dir);
|
|
}
|
|
LaunchChoice::Dashboard => {
|
|
if let Some(base) = find_daemon() {
|
|
let url = format!("{base}/");
|
|
ui::success(&format!("Opening dashboard at {url}"));
|
|
if !open_in_browser(&url) {
|
|
ui::hint(&format!("Could not open browser. Visit: {url}"));
|
|
}
|
|
} else {
|
|
ui::error("Daemon is not running. Start it with: openfang start");
|
|
}
|
|
}
|
|
LaunchChoice::Chat => {
|
|
ui::hint("Starting chat session...");
|
|
ui::blank();
|
|
// Note: tracing was initialized for stderr (init is a CLI
|
|
// subcommand). The chat TUI takes over the terminal with
|
|
// raw mode so stderr output is suppressed. We can't
|
|
// reinitialize tracing (global subscriber is set once).
|
|
cmd_quick_chat(None, None);
|
|
}
|
|
}
|
|
}
|
|
InitResult::Cancelled => {
|
|
println!(" Setup cancelled.");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Launch the openfang-desktop Tauri app, connecting to the running daemon.
|
|
fn launch_desktop_app(_openfang_dir: &std::path::Path) {
|
|
// Look for the desktop binary next to our own executable.
|
|
let desktop_bin = {
|
|
let exe = std::env::current_exe().ok();
|
|
let dir = exe.as_ref().and_then(|e| e.parent());
|
|
|
|
#[cfg(windows)]
|
|
let name = "openfang-desktop.exe";
|
|
#[cfg(not(windows))]
|
|
let name = "openfang-desktop";
|
|
|
|
dir.map(|d| d.join(name))
|
|
};
|
|
|
|
match desktop_bin {
|
|
Some(ref path) if path.exists() => {
|
|
ui::success("Launching OpenFang Desktop...");
|
|
match std::process::Command::new(path)
|
|
.stdin(std::process::Stdio::null())
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.spawn()
|
|
{
|
|
Ok(_) => {
|
|
ui::success("Desktop app started.");
|
|
}
|
|
Err(e) => {
|
|
ui::error(&format!("Failed to launch desktop app: {e}"));
|
|
ui::hint("Try: openfang dashboard");
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
ui::error("Desktop app not found.");
|
|
ui::hint("Install it with: cargo install openfang-desktop");
|
|
ui::hint("Falling back to web dashboard...");
|
|
ui::blank();
|
|
if let Some(base) = find_daemon() {
|
|
let url = format!("{base}/");
|
|
if !open_in_browser(&url) {
|
|
// Browser launch failed entirely (e.g., sandbox EPERM,
|
|
// no display server, container environment).
|
|
ui::hint("Could not open a browser automatically.");
|
|
}
|
|
// Always print the URL so the user can open it manually,
|
|
// even when open_in_browser reported success — the spawned
|
|
// opener may still fail asynchronously.
|
|
ui::hint(&format!("Dashboard: {url}"));
|
|
} else {
|
|
ui::hint("Daemon is not running. Start it with: openfang start");
|
|
ui::hint("Then open: http://127.0.0.1:4200");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Auto-detect the best available provider.
|
|
fn detect_best_provider() -> (&'static str, &'static str, &'static str) {
|
|
let providers = provider_list();
|
|
|
|
for (p, env_var, m, display) in &providers {
|
|
if std::env::var(env_var).is_ok() {
|
|
ui::success(&format!("Detected {display} ({env_var})"));
|
|
return (p, env_var, m);
|
|
}
|
|
}
|
|
// Also check GOOGLE_API_KEY
|
|
if std::env::var("GOOGLE_API_KEY").is_ok() {
|
|
ui::success("Detected Gemini (GOOGLE_API_KEY)");
|
|
return ("gemini", "GOOGLE_API_KEY", "gemini-2.5-flash");
|
|
}
|
|
// Check if Ollama is running locally (no API key needed)
|
|
if check_ollama_available() {
|
|
ui::success("Detected Ollama running locally (no API key needed)");
|
|
return ("ollama", "OLLAMA_API_KEY", "llama3.2");
|
|
}
|
|
ui::hint("No LLM provider API keys found");
|
|
ui::hint("Groq offers a free tier: https://console.groq.com");
|
|
ui::hint("Or install Ollama for local models: https://ollama.com");
|
|
("groq", "GROQ_API_KEY", "llama-3.3-70b-versatile")
|
|
}
|
|
|
|
/// Static list of supported providers: (id, env_var, default_model, display_name).
|
|
fn provider_list() -> Vec<(&'static str, &'static str, &'static str, &'static str)> {
|
|
vec![
|
|
("groq", "GROQ_API_KEY", "llama-3.3-70b-versatile", "Groq"),
|
|
("gemini", "GEMINI_API_KEY", "gemini-2.5-flash", "Gemini"),
|
|
("deepseek", "DEEPSEEK_API_KEY", "deepseek-chat", "DeepSeek"),
|
|
(
|
|
"anthropic",
|
|
"ANTHROPIC_API_KEY",
|
|
"claude-sonnet-4-20250514",
|
|
"Anthropic",
|
|
),
|
|
("openai", "OPENAI_API_KEY", "gpt-4o", "OpenAI"),
|
|
(
|
|
"openrouter",
|
|
"OPENROUTER_API_KEY",
|
|
"openrouter/google/gemini-2.5-flash",
|
|
"OpenRouter",
|
|
),
|
|
]
|
|
}
|
|
|
|
/// Quick probe to check if Ollama is running on localhost.
|
|
fn check_ollama_available() -> bool {
|
|
std::net::TcpStream::connect_timeout(
|
|
&std::net::SocketAddr::from(([127, 0, 0, 1], 11434)),
|
|
std::time::Duration::from_millis(500),
|
|
)
|
|
.is_ok()
|
|
}
|
|
|
|
/// Write config.toml if it doesn't already exist.
|
|
fn write_config_if_missing(
|
|
openfang_dir: &std::path::Path,
|
|
provider: &str,
|
|
model: &str,
|
|
api_key_env: &str,
|
|
) {
|
|
let config_path = openfang_dir.join("config.toml");
|
|
if config_path.exists() {
|
|
ui::check_ok(&format!("Config already exists: {}", config_path.display()));
|
|
} else {
|
|
let default_config = format!(
|
|
r#"# OpenFang Agent OS configuration
|
|
# See https://github.com/RightNow-AI/openfang for documentation
|
|
|
|
# For Docker, change to "0.0.0.0:4200" or set OPENFANG_LISTEN env var.
|
|
api_listen = "127.0.0.1:4200"
|
|
|
|
[default_model]
|
|
provider = "{provider}"
|
|
model = "{model}"
|
|
api_key_env = "{api_key_env}"
|
|
|
|
[memory]
|
|
decay_rate = 0.05
|
|
"#
|
|
);
|
|
std::fs::write(&config_path, &default_config).unwrap_or_else(|e| {
|
|
ui::error_with_fix("Failed to write config", &e.to_string());
|
|
std::process::exit(1);
|
|
});
|
|
restrict_file_permissions(&config_path);
|
|
ui::success(&format!("Created: {}", config_path.display()));
|
|
}
|
|
}
|
|
|
|
fn cmd_start(config: Option<PathBuf>, yolo: bool) {
|
|
if let Some(base) = find_daemon() {
|
|
ui::error_with_fix(
|
|
&format!("Daemon already running at {base}"),
|
|
"Use `openfang status` to check it, or stop it first",
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
|
|
ui::banner();
|
|
ui::blank();
|
|
println!(" Starting daemon...");
|
|
ui::blank();
|
|
|
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
rt.block_on(async {
|
|
let mut kernel_config = openfang_kernel::config::load_config(config.as_deref());
|
|
if yolo {
|
|
kernel_config.approval.auto_approve = true;
|
|
kernel_config.approval.apply_shorthands();
|
|
}
|
|
let kernel = match OpenFangKernel::boot_with_config(kernel_config) {
|
|
Ok(k) => k,
|
|
Err(e) => {
|
|
boot_kernel_error(&e);
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
|
|
let listen_addr = kernel.config.api_listen.clone();
|
|
let daemon_info_path = kernel.config.home_dir.join("daemon.json");
|
|
let provider = kernel.config.default_model.provider.clone();
|
|
let model = kernel.config.default_model.model.clone();
|
|
let agent_count = kernel.registry.count();
|
|
let model_count = kernel
|
|
.model_catalog
|
|
.read()
|
|
.map(|c| c.list_models().len())
|
|
.unwrap_or(0);
|
|
|
|
ui::success(&format!("Kernel booted ({provider}/{model})"));
|
|
if model_count > 0 {
|
|
ui::success(&format!("{model_count} models available"));
|
|
}
|
|
if agent_count > 0 {
|
|
ui::success(&format!("{agent_count} agent(s) loaded"));
|
|
}
|
|
ui::blank();
|
|
ui::kv("API", &format!("http://{listen_addr}"));
|
|
ui::kv("Dashboard", &format!("http://{listen_addr}/"));
|
|
ui::kv("Provider", &provider);
|
|
ui::kv("Model", &model);
|
|
ui::blank();
|
|
ui::hint("Open the dashboard in your browser, or run `openfang chat`");
|
|
ui::hint("Press Ctrl+C to stop the daemon");
|
|
ui::blank();
|
|
|
|
if let Err(e) =
|
|
openfang_api::server::run_daemon(kernel, &listen_addr, Some(&daemon_info_path)).await
|
|
{
|
|
ui::error(&format!("Daemon error: {e}"));
|
|
std::process::exit(1);
|
|
}
|
|
|
|
ui::blank();
|
|
println!(" OpenFang daemon stopped.");
|
|
});
|
|
}
|
|
|
|
/// Read the api_key from ~/.openfang/config.toml (if any).
|
|
///
|
|
/// Returns `None` when the key is missing, empty, or whitespace-only —
|
|
/// meaning the daemon is running in public (unauthenticated) mode.
|
|
fn read_api_key() -> Option<String> {
|
|
// 1. Config file takes precedence
|
|
let config_path = cli_openfang_home().join("config.toml");
|
|
if let Ok(text) = std::fs::read_to_string(config_path) {
|
|
if let Ok(table) = text.parse::<toml::Value>() {
|
|
if let Some(key) = table.get("api_key").and_then(|v| v.as_str()) {
|
|
let key = key.trim();
|
|
if !key.is_empty() {
|
|
return Some(key.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// 2. Fall back to OPENFANG_API_KEY env var
|
|
if let Ok(key) = std::env::var("OPENFANG_API_KEY") {
|
|
let key = key.trim().to_string();
|
|
if !key.is_empty() {
|
|
return Some(key);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn cmd_stop() {
|
|
match find_daemon() {
|
|
Some(base) => {
|
|
let client = daemon_client();
|
|
match client.post(format!("{base}/api/shutdown")).send() {
|
|
Ok(r) if r.status().is_success() => {
|
|
// Wait for daemon to actually stop (up to 5 seconds)
|
|
for _ in 0..10 {
|
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
|
if find_daemon().is_none() {
|
|
ui::success("Daemon stopped");
|
|
return;
|
|
}
|
|
}
|
|
// Still alive — force kill via PID
|
|
{
|
|
let of_dir = cli_openfang_home();
|
|
if let Some(info) = read_daemon_info(&of_dir) {
|
|
force_kill_pid(info.pid);
|
|
let _ = std::fs::remove_file(of_dir.join("daemon.json"));
|
|
}
|
|
}
|
|
ui::success("Daemon stopped (forced)");
|
|
}
|
|
Ok(r) => {
|
|
ui::error(&format!("Shutdown request failed ({})", r.status()));
|
|
}
|
|
Err(e) => {
|
|
ui::error(&format!("Could not reach daemon: {e}"));
|
|
}
|
|
}
|
|
}
|
|
None => {
|
|
ui::warn_with_fix(
|
|
"No running daemon found",
|
|
"Is it running? Check with: openfang status",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn force_kill_pid(pid: u32) {
|
|
#[cfg(unix)]
|
|
{
|
|
let _ = std::process::Command::new("kill")
|
|
.args(["-9", &pid.to_string()])
|
|
.output();
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
let _ = std::process::Command::new("taskkill")
|
|
.args(["/PID", &pid.to_string(), "/F"])
|
|
.output();
|
|
}
|
|
}
|
|
|
|
/// Show context-aware error for kernel boot failures.
|
|
fn boot_kernel_error(e: &openfang_kernel::error::KernelError) {
|
|
let msg = e.to_string();
|
|
if msg.contains("parse") || msg.contains("toml") || msg.contains("config") {
|
|
ui::error_with_fix(
|
|
"Failed to parse configuration",
|
|
"Check your config.toml syntax: openfang config show",
|
|
);
|
|
} else if msg.contains("database") || msg.contains("locked") || msg.contains("sqlite") {
|
|
ui::error_with_fix(
|
|
"Database error (file may be locked)",
|
|
"Check if another OpenFang process is running: openfang status",
|
|
);
|
|
} else if msg.contains("key") || msg.contains("API") || msg.contains("auth") {
|
|
ui::error_with_fix(
|
|
"LLM provider authentication failed",
|
|
"Run `openfang doctor` to check your API key configuration",
|
|
);
|
|
} else {
|
|
ui::error_with_fix(
|
|
&format!("Failed to boot kernel: {msg}"),
|
|
"Run `openfang doctor` to diagnose the issue",
|
|
);
|
|
}
|
|
}
|
|
|
|
fn cmd_agent_spawn(config: Option<PathBuf>, manifest_path: PathBuf) {
|
|
if !manifest_path.exists() {
|
|
ui::error_with_fix(
|
|
&format!("Manifest file not found: {}", manifest_path.display()),
|
|
"Use `openfang agent new` to spawn from a template instead",
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
|
|
let contents = std::fs::read_to_string(&manifest_path).unwrap_or_else(|e| {
|
|
eprintln!("Error reading manifest: {e}");
|
|
std::process::exit(1);
|
|
});
|
|
|
|
if let Some(base) = find_daemon() {
|
|
let client = daemon_client();
|
|
let body = daemon_json(
|
|
client
|
|
.post(format!("{base}/api/agents"))
|
|
.json(&serde_json::json!({"manifest_toml": contents}))
|
|
.send(),
|
|
);
|
|
if body.get("agent_id").is_some() {
|
|
println!("Agent spawned successfully!");
|
|
println!(" ID: {}", body["agent_id"].as_str().unwrap_or("?"));
|
|
println!(" Name: {}", body["name"].as_str().unwrap_or("?"));
|
|
} else {
|
|
eprintln!(
|
|
"Failed to spawn agent: {}",
|
|
body["error"].as_str().unwrap_or("Unknown error")
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
} else {
|
|
let manifest: AgentManifest = toml::from_str(&contents).unwrap_or_else(|e| {
|
|
eprintln!("Error parsing manifest: {e}");
|
|
std::process::exit(1);
|
|
});
|
|
let kernel = boot_kernel(config);
|
|
match kernel.spawn_agent(manifest) {
|
|
Ok(id) => {
|
|
println!("Agent spawned (in-process mode).");
|
|
println!(" ID: {id}");
|
|
println!("\n Note: Agent will be lost when this process exits.");
|
|
println!(" For persistent agents, use `openfang start` first.");
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Failed to spawn agent: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_agent_list(config: Option<PathBuf>, json: bool) {
|
|
if let Some(base) = find_daemon() {
|
|
let client = daemon_client();
|
|
let body = daemon_json(client.get(format!("{base}/api/agents")).send());
|
|
|
|
if json {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
return;
|
|
}
|
|
|
|
let agents = body.as_array();
|
|
|
|
match agents {
|
|
Some(agents) if agents.is_empty() => println!("No agents running."),
|
|
Some(agents) => {
|
|
println!(
|
|
"{:<38} {:<16} {:<10} {:<12} MODEL",
|
|
"ID", "NAME", "STATE", "PROVIDER"
|
|
);
|
|
println!("{}", "-".repeat(95));
|
|
for a in agents {
|
|
println!(
|
|
"{:<38} {:<16} {:<10} {:<12} {}",
|
|
a["id"].as_str().unwrap_or("?"),
|
|
a["name"].as_str().unwrap_or("?"),
|
|
a["state"].as_str().unwrap_or("?"),
|
|
a["model_provider"].as_str().unwrap_or("?"),
|
|
a["model_name"].as_str().unwrap_or("?"),
|
|
);
|
|
}
|
|
}
|
|
None => println!("No agents running."),
|
|
}
|
|
} else {
|
|
let kernel = boot_kernel(config);
|
|
let agents = kernel.registry.list();
|
|
|
|
if json {
|
|
let list: Vec<serde_json::Value> = agents
|
|
.iter()
|
|
.map(|e| {
|
|
serde_json::json!({
|
|
"id": e.id.to_string(),
|
|
"name": e.name,
|
|
"state": format!("{:?}", e.state),
|
|
"created_at": e.created_at.to_rfc3339(),
|
|
})
|
|
})
|
|
.collect();
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&list).unwrap_or_default()
|
|
);
|
|
return;
|
|
}
|
|
|
|
if agents.is_empty() {
|
|
println!("No agents running.");
|
|
return;
|
|
}
|
|
|
|
println!("{:<38} {:<20} {:<12} CREATED", "ID", "NAME", "STATE");
|
|
println!("{}", "-".repeat(85));
|
|
for entry in agents {
|
|
println!(
|
|
"{:<38} {:<20} {:<12} {}",
|
|
entry.id,
|
|
entry.name,
|
|
format!("{:?}", entry.state),
|
|
entry.created_at.format("%Y-%m-%d %H:%M")
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_agent_chat(config: Option<PathBuf>, agent_id_str: &str) {
|
|
tui::chat_runner::run_chat_tui(config, Some(agent_id_str.to_string()));
|
|
}
|
|
|
|
fn cmd_agent_kill(config: Option<PathBuf>, agent_id_str: &str) {
|
|
if let Some(base) = find_daemon() {
|
|
let client = daemon_client();
|
|
let body = daemon_json(
|
|
client
|
|
.delete(format!("{base}/api/agents/{agent_id_str}"))
|
|
.send(),
|
|
);
|
|
if body.get("status").is_some() {
|
|
println!("Agent {agent_id_str} killed.");
|
|
} else {
|
|
eprintln!(
|
|
"Failed to kill agent: {}",
|
|
body["error"].as_str().unwrap_or("Unknown error")
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
} else {
|
|
let agent_id: AgentId = agent_id_str.parse().unwrap_or_else(|_| {
|
|
eprintln!("Invalid agent ID: {agent_id_str}");
|
|
std::process::exit(1);
|
|
});
|
|
let kernel = boot_kernel(config);
|
|
match kernel.kill_agent(agent_id) {
|
|
Ok(()) => println!("Agent {agent_id} killed."),
|
|
Err(e) => {
|
|
eprintln!("Failed to kill agent: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_agent_set(agent_id_str: &str, field: &str, value: &str) {
|
|
match field {
|
|
"model" => {
|
|
if let Some(base) = find_daemon() {
|
|
let client = daemon_client();
|
|
let body = daemon_json(
|
|
client
|
|
.put(format!("{base}/api/agents/{agent_id_str}/model"))
|
|
.json(&serde_json::json!({"model": value}))
|
|
.send(),
|
|
);
|
|
if body.get("status").is_some() {
|
|
println!("Agent {agent_id_str} model set to {value}.");
|
|
} else {
|
|
eprintln!(
|
|
"Failed to set model: {}",
|
|
body["error"].as_str().unwrap_or("Unknown error")
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
} else {
|
|
eprintln!("No running daemon found. Start one with: openfang start");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
_ => {
|
|
eprintln!("Unknown field: {field}. Supported fields: model");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_agent_new(config: Option<PathBuf>, template_name: Option<String>) {
|
|
let all_templates = templates::load_all_templates();
|
|
if all_templates.is_empty() {
|
|
ui::error_with_fix(
|
|
"No agent templates found",
|
|
"Run `openfang init` to set up the agents directory",
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
|
|
// Resolve template: by name or interactive picker
|
|
let chosen = match template_name {
|
|
Some(ref name) => match all_templates.iter().find(|t| t.name == *name) {
|
|
Some(t) => t,
|
|
None => {
|
|
ui::error_with_fix(
|
|
&format!("Template '{name}' not found"),
|
|
"Run `openfang agent new` to see available templates",
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
},
|
|
None => {
|
|
ui::section("Available Agent Templates");
|
|
ui::blank();
|
|
for (i, t) in all_templates.iter().enumerate() {
|
|
let desc = if t.description.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(" {}", t.description)
|
|
};
|
|
println!(
|
|
" {:>2}. {:<22}{}",
|
|
i + 1,
|
|
t.name,
|
|
colored::Colorize::dimmed(desc.as_str())
|
|
);
|
|
}
|
|
ui::blank();
|
|
let choice = prompt_input(" Choose template [1]: ");
|
|
let idx = if choice.is_empty() {
|
|
0
|
|
} else {
|
|
choice
|
|
.parse::<usize>()
|
|
.unwrap_or(1)
|
|
.saturating_sub(1)
|
|
.min(all_templates.len() - 1)
|
|
};
|
|
&all_templates[idx]
|
|
}
|
|
};
|
|
|
|
// Spawn the agent
|
|
spawn_template_agent(config, chosen);
|
|
}
|
|
|
|
/// Spawn an agent from a template, via daemon or in-process.
|
|
fn spawn_template_agent(config: Option<PathBuf>, template: &templates::AgentTemplate) {
|
|
if let Some(base) = find_daemon() {
|
|
let client = daemon_client();
|
|
let body = daemon_json(
|
|
client
|
|
.post(format!("{base}/api/agents"))
|
|
.json(&serde_json::json!({"manifest_toml": template.content}))
|
|
.send(),
|
|
);
|
|
if let Some(id) = body["agent_id"].as_str() {
|
|
ui::blank();
|
|
ui::success(&format!("Agent '{}' spawned", template.name));
|
|
ui::kv("ID", id);
|
|
if let Some(model) = body["model_name"].as_str() {
|
|
let provider = body["model_provider"].as_str().unwrap_or("?");
|
|
ui::kv("Model", &format!("{provider}/{model}"));
|
|
}
|
|
ui::blank();
|
|
ui::hint(&format!("Chat: openfang chat {}", template.name));
|
|
} else {
|
|
ui::error(&format!(
|
|
"Failed to spawn: {}",
|
|
body["error"].as_str().unwrap_or("Unknown error")
|
|
));
|
|
std::process::exit(1);
|
|
}
|
|
} else {
|
|
let manifest: AgentManifest = toml::from_str(&template.content).unwrap_or_else(|e| {
|
|
ui::error_with_fix(
|
|
&format!("Failed to parse template '{}': {e}", template.name),
|
|
"The template manifest may be corrupted",
|
|
);
|
|
std::process::exit(1);
|
|
});
|
|
let kernel = boot_kernel(config);
|
|
match kernel.spawn_agent(manifest) {
|
|
Ok(id) => {
|
|
ui::blank();
|
|
ui::success(&format!("Agent '{}' spawned (in-process)", template.name));
|
|
ui::kv("ID", &id.to_string());
|
|
ui::blank();
|
|
ui::hint(&format!("Chat: openfang chat {}", template.name));
|
|
ui::hint("Note: Agent will be lost when this process exits");
|
|
ui::hint("For persistent agents, use `openfang start` first");
|
|
}
|
|
Err(e) => {
|
|
ui::error(&format!("Failed to spawn agent: {e}"));
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_status(config: Option<PathBuf>, json: bool) {
|
|
if let Some(base) = find_daemon() {
|
|
let client = daemon_client();
|
|
let body = daemon_json(client.get(format!("{base}/api/status")).send());
|
|
|
|
if json {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
return;
|
|
}
|
|
|
|
ui::section("OpenFang Daemon Status");
|
|
ui::blank();
|
|
ui::kv_ok("Status", body["status"].as_str().unwrap_or("?"));
|
|
ui::kv(
|
|
"Agents",
|
|
&body["agent_count"].as_u64().unwrap_or(0).to_string(),
|
|
);
|
|
ui::kv("Provider", body["default_provider"].as_str().unwrap_or("?"));
|
|
ui::kv("Model", body["default_model"].as_str().unwrap_or("?"));
|
|
ui::kv("API", &base);
|
|
ui::kv("Dashboard", &format!("{base}/"));
|
|
ui::kv("Data dir", body["data_dir"].as_str().unwrap_or("?"));
|
|
ui::kv(
|
|
"Uptime",
|
|
&format!("{}s", body["uptime_seconds"].as_u64().unwrap_or(0)),
|
|
);
|
|
|
|
if let Some(agents) = body["agents"].as_array() {
|
|
if !agents.is_empty() {
|
|
ui::blank();
|
|
ui::section("Active Agents");
|
|
for a in agents {
|
|
println!(
|
|
" {} ({}) -- {} [{}:{}]",
|
|
a["name"].as_str().unwrap_or("?"),
|
|
a["id"].as_str().unwrap_or("?"),
|
|
a["state"].as_str().unwrap_or("?"),
|
|
a["model_provider"].as_str().unwrap_or("?"),
|
|
a["model_name"].as_str().unwrap_or("?"),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
let kernel = boot_kernel(config);
|
|
let agent_count = kernel.registry.count();
|
|
|
|
if json {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&serde_json::json!({
|
|
"status": "in-process",
|
|
"agent_count": agent_count,
|
|
"data_dir": kernel.config.data_dir.display().to_string(),
|
|
"default_provider": kernel.config.default_model.provider,
|
|
"default_model": kernel.config.default_model.model,
|
|
"daemon": false,
|
|
}))
|
|
.unwrap_or_default()
|
|
);
|
|
return;
|
|
}
|
|
|
|
ui::section("OpenFang Status (in-process)");
|
|
ui::blank();
|
|
ui::kv("Agents", &agent_count.to_string());
|
|
ui::kv("Provider", &kernel.config.default_model.provider);
|
|
ui::kv("Model", &kernel.config.default_model.model);
|
|
ui::kv("Data dir", &kernel.config.data_dir.display().to_string());
|
|
ui::kv_warn("Daemon", "NOT RUNNING");
|
|
ui::blank();
|
|
ui::hint("Run `openfang start` to launch the daemon");
|
|
|
|
if agent_count > 0 {
|
|
ui::blank();
|
|
ui::section("Persisted Agents");
|
|
for entry in kernel.registry.list() {
|
|
println!(" {} ({}) -- {:?}", entry.name, entry.id, entry.state);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_doctor(json: bool, repair: bool) {
|
|
let mut checks: Vec<serde_json::Value> = Vec::new();
|
|
let mut all_ok = true;
|
|
let mut repaired = false;
|
|
|
|
if !json {
|
|
ui::step("OpenFang Doctor");
|
|
println!();
|
|
}
|
|
|
|
let home = dirs::home_dir();
|
|
if let Some(_h) = &home {
|
|
let openfang_dir = cli_openfang_home();
|
|
|
|
// --- Check 1: OpenFang directory ---
|
|
if openfang_dir.exists() {
|
|
if !json {
|
|
ui::check_ok(&format!("OpenFang directory: {}", openfang_dir.display()));
|
|
}
|
|
checks.push(serde_json::json!({"check": "openfang_dir", "status": "ok", "path": openfang_dir.display().to_string()}));
|
|
} else if repair {
|
|
if !json {
|
|
ui::check_fail("OpenFang directory not found.");
|
|
}
|
|
let answer = prompt_input(" Create it now? [Y/n] ");
|
|
if answer.is_empty() || answer.starts_with('y') || answer.starts_with('Y') {
|
|
if std::fs::create_dir_all(&openfang_dir).is_ok() {
|
|
restrict_dir_permissions(&openfang_dir);
|
|
for sub in ["data", "agents"] {
|
|
let _ = std::fs::create_dir_all(openfang_dir.join(sub));
|
|
}
|
|
if !json {
|
|
ui::check_ok("Created OpenFang directory");
|
|
}
|
|
repaired = true;
|
|
} else {
|
|
if !json {
|
|
ui::check_fail("Failed to create directory");
|
|
}
|
|
all_ok = false;
|
|
}
|
|
} else {
|
|
all_ok = false;
|
|
}
|
|
checks.push(serde_json::json!({"check": "openfang_dir", "status": if repaired { "repaired" } else { "fail" }}));
|
|
} else {
|
|
if !json {
|
|
ui::check_fail("OpenFang directory not found. Run `openfang init` first.");
|
|
}
|
|
checks.push(serde_json::json!({"check": "openfang_dir", "status": "fail"}));
|
|
all_ok = false;
|
|
}
|
|
|
|
// --- Check 2: .env file exists + permissions ---
|
|
let env_path = openfang_dir.join(".env");
|
|
if env_path.exists() {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
if let Ok(meta) = std::fs::metadata(&env_path) {
|
|
let mode = meta.permissions().mode() & 0o777;
|
|
if mode == 0o600 {
|
|
if !json {
|
|
ui::check_ok(".env file (permissions OK)");
|
|
}
|
|
} else if repair {
|
|
let _ = std::fs::set_permissions(
|
|
&env_path,
|
|
std::fs::Permissions::from_mode(0o600),
|
|
);
|
|
if !json {
|
|
ui::check_ok(".env file (permissions fixed to 0600)");
|
|
}
|
|
repaired = true;
|
|
} else if !json {
|
|
ui::check_warn(&format!(
|
|
".env file has loose permissions ({:o}), should be 0600",
|
|
mode
|
|
));
|
|
}
|
|
} else if !json {
|
|
ui::check_ok(".env file");
|
|
}
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
if !json {
|
|
ui::check_ok(".env file");
|
|
}
|
|
}
|
|
checks.push(serde_json::json!({"check": "env_file", "status": "ok"}));
|
|
} else {
|
|
if !json {
|
|
ui::check_warn(
|
|
".env file not found (create with: openfang config set-key <provider>)",
|
|
);
|
|
}
|
|
checks.push(serde_json::json!({"check": "env_file", "status": "warn"}));
|
|
}
|
|
|
|
// --- Check 3: Config TOML syntax validation ---
|
|
let config_path = openfang_dir.join("config.toml");
|
|
if config_path.exists() {
|
|
let config_content = std::fs::read_to_string(&config_path).unwrap_or_default();
|
|
match toml::from_str::<toml::Value>(&config_content) {
|
|
Ok(_) => {
|
|
if !json {
|
|
ui::check_ok(&format!("Config file: {}", config_path.display()));
|
|
}
|
|
checks.push(serde_json::json!({"check": "config_file", "status": "ok"}));
|
|
}
|
|
Err(e) => {
|
|
if !json {
|
|
ui::check_fail(&format!("Config file has syntax errors: {e}"));
|
|
ui::hint("Fix with: openfang config edit");
|
|
}
|
|
checks.push(serde_json::json!({"check": "config_syntax", "status": "fail", "error": e.to_string()}));
|
|
all_ok = false;
|
|
}
|
|
}
|
|
} else if repair {
|
|
if !json {
|
|
ui::check_fail("Config file not found.");
|
|
}
|
|
let answer = prompt_input(" Create default config? [Y/n] ");
|
|
if answer.is_empty() || answer.starts_with('y') || answer.starts_with('Y') {
|
|
let (provider, api_key_env, model) = detect_best_provider();
|
|
let default_config = format!(
|
|
r#"# OpenFang Agent OS configuration
|
|
# See https://github.com/RightNow-AI/openfang for documentation
|
|
|
|
# For Docker, change to "0.0.0.0:4200" or set OPENFANG_LISTEN env var.
|
|
api_listen = "127.0.0.1:4200"
|
|
|
|
[default_model]
|
|
provider = "{provider}"
|
|
model = "{model}"
|
|
api_key_env = "{api_key_env}"
|
|
|
|
[memory]
|
|
decay_rate = 0.05
|
|
"#
|
|
);
|
|
let _ = std::fs::create_dir_all(&openfang_dir);
|
|
if std::fs::write(&config_path, default_config).is_ok() {
|
|
restrict_file_permissions(&config_path);
|
|
if !json {
|
|
ui::check_ok("Created default config.toml");
|
|
}
|
|
repaired = true;
|
|
} else {
|
|
if !json {
|
|
ui::check_fail("Failed to create config.toml");
|
|
}
|
|
all_ok = false;
|
|
}
|
|
} else {
|
|
all_ok = false;
|
|
}
|
|
checks.push(serde_json::json!({"check": "config_file", "status": if repaired { "repaired" } else { "fail" }}));
|
|
} else {
|
|
if !json {
|
|
ui::check_fail("Config file not found.");
|
|
}
|
|
checks.push(serde_json::json!({"check": "config_file", "status": "fail"}));
|
|
all_ok = false;
|
|
}
|
|
|
|
// --- Check 4: Port availability ---
|
|
// Read api_listen from config (default: 127.0.0.1:4200)
|
|
let api_listen = {
|
|
let cfg_path = openfang_dir.join("config.toml");
|
|
if cfg_path.exists() {
|
|
std::fs::read_to_string(&cfg_path)
|
|
.ok()
|
|
.and_then(|s| toml::from_str::<openfang_types::config::KernelConfig>(&s).ok())
|
|
.map(|c| c.api_listen)
|
|
.unwrap_or_else(|| "127.0.0.1:4200".to_string())
|
|
} else {
|
|
"127.0.0.1:4200".to_string()
|
|
}
|
|
};
|
|
if !json {
|
|
println!();
|
|
}
|
|
let daemon_running = find_daemon();
|
|
if let Some(ref base) = daemon_running {
|
|
if !json {
|
|
ui::check_ok(&format!("Daemon running at {base}"));
|
|
}
|
|
checks.push(serde_json::json!({"check": "daemon", "status": "ok", "url": base}));
|
|
} else {
|
|
if !json {
|
|
ui::check_warn("Daemon not running (start with `openfang start`)");
|
|
}
|
|
checks.push(serde_json::json!({"check": "daemon", "status": "warn"}));
|
|
|
|
// Check if the configured port is available
|
|
let bind_addr = if api_listen.starts_with("0.0.0.0") {
|
|
api_listen.replacen("0.0.0.0", "127.0.0.1", 1)
|
|
} else {
|
|
api_listen.clone()
|
|
};
|
|
match std::net::TcpListener::bind(&bind_addr) {
|
|
Ok(_) => {
|
|
if !json {
|
|
ui::check_ok(&format!("Port {api_listen} is available"));
|
|
}
|
|
checks.push(
|
|
serde_json::json!({"check": "port", "status": "ok", "address": api_listen}),
|
|
);
|
|
}
|
|
Err(_) => {
|
|
if !json {
|
|
ui::check_warn(&format!("Port {api_listen} is in use by another process"));
|
|
}
|
|
checks.push(serde_json::json!({"check": "port", "status": "warn", "address": api_listen}));
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Check 5: Stale daemon.json ---
|
|
let daemon_json_path = openfang_dir.join("daemon.json");
|
|
if daemon_json_path.exists() && daemon_running.is_none() {
|
|
if repair {
|
|
let _ = std::fs::remove_file(&daemon_json_path);
|
|
if !json {
|
|
ui::check_ok("Removed stale daemon.json");
|
|
}
|
|
repaired = true;
|
|
} else if !json {
|
|
ui::check_warn(
|
|
"Stale daemon.json found (daemon not running). Run with --repair to clean up.",
|
|
);
|
|
}
|
|
checks.push(serde_json::json!({"check": "stale_daemon_json", "status": if repair { "repaired" } else { "warn" }}));
|
|
}
|
|
|
|
// --- Check 6: Database file ---
|
|
let db_path = openfang_dir.join("data").join("openfang.db");
|
|
if db_path.exists() {
|
|
// Quick SQLite magic bytes check
|
|
if let Ok(bytes) = std::fs::read(&db_path) {
|
|
if bytes.len() >= 16 && bytes.starts_with(b"SQLite format 3") {
|
|
if !json {
|
|
ui::check_ok("Database file (valid SQLite)");
|
|
}
|
|
checks.push(serde_json::json!({"check": "database", "status": "ok"}));
|
|
} else {
|
|
if !json {
|
|
ui::check_fail("Database file exists but is not valid SQLite");
|
|
}
|
|
checks.push(serde_json::json!({"check": "database", "status": "fail"}));
|
|
all_ok = false;
|
|
}
|
|
}
|
|
} else {
|
|
if !json {
|
|
ui::check_warn("No database file (will be created on first run)");
|
|
}
|
|
checks.push(serde_json::json!({"check": "database", "status": "warn"}));
|
|
}
|
|
|
|
// --- Check 7: Disk space ---
|
|
#[cfg(unix)]
|
|
{
|
|
if let Ok(output) = std::process::Command::new("df")
|
|
.args(["-m", &openfang_dir.display().to_string()])
|
|
.output()
|
|
{
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
// Parse the available MB from df output (4th column of 2nd line)
|
|
if let Some(line) = stdout.lines().nth(1) {
|
|
let cols: Vec<&str> = line.split_whitespace().collect();
|
|
if cols.len() >= 4 {
|
|
if let Ok(available_mb) = cols[3].parse::<u64>() {
|
|
if available_mb < 100 {
|
|
if !json {
|
|
ui::check_warn(&format!(
|
|
"Low disk space: {available_mb}MB available"
|
|
));
|
|
}
|
|
checks.push(serde_json::json!({"check": "disk_space", "status": "warn", "available_mb": available_mb}));
|
|
} else {
|
|
if !json {
|
|
ui::check_ok(&format!(
|
|
"Disk space: {available_mb}MB available"
|
|
));
|
|
}
|
|
checks.push(serde_json::json!({"check": "disk_space", "status": "ok", "available_mb": available_mb}));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Check 8: Agent manifests parse correctly ---
|
|
let agents_dir = openfang_dir.join("agents");
|
|
if agents_dir.exists() {
|
|
let mut agent_errors = Vec::new();
|
|
if let Ok(entries) = std::fs::read_dir(&agents_dir) {
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.extension().and_then(|e| e.to_str()) == Some("toml") {
|
|
if let Ok(content) = std::fs::read_to_string(&path) {
|
|
if let Err(e) = toml::from_str::<AgentManifest>(&content) {
|
|
agent_errors.push((
|
|
path.file_name()
|
|
.unwrap_or_default()
|
|
.to_string_lossy()
|
|
.to_string(),
|
|
e.to_string(),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if agent_errors.is_empty() {
|
|
if !json {
|
|
ui::check_ok("Agent manifests are valid");
|
|
}
|
|
checks.push(serde_json::json!({"check": "agent_manifests", "status": "ok"}));
|
|
} else {
|
|
for (file, err) in &agent_errors {
|
|
if !json {
|
|
ui::check_fail(&format!("Invalid manifest {file}: {err}"));
|
|
}
|
|
}
|
|
checks.push(serde_json::json!({"check": "agent_manifests", "status": "fail", "errors": agent_errors.len()}));
|
|
all_ok = false;
|
|
}
|
|
}
|
|
} else {
|
|
if !json {
|
|
ui::check_fail("Could not determine home directory");
|
|
}
|
|
checks.push(serde_json::json!({"check": "home_dir", "status": "fail"}));
|
|
all_ok = false;
|
|
}
|
|
|
|
// --- LLM providers ---
|
|
if !json {
|
|
println!("\n LLM Providers:");
|
|
}
|
|
let provider_keys = [
|
|
("GROQ_API_KEY", "Groq", "groq"),
|
|
("OPENROUTER_API_KEY", "OpenRouter", "openrouter"),
|
|
("ANTHROPIC_API_KEY", "Anthropic", "anthropic"),
|
|
("OPENAI_API_KEY", "OpenAI", "openai"),
|
|
("DEEPSEEK_API_KEY", "DeepSeek", "deepseek"),
|
|
("GEMINI_API_KEY", "Gemini", "gemini"),
|
|
("GOOGLE_API_KEY", "Google", "google"),
|
|
("TOGETHER_API_KEY", "Together", "together"),
|
|
("MISTRAL_API_KEY", "Mistral", "mistral"),
|
|
("FIREWORKS_API_KEY", "Fireworks", "fireworks"),
|
|
];
|
|
|
|
let mut any_key_set = false;
|
|
for (env_var, name, provider_id) in &provider_keys {
|
|
let set = std::env::var(env_var).is_ok();
|
|
if set {
|
|
// --- Check 9: Live key validation ---
|
|
let valid = test_api_key(provider_id, env_var);
|
|
if valid {
|
|
if !json {
|
|
ui::provider_status(name, env_var, true);
|
|
}
|
|
} else {
|
|
if !json {
|
|
ui::check_fail(&format!("{name} ({env_var}) - key rejected (401/403)"));
|
|
}
|
|
all_ok = false;
|
|
}
|
|
any_key_set = true;
|
|
checks.push(serde_json::json!({"check": "provider", "name": name, "env_var": env_var, "status": if valid { "ok" } else { "fail" }, "live_test": !valid}));
|
|
} else {
|
|
if !json {
|
|
ui::provider_status(name, env_var, false);
|
|
}
|
|
checks.push(serde_json::json!({"check": "provider", "name": name, "env_var": env_var, "status": "warn"}));
|
|
}
|
|
}
|
|
|
|
if !any_key_set {
|
|
if !json {
|
|
println!();
|
|
ui::check_fail("No LLM provider API keys found!");
|
|
ui::blank();
|
|
ui::section("Getting an API key (free tiers)");
|
|
ui::suggest_cmd("Groq:", "https://console.groq.com (free, fast)");
|
|
ui::suggest_cmd("Gemini:", "https://aistudio.google.com (free tier)");
|
|
ui::suggest_cmd("DeepSeek:", "https://platform.deepseek.com (low cost)");
|
|
ui::blank();
|
|
ui::hint("Or run: openfang config set-key groq");
|
|
}
|
|
all_ok = false;
|
|
}
|
|
|
|
// --- Check 10: Channel token format validation ---
|
|
if !json {
|
|
println!("\n Channel Integrations:");
|
|
}
|
|
let channel_keys = [
|
|
("TELEGRAM_BOT_TOKEN", "Telegram"),
|
|
("DISCORD_BOT_TOKEN", "Discord"),
|
|
("SLACK_APP_TOKEN", "Slack App"),
|
|
("SLACK_BOT_TOKEN", "Slack Bot"),
|
|
];
|
|
for (env_var, name) in &channel_keys {
|
|
let set = std::env::var(env_var).is_ok();
|
|
if set {
|
|
// Format validation
|
|
let val = std::env::var(env_var).unwrap_or_default();
|
|
let format_ok = match *env_var {
|
|
"TELEGRAM_BOT_TOKEN" => val.contains(':'), // Telegram tokens have format "123456:ABC-DEF..."
|
|
"DISCORD_BOT_TOKEN" => val.len() > 50, // Discord tokens are typically 59+ chars
|
|
"SLACK_APP_TOKEN" => val.starts_with("xapp-"),
|
|
"SLACK_BOT_TOKEN" => val.starts_with("xoxb-"),
|
|
_ => true,
|
|
};
|
|
if format_ok {
|
|
if !json {
|
|
ui::provider_status(name, env_var, true);
|
|
}
|
|
} else if !json {
|
|
ui::check_warn(&format!("{name} ({env_var}) - unexpected token format"));
|
|
}
|
|
checks.push(serde_json::json!({"check": "channel", "name": name, "env_var": env_var, "status": if format_ok { "ok" } else { "warn" }}));
|
|
} else {
|
|
if !json {
|
|
ui::provider_status(name, env_var, false);
|
|
}
|
|
checks.push(serde_json::json!({"check": "channel", "name": name, "env_var": env_var, "status": "warn"}));
|
|
}
|
|
}
|
|
|
|
// --- Check 11: .env keys vs config api_key_env consistency ---
|
|
{
|
|
let openfang_dir = cli_openfang_home();
|
|
let config_path = openfang_dir.join("config.toml");
|
|
if config_path.exists() {
|
|
let config_str = std::fs::read_to_string(&config_path).unwrap_or_default();
|
|
// Look for api_key_env references in config
|
|
for line in config_str.lines() {
|
|
let trimmed = line.trim();
|
|
if let Some(rest) = trimmed.strip_prefix("api_key_env") {
|
|
if let Some(val_part) = rest.strip_prefix('=') {
|
|
let val = val_part.trim().trim_matches('"');
|
|
if !val.is_empty() && std::env::var(val).is_err() {
|
|
if !json {
|
|
ui::check_warn(&format!(
|
|
"Config references {val} but it is not set in env or .env"
|
|
));
|
|
}
|
|
checks.push(serde_json::json!({"check": "env_consistency", "status": "warn", "missing_var": val}));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Check 12: Config deserialization into KernelConfig ---
|
|
{
|
|
let openfang_dir = cli_openfang_home();
|
|
let config_path = openfang_dir.join("config.toml");
|
|
if config_path.exists() {
|
|
if !json {
|
|
println!("\n Config Validation:");
|
|
}
|
|
let config_content = std::fs::read_to_string(&config_path).unwrap_or_default();
|
|
match toml::from_str::<openfang_types::config::KernelConfig>(&config_content) {
|
|
Ok(cfg) => {
|
|
if !json {
|
|
ui::check_ok("Config deserializes into KernelConfig");
|
|
}
|
|
checks.push(serde_json::json!({"check": "config_deser", "status": "ok"}));
|
|
|
|
// Check exec policy
|
|
let mode = format!("{:?}", cfg.exec_policy.mode);
|
|
let safe_bins_count = cfg.exec_policy.safe_bins.len();
|
|
if !json {
|
|
ui::check_ok(&format!(
|
|
"Exec policy: mode={mode}, safe_bins={safe_bins_count}"
|
|
));
|
|
}
|
|
checks.push(serde_json::json!({"check": "exec_policy", "status": "ok", "mode": mode, "safe_bins": safe_bins_count}));
|
|
|
|
// Check includes
|
|
if !cfg.include.is_empty() {
|
|
let mut include_ok = true;
|
|
for inc in &cfg.include {
|
|
let inc_path = openfang_dir.join(inc);
|
|
if inc_path.exists() {
|
|
if !json {
|
|
ui::check_ok(&format!("Include file: {inc}"));
|
|
}
|
|
} else if repair {
|
|
if !json {
|
|
ui::check_warn(&format!("Include file missing: {inc}"));
|
|
}
|
|
include_ok = false;
|
|
} else {
|
|
if !json {
|
|
ui::check_fail(&format!("Include file not found: {inc}"));
|
|
}
|
|
include_ok = false;
|
|
all_ok = false;
|
|
}
|
|
}
|
|
checks.push(serde_json::json!({"check": "config_includes", "status": if include_ok { "ok" } else { "fail" }, "count": cfg.include.len()}));
|
|
}
|
|
|
|
// Check MCP server configs
|
|
if !cfg.mcp_servers.is_empty() {
|
|
let mcp_count = cfg.mcp_servers.len();
|
|
if !json {
|
|
ui::check_ok(&format!("MCP servers configured: {mcp_count}"));
|
|
}
|
|
for server in &cfg.mcp_servers {
|
|
// Validate transport config
|
|
match &server.transport {
|
|
openfang_types::config::McpTransportEntry::Stdio {
|
|
command,
|
|
..
|
|
} => {
|
|
if command.is_empty() {
|
|
if !json {
|
|
ui::check_warn(&format!(
|
|
"MCP server '{}' has empty command",
|
|
server.name
|
|
));
|
|
}
|
|
checks.push(serde_json::json!({"check": "mcp_server_config", "status": "warn", "name": server.name}));
|
|
}
|
|
}
|
|
openfang_types::config::McpTransportEntry::Sse { url } => {
|
|
if url.is_empty() {
|
|
if !json {
|
|
ui::check_warn(&format!(
|
|
"MCP server '{}' has empty URL",
|
|
server.name
|
|
));
|
|
}
|
|
checks.push(serde_json::json!({"check": "mcp_server_config", "status": "warn", "name": server.name}));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
checks.push(serde_json::json!({"check": "mcp_servers", "status": "ok", "count": mcp_count}));
|
|
}
|
|
}
|
|
Err(e) => {
|
|
if !json {
|
|
ui::check_fail(&format!("Config fails KernelConfig deserialization: {e}"));
|
|
}
|
|
checks.push(serde_json::json!({"check": "config_deser", "status": "fail", "error": e.to_string()}));
|
|
all_ok = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Check 13: Skill registry health ---
|
|
{
|
|
if !json {
|
|
println!("\n Skills:");
|
|
}
|
|
let skills_dir = cli_openfang_home().join("skills");
|
|
let mut skill_reg = openfang_skills::registry::SkillRegistry::new(skills_dir.clone());
|
|
skill_reg.load_bundled();
|
|
let bundled_count = skill_reg.count();
|
|
if !json {
|
|
ui::check_ok(&format!("Bundled skills loaded: {bundled_count}"));
|
|
}
|
|
checks.push(
|
|
serde_json::json!({"check": "bundled_skills", "status": "ok", "count": bundled_count}),
|
|
);
|
|
|
|
// Check workspace skills if home dir available
|
|
if skills_dir.exists() {
|
|
match skill_reg.load_workspace_skills(&skills_dir) {
|
|
Ok(_) => {
|
|
let total = skill_reg.count();
|
|
let ws_count = total.saturating_sub(bundled_count);
|
|
if ws_count > 0 {
|
|
if !json {
|
|
ui::check_ok(&format!("Workspace skills loaded: {ws_count}"));
|
|
}
|
|
checks.push(serde_json::json!({"check": "workspace_skills", "status": "ok", "count": ws_count}));
|
|
}
|
|
}
|
|
Err(e) => {
|
|
if !json {
|
|
ui::check_warn(&format!("Failed to load workspace skills: {e}"));
|
|
}
|
|
checks.push(serde_json::json!({"check": "workspace_skills", "status": "warn", "error": e.to_string()}));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for prompt injection issues in skill definitions
|
|
// Only flag Critical-severity warnings (Warning-level hits are expected
|
|
// in bundled skills that mention shell commands in educational context).
|
|
let skills = skill_reg.list();
|
|
let mut injection_warnings = 0;
|
|
for skill in &skills {
|
|
if let Some(ref prompt) = skill.manifest.prompt_context {
|
|
let warnings = openfang_skills::verify::SkillVerifier::scan_prompt_content(prompt);
|
|
let has_critical = warnings.iter().any(|w| {
|
|
matches!(
|
|
w.severity,
|
|
openfang_skills::verify::WarningSeverity::Critical
|
|
)
|
|
});
|
|
if has_critical {
|
|
injection_warnings += 1;
|
|
if !json {
|
|
ui::check_warn(&format!(
|
|
"Prompt injection warning in skill: {}",
|
|
skill.manifest.skill.name
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let blocked = skill_reg.blocked_count();
|
|
if injection_warnings > 0 || blocked > 0 {
|
|
let total_warnings = injection_warnings + blocked;
|
|
if blocked > 0 && !json {
|
|
ui::check_warn(&format!(
|
|
"{blocked} workspace skill(s) were blocked for critical prompt injection"
|
|
));
|
|
}
|
|
checks.push(serde_json::json!({"check": "skill_injection_scan", "status": "warn", "warnings": total_warnings, "blocked": blocked}));
|
|
} else {
|
|
if !json {
|
|
ui::check_ok("All skills pass prompt injection scan");
|
|
}
|
|
checks.push(serde_json::json!({"check": "skill_injection_scan", "status": "ok"}));
|
|
}
|
|
}
|
|
|
|
// --- Check 14: Extension registry health ---
|
|
{
|
|
if !json {
|
|
println!("\n Extensions:");
|
|
}
|
|
let openfang_dir = cli_openfang_home();
|
|
let mut ext_registry =
|
|
openfang_extensions::registry::IntegrationRegistry::new(&openfang_dir);
|
|
ext_registry.load_bundled();
|
|
let _ = ext_registry.load_installed();
|
|
let template_count = ext_registry.template_count();
|
|
let installed_count = ext_registry.installed_count();
|
|
if !json {
|
|
ui::check_ok(&format!(
|
|
"Available integration templates: {template_count}"
|
|
));
|
|
ui::check_ok(&format!("Installed integrations: {installed_count}"));
|
|
}
|
|
checks.push(serde_json::json!({"check": "extensions_available", "status": "ok", "count": template_count}));
|
|
checks.push(serde_json::json!({"check": "extensions_installed", "status": "ok", "count": installed_count}));
|
|
}
|
|
|
|
// --- Check 15: Daemon health detail (if running) ---
|
|
if let Some(ref base) = find_daemon() {
|
|
if !json {
|
|
println!("\n Daemon Health:");
|
|
}
|
|
let client = daemon_client();
|
|
match client.get(format!("{base}/api/health/detail")).send() {
|
|
Ok(resp) if resp.status().is_success() => {
|
|
if let Ok(body) = resp.json::<serde_json::Value>() {
|
|
if let Some(agents) = body.get("agent_count").and_then(|v| v.as_u64()) {
|
|
if !json {
|
|
ui::check_ok(&format!("Running agents: {agents}"));
|
|
}
|
|
checks.push(serde_json::json!({"check": "daemon_agents", "status": "ok", "count": agents}));
|
|
}
|
|
if let Some(uptime) = body.get("uptime_secs").and_then(|v| v.as_u64()) {
|
|
let hours = uptime / 3600;
|
|
let mins = (uptime % 3600) / 60;
|
|
if !json {
|
|
ui::check_ok(&format!("Daemon uptime: {hours}h {mins}m"));
|
|
}
|
|
checks.push(serde_json::json!({"check": "daemon_uptime", "status": "ok", "secs": uptime}));
|
|
}
|
|
if let Some(db_status) = body.get("database").and_then(|v| v.as_str()) {
|
|
if db_status == "connected" || db_status == "ok" {
|
|
if !json {
|
|
ui::check_ok("Database connectivity: OK");
|
|
}
|
|
} else {
|
|
if !json {
|
|
ui::check_fail(&format!("Database status: {db_status}"));
|
|
}
|
|
all_ok = false;
|
|
}
|
|
checks.push(serde_json::json!({"check": "daemon_db", "status": db_status}));
|
|
}
|
|
}
|
|
}
|
|
Ok(resp) => {
|
|
if !json {
|
|
ui::check_warn(&format!("Health detail returned {}", resp.status()));
|
|
}
|
|
checks.push(serde_json::json!({"check": "daemon_health", "status": "warn"}));
|
|
}
|
|
Err(e) => {
|
|
if !json {
|
|
ui::check_warn(&format!("Failed to query daemon health: {e}"));
|
|
}
|
|
checks.push(serde_json::json!({"check": "daemon_health", "status": "warn", "error": e.to_string()}));
|
|
}
|
|
}
|
|
|
|
// Check skills endpoint
|
|
match client.get(format!("{base}/api/skills")).send() {
|
|
Ok(resp) if resp.status().is_success() => {
|
|
if let Ok(body) = resp.json::<serde_json::Value>() {
|
|
if let Some(arr) = body.as_array() {
|
|
if !json {
|
|
ui::check_ok(&format!("Skills loaded in daemon: {}", arr.len()));
|
|
}
|
|
checks.push(serde_json::json!({"check": "daemon_skills", "status": "ok", "count": arr.len()}));
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
// Check MCP servers endpoint
|
|
match client.get(format!("{base}/api/mcp/servers")).send() {
|
|
Ok(resp) if resp.status().is_success() => {
|
|
if let Ok(body) = resp.json::<serde_json::Value>() {
|
|
if let Some(arr) = body.as_array() {
|
|
let connected = arr
|
|
.iter()
|
|
.filter(|s| {
|
|
s.get("connected")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false)
|
|
})
|
|
.count();
|
|
if !json {
|
|
ui::check_ok(&format!(
|
|
"MCP servers: {} configured, {} connected",
|
|
arr.len(),
|
|
connected
|
|
));
|
|
}
|
|
checks.push(serde_json::json!({"check": "daemon_mcp", "status": "ok", "configured": arr.len(), "connected": connected}));
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
// Check extensions health endpoint
|
|
match client.get(format!("{base}/api/integrations/health")).send() {
|
|
Ok(resp) if resp.status().is_success() => {
|
|
if let Ok(body) = resp.json::<serde_json::Value>() {
|
|
let entries = body.get("health").and_then(|h| h.as_array());
|
|
if let Some(arr) = entries {
|
|
let healthy = arr
|
|
.iter()
|
|
.filter(|v| {
|
|
v.get("status")
|
|
.and_then(|s| s.as_str())
|
|
.map(|s| s.eq_ignore_ascii_case("ready"))
|
|
.unwrap_or(false)
|
|
})
|
|
.count();
|
|
let total = arr.len();
|
|
if healthy == total {
|
|
if !json {
|
|
ui::check_ok(&format!(
|
|
"Integration health: {healthy}/{total} healthy"
|
|
));
|
|
}
|
|
} else if !json {
|
|
ui::check_warn(&format!(
|
|
"Integration health: {healthy}/{total} healthy"
|
|
));
|
|
}
|
|
checks.push(serde_json::json!({"check": "integration_health", "status": if healthy == total { "ok" } else { "warn" }, "healthy": healthy, "total": total}));
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
if !json {
|
|
println!();
|
|
}
|
|
match std::process::Command::new("rustc")
|
|
.arg("--version")
|
|
.output()
|
|
{
|
|
Ok(output) => {
|
|
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
if !json {
|
|
ui::check_ok(&format!("Rust: {version}"));
|
|
}
|
|
checks.push(serde_json::json!({"check": "rust", "status": "ok", "version": version}));
|
|
}
|
|
Err(_) => {
|
|
if !json {
|
|
ui::check_fail("Rust toolchain not found");
|
|
}
|
|
checks.push(serde_json::json!({"check": "rust", "status": "fail"}));
|
|
all_ok = false;
|
|
}
|
|
}
|
|
|
|
// Python runtime check
|
|
match std::process::Command::new("python3")
|
|
.arg("--version")
|
|
.output()
|
|
{
|
|
Ok(output) if output.status.success() => {
|
|
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
if !json {
|
|
ui::check_ok(&format!("Python: {version}"));
|
|
}
|
|
checks.push(serde_json::json!({"check": "python", "status": "ok", "version": version}));
|
|
}
|
|
_ => {
|
|
// Try `python` instead
|
|
match std::process::Command::new("python")
|
|
.arg("--version")
|
|
.output()
|
|
{
|
|
Ok(output) if output.status.success() => {
|
|
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
if !json {
|
|
ui::check_ok(&format!("Python: {version}"));
|
|
}
|
|
checks.push(
|
|
serde_json::json!({"check": "python", "status": "ok", "version": version}),
|
|
);
|
|
}
|
|
_ => {
|
|
if !json {
|
|
ui::check_warn("Python not found (needed for Python skill runtime)");
|
|
}
|
|
checks.push(serde_json::json!({"check": "python", "status": "warn"}));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Node.js runtime check
|
|
match std::process::Command::new("node").arg("--version").output() {
|
|
Ok(output) if output.status.success() => {
|
|
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
if !json {
|
|
ui::check_ok(&format!("Node.js: {version}"));
|
|
}
|
|
checks.push(serde_json::json!({"check": "node", "status": "ok", "version": version}));
|
|
}
|
|
_ => {
|
|
if !json {
|
|
ui::check_warn("Node.js not found (needed for Node skill runtime)");
|
|
}
|
|
checks.push(serde_json::json!({"check": "node", "status": "warn"}));
|
|
}
|
|
}
|
|
|
|
if json {
|
|
write_stdout_safe(
|
|
&serde_json::to_string_pretty(&serde_json::json!({
|
|
"all_ok": all_ok,
|
|
"checks": checks,
|
|
}))
|
|
.unwrap_or_default(),
|
|
);
|
|
} else {
|
|
println!();
|
|
if all_ok {
|
|
ui::success("All checks passed! OpenFang is ready.");
|
|
if find_daemon().is_none() {
|
|
ui::hint("Start the daemon: openfang start");
|
|
}
|
|
} else if repaired {
|
|
ui::success("Repairs applied. Re-run `openfang doctor` to verify.");
|
|
} else {
|
|
ui::error("Some checks failed.");
|
|
if !repair {
|
|
ui::hint("Run `openfang doctor --repair` to attempt auto-fix");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Dashboard command
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn cmd_dashboard() {
|
|
let base = if let Some(url) = find_daemon() {
|
|
url
|
|
} else {
|
|
// Auto-start the daemon
|
|
ui::hint("No daemon running — starting one now...");
|
|
match start_daemon_background() {
|
|
Ok(url) => {
|
|
ui::success("Daemon started");
|
|
url
|
|
}
|
|
Err(e) => {
|
|
ui::error_with_fix(
|
|
&format!("Could not start daemon: {e}"),
|
|
"Start it manually: openfang start",
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
};
|
|
|
|
let url = format!("{base}/");
|
|
ui::success(&format!("Opening dashboard at {url}"));
|
|
if copy_to_clipboard(&url) {
|
|
ui::hint("URL copied to clipboard");
|
|
}
|
|
if !open_in_browser(&url) {
|
|
ui::hint(&format!("Could not open browser. Visit: {url}"));
|
|
}
|
|
}
|
|
|
|
/// Copy text to the system clipboard. Returns true on success.
|
|
fn copy_to_clipboard(text: &str) -> bool {
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
// Use PowerShell to set clipboard (handles special characters better than cmd)
|
|
std::process::Command::new("powershell")
|
|
.args([
|
|
"-NoProfile",
|
|
"-Command",
|
|
&format!("Set-Clipboard '{}'", text.replace('\'', "''")),
|
|
])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.status()
|
|
.map(|s| s.success())
|
|
.unwrap_or(false)
|
|
}
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
use std::io::Write as IoWrite;
|
|
std::process::Command::new("pbcopy")
|
|
.stdin(std::process::Stdio::piped())
|
|
.spawn()
|
|
.and_then(|mut child| {
|
|
if let Some(ref mut stdin) = child.stdin {
|
|
let _ = stdin.write_all(text.as_bytes());
|
|
}
|
|
child.wait()
|
|
})
|
|
.map(|s| s.success())
|
|
.unwrap_or(false)
|
|
}
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
use std::io::Write as IoWrite;
|
|
// Try xclip first, then xsel
|
|
let result = std::process::Command::new("xclip")
|
|
.args(["-selection", "clipboard"])
|
|
.stdin(std::process::Stdio::piped())
|
|
.spawn()
|
|
.and_then(|mut child| {
|
|
if let Some(ref mut stdin) = child.stdin {
|
|
let _ = stdin.write_all(text.as_bytes());
|
|
}
|
|
child.wait()
|
|
})
|
|
.map(|s| s.success())
|
|
.unwrap_or(false);
|
|
if result {
|
|
return true;
|
|
}
|
|
std::process::Command::new("xsel")
|
|
.args(["--clipboard", "--input"])
|
|
.stdin(std::process::Stdio::piped())
|
|
.spawn()
|
|
.and_then(|mut child| {
|
|
if let Some(ref mut stdin) = child.stdin {
|
|
let _ = stdin.write_all(text.as_bytes());
|
|
}
|
|
child.wait()
|
|
})
|
|
.map(|s| s.success())
|
|
.unwrap_or(false)
|
|
}
|
|
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
|
{
|
|
let _ = text;
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Try to open a URL in the default browser. Returns true on success.
|
|
pub(crate) fn open_in_browser(url: &str) -> bool {
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
std::process::Command::new("cmd")
|
|
.args(["/C", "start", "", url])
|
|
.spawn()
|
|
.is_ok()
|
|
}
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
std::process::Command::new("open").arg(url).spawn().is_ok()
|
|
}
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
// Try multiple openers in order. xdg-open is the standard, but it
|
|
// (or the browser it launches) can fail with EPERM in sandboxed
|
|
// environments (containers, Snap, Flatpak, user-namespace
|
|
// restrictions). Fall through to alternatives if any opener fails.
|
|
let openers = [
|
|
"xdg-open",
|
|
"sensible-browser",
|
|
"x-www-browser",
|
|
"firefox",
|
|
"google-chrome",
|
|
"chromium",
|
|
"chromium-browser",
|
|
];
|
|
for opener in &openers {
|
|
let result = std::process::Command::new(opener)
|
|
.arg(url)
|
|
.stdin(std::process::Stdio::null())
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.spawn();
|
|
if result.is_ok() {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
|
{
|
|
let _ = url;
|
|
false
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shell completion command
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn cmd_completion(shell: clap_complete::Shell) {
|
|
use clap::CommandFactory;
|
|
let mut cmd = Cli::command();
|
|
clap_complete::generate(shell, &mut cmd, "openfang", &mut std::io::stdout());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Workflow commands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn cmd_workflow_list() {
|
|
let base = require_daemon("workflow list");
|
|
let client = daemon_client();
|
|
let body = daemon_json(client.get(format!("{base}/api/workflows")).send());
|
|
|
|
match body.as_array() {
|
|
Some(workflows) if workflows.is_empty() => println!("No workflows registered."),
|
|
Some(workflows) => {
|
|
println!("{:<38} {:<20} {:<6} CREATED", "ID", "NAME", "STEPS");
|
|
println!("{}", "-".repeat(80));
|
|
for w in workflows {
|
|
println!(
|
|
"{:<38} {:<20} {:<6} {}",
|
|
w["id"].as_str().unwrap_or("?"),
|
|
w["name"].as_str().unwrap_or("?"),
|
|
w["steps"].as_u64().unwrap_or(0),
|
|
w["created_at"].as_str().unwrap_or("?"),
|
|
);
|
|
}
|
|
}
|
|
None => println!("No workflows registered."),
|
|
}
|
|
}
|
|
|
|
fn cmd_workflow_create(file: PathBuf) {
|
|
let base = require_daemon("workflow create");
|
|
if !file.exists() {
|
|
eprintln!("Workflow file not found: {}", file.display());
|
|
std::process::exit(1);
|
|
}
|
|
let contents = std::fs::read_to_string(&file).unwrap_or_else(|e| {
|
|
eprintln!("Error reading workflow file: {e}");
|
|
std::process::exit(1);
|
|
});
|
|
let json_body: serde_json::Value = serde_json::from_str(&contents).unwrap_or_else(|e| {
|
|
eprintln!("Invalid JSON: {e}");
|
|
std::process::exit(1);
|
|
});
|
|
|
|
let client = daemon_client();
|
|
let body = daemon_json(
|
|
client
|
|
.post(format!("{base}/api/workflows"))
|
|
.json(&json_body)
|
|
.send(),
|
|
);
|
|
|
|
if let Some(id) = body["workflow_id"].as_str() {
|
|
println!("Workflow created successfully!");
|
|
println!(" ID: {id}");
|
|
} else {
|
|
eprintln!(
|
|
"Failed to create workflow: {}",
|
|
body["error"].as_str().unwrap_or("Unknown error")
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
fn cmd_workflow_run(workflow_id: &str, input: &str) {
|
|
let base = require_daemon("workflow run");
|
|
let client = daemon_client();
|
|
let body = daemon_json(
|
|
client
|
|
.post(format!("{base}/api/workflows/{workflow_id}/run"))
|
|
.json(&serde_json::json!({"input": input}))
|
|
.send(),
|
|
);
|
|
|
|
if let Some(output) = body["output"].as_str() {
|
|
println!("Workflow completed!");
|
|
println!(" Run ID: {}", body["run_id"].as_str().unwrap_or("?"));
|
|
println!(" Output:\n{output}");
|
|
} else {
|
|
eprintln!(
|
|
"Workflow failed: {}",
|
|
body["error"].as_str().unwrap_or("Unknown error")
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
fn cmd_workflow_get(workflow_id: &str) {
|
|
let base = require_daemon("workflow get");
|
|
let client = daemon_client();
|
|
let body = daemon_json(
|
|
client
|
|
.get(format!("{base}/api/workflows/{workflow_id}"))
|
|
.send(),
|
|
);
|
|
|
|
if body.get("error").is_some() {
|
|
eprintln!(
|
|
"Workflow not found: {}",
|
|
body["error"].as_str().unwrap_or("Unknown error")
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
|
|
println!("Workflow: {}", body["name"].as_str().unwrap_or("?"));
|
|
println!(" ID: {}", body["id"].as_str().unwrap_or("?"));
|
|
println!(
|
|
" Description: {}",
|
|
body["description"].as_str().unwrap_or("")
|
|
);
|
|
println!(
|
|
" Created: {}",
|
|
body["created_at"].as_str().unwrap_or("?")
|
|
);
|
|
|
|
if let Some(steps) = body["steps"].as_array() {
|
|
println!(" Steps ({}):", steps.len());
|
|
for (i, s) in steps.iter().enumerate() {
|
|
let name = s["name"].as_str().unwrap_or("step");
|
|
let agent = s["agent"]
|
|
.get("name")
|
|
.or_else(|| s["agent"].get("id"))
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("?");
|
|
println!(" #{}: {} -> {}", i + 1, name, agent);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_workflow_update(workflow_id: &str, file: PathBuf) {
|
|
let base = require_daemon("workflow update");
|
|
if !file.exists() {
|
|
eprintln!("Workflow file not found: {}", file.display());
|
|
std::process::exit(1);
|
|
}
|
|
let contents = std::fs::read_to_string(&file).unwrap_or_else(|e| {
|
|
eprintln!("Error reading workflow file: {e}");
|
|
std::process::exit(1);
|
|
});
|
|
let json_body: serde_json::Value = serde_json::from_str(&contents).unwrap_or_else(|e| {
|
|
eprintln!("Invalid JSON: {e}");
|
|
std::process::exit(1);
|
|
});
|
|
|
|
let client = daemon_client();
|
|
let body = daemon_json(
|
|
client
|
|
.put(format!("{base}/api/workflows/{workflow_id}"))
|
|
.json(&json_body)
|
|
.send(),
|
|
);
|
|
|
|
if body["status"].as_str() == Some("updated") {
|
|
println!("Workflow updated successfully!");
|
|
println!(" ID: {}", body["workflow_id"].as_str().unwrap_or("?"));
|
|
} else {
|
|
eprintln!(
|
|
"Failed to update workflow: {}",
|
|
body["error"].as_str().unwrap_or("Unknown error")
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
fn cmd_workflow_delete(workflow_id: &str) {
|
|
let base = require_daemon("workflow delete");
|
|
let client = daemon_client();
|
|
let body = daemon_json(
|
|
client
|
|
.delete(format!("{base}/api/workflows/{workflow_id}"))
|
|
.send(),
|
|
);
|
|
|
|
if body["status"].as_str() == Some("removed") {
|
|
println!("Workflow deleted successfully!");
|
|
println!(" ID: {}", body["workflow_id"].as_str().unwrap_or("?"));
|
|
} else {
|
|
eprintln!(
|
|
"Failed to delete workflow: {}",
|
|
body["error"].as_str().unwrap_or("Unknown error")
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Trigger commands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn cmd_trigger_list(agent_id: Option<&str>) {
|
|
let base = require_daemon("trigger list");
|
|
let client = daemon_client();
|
|
|
|
let url = match agent_id {
|
|
Some(id) => format!("{base}/api/triggers?agent_id={id}"),
|
|
None => format!("{base}/api/triggers"),
|
|
};
|
|
let body = daemon_json(client.get(&url).send());
|
|
|
|
match body.as_array() {
|
|
Some(triggers) if triggers.is_empty() => println!("No triggers registered."),
|
|
Some(triggers) => {
|
|
println!(
|
|
"{:<38} {:<38} {:<8} {:<6} PATTERN",
|
|
"TRIGGER ID", "AGENT ID", "ENABLED", "FIRES"
|
|
);
|
|
println!("{}", "-".repeat(110));
|
|
for t in triggers {
|
|
println!(
|
|
"{:<38} {:<38} {:<8} {:<6} {}",
|
|
t["id"].as_str().unwrap_or("?"),
|
|
t["agent_id"].as_str().unwrap_or("?"),
|
|
t["enabled"].as_bool().unwrap_or(false),
|
|
t["fire_count"].as_u64().unwrap_or(0),
|
|
t["pattern"],
|
|
);
|
|
}
|
|
}
|
|
None => println!("No triggers registered."),
|
|
}
|
|
}
|
|
|
|
fn cmd_trigger_create(agent_id: &str, pattern_json: &str, prompt: &str, max_fires: u64) {
|
|
let base = require_daemon("trigger create");
|
|
let pattern: serde_json::Value = serde_json::from_str(pattern_json).unwrap_or_else(|e| {
|
|
eprintln!("Invalid pattern JSON: {e}");
|
|
eprintln!("Examples:");
|
|
eprintln!(" '{{\"lifecycle\":{{}}}}'");
|
|
eprintln!(" '{{\"agent_spawned\":{{\"name_pattern\":\"*\"}}}}'");
|
|
eprintln!(" '{{\"agent_terminated\":{{}}}}'");
|
|
eprintln!(" '{{\"all\":{{}}}}'");
|
|
std::process::exit(1);
|
|
});
|
|
|
|
let client = daemon_client();
|
|
let body = daemon_json(
|
|
client
|
|
.post(format!("{base}/api/triggers"))
|
|
.json(&serde_json::json!({
|
|
"agent_id": agent_id,
|
|
"pattern": pattern,
|
|
"prompt_template": prompt,
|
|
"max_fires": max_fires,
|
|
}))
|
|
.send(),
|
|
);
|
|
|
|
if let Some(id) = body["trigger_id"].as_str() {
|
|
println!("Trigger created successfully!");
|
|
println!(" Trigger ID: {id}");
|
|
println!(" Agent ID: {agent_id}");
|
|
} else {
|
|
eprintln!(
|
|
"Failed to create trigger: {}",
|
|
body["error"].as_str().unwrap_or("Unknown error")
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
fn cmd_trigger_delete(trigger_id: &str) {
|
|
let base = require_daemon("trigger delete");
|
|
let client = daemon_client();
|
|
let body = daemon_json(
|
|
client
|
|
.delete(format!("{base}/api/triggers/{trigger_id}"))
|
|
.send(),
|
|
);
|
|
|
|
if body.get("status").is_some() {
|
|
println!("Trigger {trigger_id} deleted.");
|
|
} else {
|
|
eprintln!(
|
|
"Failed to delete trigger: {}",
|
|
body["error"].as_str().unwrap_or("Unknown error")
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
/// Require a running daemon — exit with helpful message if not found.
|
|
fn require_daemon(command: &str) -> String {
|
|
find_daemon().unwrap_or_else(|| {
|
|
ui::error_with_fix(
|
|
&format!("`openfang {command}` requires a running daemon"),
|
|
"Start the daemon: openfang start",
|
|
);
|
|
ui::hint("Or try `openfang chat` which works without a daemon");
|
|
std::process::exit(1);
|
|
})
|
|
}
|
|
|
|
fn boot_kernel(config: Option<PathBuf>) -> OpenFangKernel {
|
|
match OpenFangKernel::boot(config.as_deref()) {
|
|
Ok(k) => k,
|
|
Err(e) => {
|
|
boot_kernel_error(&e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Migrate command
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn cmd_migrate(args: MigrateArgs) {
|
|
let source = match args.from {
|
|
MigrateSourceArg::Openclaw => openfang_migrate::MigrateSource::OpenClaw,
|
|
MigrateSourceArg::Langchain => openfang_migrate::MigrateSource::LangChain,
|
|
MigrateSourceArg::Autogpt => openfang_migrate::MigrateSource::AutoGpt,
|
|
};
|
|
|
|
let source_dir = args.source_dir.unwrap_or_else(|| {
|
|
let home = dirs::home_dir().unwrap_or_else(|| {
|
|
eprintln!("Error: Could not determine home directory");
|
|
std::process::exit(1);
|
|
});
|
|
match source {
|
|
openfang_migrate::MigrateSource::OpenClaw => home.join(".openclaw"),
|
|
openfang_migrate::MigrateSource::LangChain => home.join(".langchain"),
|
|
openfang_migrate::MigrateSource::AutoGpt => home.join("Auto-GPT"),
|
|
}
|
|
});
|
|
|
|
let target_dir = cli_openfang_home();
|
|
|
|
println!("Migrating from {} ({})...", source, source_dir.display());
|
|
if args.dry_run {
|
|
println!(" (dry run — no changes will be made)\n");
|
|
}
|
|
|
|
let options = openfang_migrate::MigrateOptions {
|
|
source,
|
|
source_dir,
|
|
target_dir,
|
|
dry_run: args.dry_run,
|
|
};
|
|
|
|
match openfang_migrate::run_migration(&options) {
|
|
Ok(report) => {
|
|
report.print_summary();
|
|
|
|
// Save migration report
|
|
if !args.dry_run {
|
|
let report_path = options.target_dir.join("migration_report.md");
|
|
if let Err(e) = std::fs::write(&report_path, report.to_markdown()) {
|
|
eprintln!("Warning: Could not save migration report: {e}");
|
|
} else {
|
|
println!("\n Report saved to: {}", report_path.display());
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Migration failed: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Skill commands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn cmd_skill_install(source: &str) {
|
|
let home = openfang_home();
|
|
let skills_dir = home.join("skills");
|
|
std::fs::create_dir_all(&skills_dir).unwrap_or_else(|e| {
|
|
eprintln!("Error creating skills directory: {e}");
|
|
std::process::exit(1);
|
|
});
|
|
|
|
let source_path = PathBuf::from(source);
|
|
if source_path.exists() && source_path.is_dir() {
|
|
// Local directory install
|
|
let manifest_path = source_path.join("skill.toml");
|
|
if !manifest_path.exists() {
|
|
// Check if it's an OpenClaw skill
|
|
if openfang_skills::openclaw_compat::detect_openclaw_skill(&source_path) {
|
|
println!("Detected OpenClaw skill format. Converting...");
|
|
match openfang_skills::openclaw_compat::convert_openclaw_skill(&source_path) {
|
|
Ok(manifest) => {
|
|
let dest = skills_dir.join(&manifest.skill.name);
|
|
// Copy skill directory
|
|
copy_dir_recursive(&source_path, &dest);
|
|
if let Err(e) = openfang_skills::openclaw_compat::write_openfang_manifest(
|
|
&dest, &manifest,
|
|
) {
|
|
eprintln!("Failed to write manifest: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
println!("Installed OpenClaw skill: {}", manifest.skill.name);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Failed to convert OpenClaw skill: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
eprintln!("No skill.toml found in {source}");
|
|
std::process::exit(1);
|
|
}
|
|
|
|
// Read manifest to get skill name
|
|
let toml_str = std::fs::read_to_string(&manifest_path).unwrap_or_else(|e| {
|
|
eprintln!("Error reading skill.toml: {e}");
|
|
std::process::exit(1);
|
|
});
|
|
let manifest: openfang_skills::SkillManifest =
|
|
toml::from_str(&toml_str).unwrap_or_else(|e| {
|
|
eprintln!("Error parsing skill.toml: {e}");
|
|
std::process::exit(1);
|
|
});
|
|
|
|
let dest = skills_dir.join(&manifest.skill.name);
|
|
copy_dir_recursive(&source_path, &dest);
|
|
println!(
|
|
"Installed skill: {} v{}",
|
|
manifest.skill.name, manifest.skill.version
|
|
);
|
|
} else if source.starts_with("https://")
|
|
|| source.starts_with("http://")
|
|
|| source.starts_with("git@")
|
|
{
|
|
// Git URL install — clone to temp dir then install from there
|
|
ui::step(&format!("Cloning skill from {source}..."));
|
|
let tmp_dir = tempfile::tempdir().unwrap_or_else(|e| {
|
|
eprintln!("Failed to create temp directory: {e}");
|
|
std::process::exit(1);
|
|
});
|
|
let clone_path = tmp_dir.path().join("skill");
|
|
let status = std::process::Command::new("git")
|
|
.args([
|
|
"clone",
|
|
"--depth",
|
|
"1",
|
|
source,
|
|
clone_path.to_str().unwrap(),
|
|
])
|
|
.status();
|
|
match status {
|
|
Ok(s) if s.success() => {}
|
|
Ok(_) => {
|
|
eprintln!("Failed to clone repository: {source}");
|
|
std::process::exit(1);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Failed to run git: {e}");
|
|
ui::hint("Make sure git is installed and available on your PATH.");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
// Reuse the local directory install logic on the cloned repo
|
|
let manifest_path = clone_path.join("skill.toml");
|
|
if !manifest_path.exists() {
|
|
if openfang_skills::openclaw_compat::detect_openclaw_skill(&clone_path) {
|
|
println!("Detected OpenClaw skill format. Converting...");
|
|
match openfang_skills::openclaw_compat::convert_openclaw_skill(&clone_path) {
|
|
Ok(manifest) => {
|
|
let dest = skills_dir.join(&manifest.skill.name);
|
|
copy_dir_recursive(&clone_path, &dest);
|
|
if let Err(e) = openfang_skills::openclaw_compat::write_openfang_manifest(
|
|
&dest, &manifest,
|
|
) {
|
|
eprintln!("Failed to write manifest: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
println!("Installed OpenClaw skill: {}", manifest.skill.name);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Failed to convert OpenClaw skill: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
eprintln!("No skill.toml found in cloned repository: {source}");
|
|
std::process::exit(1);
|
|
}
|
|
|
|
let toml_str = std::fs::read_to_string(&manifest_path).unwrap_or_else(|e| {
|
|
eprintln!("Error reading skill.toml: {e}");
|
|
std::process::exit(1);
|
|
});
|
|
let manifest: openfang_skills::SkillManifest =
|
|
toml::from_str(&toml_str).unwrap_or_else(|e| {
|
|
eprintln!("Error parsing skill.toml: {e}");
|
|
std::process::exit(1);
|
|
});
|
|
|
|
let dest = skills_dir.join(&manifest.skill.name);
|
|
copy_dir_recursive(&clone_path, &dest);
|
|
println!(
|
|
"Installed skill: {} v{}",
|
|
manifest.skill.name, manifest.skill.version
|
|
);
|
|
} else {
|
|
// Remote install from FangHub
|
|
println!("Installing {source} from FangHub...");
|
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
let client = openfang_skills::marketplace::MarketplaceClient::new(
|
|
openfang_skills::marketplace::MarketplaceConfig::default(),
|
|
);
|
|
match rt.block_on(client.install(source, &skills_dir)) {
|
|
Ok(version) => println!("Installed {source} {version}"),
|
|
Err(e) => {
|
|
eprintln!("Failed to install skill: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_skill_list() {
|
|
let home = openfang_home();
|
|
let skills_dir = home.join("skills");
|
|
|
|
let mut registry = openfang_skills::registry::SkillRegistry::new(skills_dir);
|
|
match registry.load_all() {
|
|
Ok(0) => println!("No skills installed."),
|
|
Ok(count) => {
|
|
println!("{count} skill(s) installed:\n");
|
|
println!(
|
|
"{:<20} {:<10} {:<8} DESCRIPTION",
|
|
"NAME", "VERSION", "TOOLS"
|
|
);
|
|
println!("{}", "-".repeat(70));
|
|
for skill in registry.list() {
|
|
println!(
|
|
"{:<20} {:<10} {:<8} {}",
|
|
skill.manifest.skill.name,
|
|
skill.manifest.skill.version,
|
|
skill.manifest.tools.provided.len(),
|
|
skill.manifest.skill.description,
|
|
);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error loading skills: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_skill_remove(name: &str) {
|
|
let home = openfang_home();
|
|
let skills_dir = home.join("skills");
|
|
|
|
let mut registry = openfang_skills::registry::SkillRegistry::new(skills_dir);
|
|
let _ = registry.load_all();
|
|
match registry.remove(name) {
|
|
Ok(()) => println!("Removed skill: {name}"),
|
|
Err(e) => {
|
|
eprintln!("Failed to remove skill: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_skill_search(query: &str) {
|
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
let client = openfang_skills::marketplace::MarketplaceClient::new(
|
|
openfang_skills::marketplace::MarketplaceConfig::default(),
|
|
);
|
|
match rt.block_on(client.search(query)) {
|
|
Ok(results) if results.is_empty() => println!("No skills found for \"{query}\"."),
|
|
Ok(results) => {
|
|
println!("Skills matching \"{query}\":\n");
|
|
for r in results {
|
|
println!(" {} ({})", r.name, r.stars);
|
|
if !r.description.is_empty() {
|
|
println!(" {}", r.description);
|
|
}
|
|
println!(" {}", r.url);
|
|
println!();
|
|
}
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Search failed: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_skill_create() {
|
|
let name = prompt_input("Skill name: ");
|
|
let description = prompt_input("Description: ");
|
|
let runtime = prompt_input("Runtime (python/node/wasm) [python]: ");
|
|
let runtime = if runtime.is_empty() {
|
|
"python".to_string()
|
|
} else {
|
|
runtime
|
|
};
|
|
|
|
let home = openfang_home();
|
|
let skill_dir = home.join("skills").join(&name);
|
|
std::fs::create_dir_all(skill_dir.join("src")).unwrap_or_else(|e| {
|
|
eprintln!("Error creating skill directory: {e}");
|
|
std::process::exit(1);
|
|
});
|
|
|
|
let manifest = format!(
|
|
r#"[skill]
|
|
name = "{name}"
|
|
version = "0.1.0"
|
|
description = "{description}"
|
|
author = ""
|
|
license = "MIT"
|
|
tags = []
|
|
|
|
[runtime]
|
|
type = "{runtime}"
|
|
entry = "src/main.py"
|
|
|
|
[[tools.provided]]
|
|
name = "{tool_name}"
|
|
description = "{description}"
|
|
input_schema = {{ type = "object", properties = {{ input = {{ type = "string" }} }}, required = ["input"] }}
|
|
|
|
[requirements]
|
|
tools = []
|
|
capabilities = []
|
|
"#,
|
|
tool_name = name.replace('-', "_"),
|
|
);
|
|
|
|
std::fs::write(skill_dir.join("skill.toml"), &manifest).unwrap();
|
|
|
|
// Create entry point
|
|
let entry_content = match runtime.as_str() {
|
|
"python" => format!(
|
|
r#"#!/usr/bin/env python3
|
|
"""OpenFang skill: {name}"""
|
|
import json
|
|
import sys
|
|
|
|
def main():
|
|
payload = json.loads(sys.stdin.read())
|
|
tool_name = payload["tool"]
|
|
input_data = payload["input"]
|
|
|
|
# TODO: Implement your skill logic here
|
|
result = {{"result": f"Processed: {{input_data.get('input', '')}}"}}
|
|
|
|
print(json.dumps(result))
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
"#
|
|
),
|
|
_ => "// TODO: Implement your skill\n".to_string(),
|
|
};
|
|
|
|
let entry_path = if runtime == "python" {
|
|
"src/main.py"
|
|
} else {
|
|
"src/index.js"
|
|
};
|
|
std::fs::write(skill_dir.join(entry_path), entry_content).unwrap();
|
|
|
|
println!("\nSkill created: {}", skill_dir.display());
|
|
println!("\nFiles:");
|
|
println!(" skill.toml");
|
|
println!(" {entry_path}");
|
|
println!("\nNext steps:");
|
|
println!(" 1. Edit the entry point to implement your skill logic");
|
|
println!(" 2. Test locally: openfang skill test");
|
|
println!(
|
|
" 3. Install: openfang skill install {}",
|
|
skill_dir.display()
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Channel commands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn cmd_channel_list() {
|
|
let home = openfang_home();
|
|
let config_path = home.join("config.toml");
|
|
|
|
if !config_path.exists() {
|
|
println!("No configuration found. Run `openfang init` first.");
|
|
return;
|
|
}
|
|
|
|
let config_str = std::fs::read_to_string(&config_path).unwrap_or_default();
|
|
|
|
println!("Channel Integrations:\n");
|
|
println!("{:<12} {:<10} STATUS", "CHANNEL", "ENV VAR");
|
|
println!("{}", "-".repeat(50));
|
|
|
|
let channels: Vec<(&str, &str)> = vec![
|
|
("webchat", ""),
|
|
("telegram", "TELEGRAM_BOT_TOKEN"),
|
|
("discord", "DISCORD_BOT_TOKEN"),
|
|
("slack", "SLACK_BOT_TOKEN"),
|
|
("whatsapp", "WA_ACCESS_TOKEN"),
|
|
("signal", ""),
|
|
("matrix", "MATRIX_TOKEN"),
|
|
("email", "EMAIL_PASSWORD"),
|
|
];
|
|
|
|
for (name, env_var) in channels {
|
|
let configured = config_str.contains(&format!("[channels.{name}]"));
|
|
let env_set = if env_var.is_empty() {
|
|
true
|
|
} else {
|
|
std::env::var(env_var).is_ok()
|
|
};
|
|
|
|
let status = match (configured, env_set) {
|
|
(true, true) => "Ready",
|
|
(true, false) => "Missing env",
|
|
(false, _) => "Not configured",
|
|
};
|
|
|
|
println!(
|
|
"{:<12} {:<10} {}",
|
|
name,
|
|
if env_var.is_empty() { "—" } else { env_var },
|
|
status,
|
|
);
|
|
}
|
|
|
|
println!("\nUse `openfang channel setup <channel>` to configure a channel.");
|
|
}
|
|
|
|
fn cmd_channel_setup(channel: Option<&str>) {
|
|
let channel = match channel {
|
|
Some(c) => c.to_string(),
|
|
None => {
|
|
// Interactive channel picker
|
|
ui::section("Channel Setup");
|
|
ui::blank();
|
|
let channel_list = [
|
|
("telegram", "Telegram bot (BotFather)"),
|
|
("discord", "Discord bot"),
|
|
("slack", "Slack app (Socket Mode)"),
|
|
("whatsapp", "WhatsApp Cloud API"),
|
|
("email", "Email (IMAP/SMTP)"),
|
|
("signal", "Signal (signal-cli)"),
|
|
("matrix", "Matrix homeserver"),
|
|
];
|
|
|
|
for (i, (name, desc)) in channel_list.iter().enumerate() {
|
|
println!(" {:>2}. {:<12} {}", i + 1, name, desc.dimmed());
|
|
}
|
|
ui::blank();
|
|
|
|
let choice = prompt_input(" Choose channel [1]: ");
|
|
let idx = if choice.is_empty() {
|
|
0
|
|
} else {
|
|
choice
|
|
.parse::<usize>()
|
|
.unwrap_or(1)
|
|
.saturating_sub(1)
|
|
.min(channel_list.len() - 1)
|
|
};
|
|
channel_list[idx].0.to_string()
|
|
}
|
|
};
|
|
|
|
match channel.as_str() {
|
|
"telegram" => {
|
|
ui::section("Setting up Telegram");
|
|
ui::blank();
|
|
println!(" 1. Open Telegram and message @BotFather");
|
|
println!(" 2. Send /newbot and follow the prompts");
|
|
println!(" 3. Copy the bot token");
|
|
ui::blank();
|
|
|
|
let token = prompt_input(" Paste your bot token: ");
|
|
if token.is_empty() {
|
|
ui::error("No token provided. Setup cancelled.");
|
|
return;
|
|
}
|
|
|
|
let config_block = "\n[channels.telegram]\nbot_token_env = \"TELEGRAM_BOT_TOKEN\"\ndefault_agent = \"assistant\"\n";
|
|
maybe_write_channel_config("telegram", config_block);
|
|
|
|
// Save token to .env
|
|
match dotenv::save_env_key("TELEGRAM_BOT_TOKEN", &token) {
|
|
Ok(()) => ui::success("Token saved to ~/.openfang/.env"),
|
|
Err(_) => println!(" export TELEGRAM_BOT_TOKEN={token}"),
|
|
}
|
|
|
|
ui::blank();
|
|
ui::success("Telegram configured");
|
|
notify_daemon_restart();
|
|
}
|
|
"discord" => {
|
|
ui::section("Setting up Discord");
|
|
ui::blank();
|
|
println!(" 1. Go to https://discord.com/developers/applications");
|
|
println!(" 2. Create a New Application");
|
|
println!(" 3. Go to Bot section and click 'Add Bot'");
|
|
println!(" 4. Copy the bot token");
|
|
println!(" 5. Under Privileged Gateway Intents, enable:");
|
|
println!(" - Message Content Intent");
|
|
println!(" 6. Use OAuth2 URL Generator to invite bot to your server");
|
|
ui::blank();
|
|
|
|
let token = prompt_input(" Paste your bot token: ");
|
|
if token.is_empty() {
|
|
ui::error("No token provided. Setup cancelled.");
|
|
return;
|
|
}
|
|
|
|
let config_block = "\n[channels.discord]\nbot_token_env = \"DISCORD_BOT_TOKEN\"\ndefault_agent = \"coder\"\n";
|
|
maybe_write_channel_config("discord", config_block);
|
|
|
|
match dotenv::save_env_key("DISCORD_BOT_TOKEN", &token) {
|
|
Ok(()) => ui::success("Token saved to ~/.openfang/.env"),
|
|
Err(_) => println!(" export DISCORD_BOT_TOKEN={token}"),
|
|
}
|
|
|
|
ui::blank();
|
|
ui::success("Discord configured");
|
|
notify_daemon_restart();
|
|
}
|
|
"slack" => {
|
|
ui::section("Setting up Slack");
|
|
ui::blank();
|
|
println!(" 1. Go to https://api.slack.com/apps");
|
|
println!(" 2. Create New App -> From Scratch");
|
|
println!(" 3. Enable Socket Mode (Settings -> Socket Mode)");
|
|
println!(" 4. Copy the App-Level Token (xapp-...)");
|
|
println!(" 5. Go to OAuth & Permissions, add scopes:");
|
|
println!(" - chat:write, app_mentions:read, im:history");
|
|
println!(" 6. Install to workspace and copy Bot Token (xoxb-...)");
|
|
ui::blank();
|
|
|
|
let app_token = prompt_input(" Paste your App Token (xapp-...): ");
|
|
let bot_token = prompt_input(" Paste your Bot Token (xoxb-...): ");
|
|
|
|
let config_block = "\n[channels.slack]\napp_token_env = \"SLACK_APP_TOKEN\"\nbot_token_env = \"SLACK_BOT_TOKEN\"\ndefault_agent = \"assistant\"\n";
|
|
maybe_write_channel_config("slack", config_block);
|
|
|
|
if !app_token.is_empty() {
|
|
match dotenv::save_env_key("SLACK_APP_TOKEN", &app_token) {
|
|
Ok(()) => ui::success("App token saved to ~/.openfang/.env"),
|
|
Err(_) => println!(" export SLACK_APP_TOKEN={app_token}"),
|
|
}
|
|
}
|
|
if !bot_token.is_empty() {
|
|
match dotenv::save_env_key("SLACK_BOT_TOKEN", &bot_token) {
|
|
Ok(()) => ui::success("Bot token saved to ~/.openfang/.env"),
|
|
Err(_) => println!(" export SLACK_BOT_TOKEN={bot_token}"),
|
|
}
|
|
}
|
|
|
|
ui::blank();
|
|
ui::success("Slack configured");
|
|
notify_daemon_restart();
|
|
}
|
|
"whatsapp" => {
|
|
ui::section("Setting up WhatsApp");
|
|
ui::blank();
|
|
println!(" WhatsApp Cloud API (recommended for production):");
|
|
println!(" 1. Go to https://developers.facebook.com");
|
|
println!(" 2. Create a Business App");
|
|
println!(" 3. Add WhatsApp product");
|
|
println!(" 4. Set up a test phone number");
|
|
println!(" 5. Copy Phone Number ID and Access Token");
|
|
ui::blank();
|
|
|
|
let phone_id = prompt_input(" Phone Number ID: ");
|
|
let access_token = prompt_input(" Access Token: ");
|
|
let verify_token = prompt_input(" Verify Token: ");
|
|
|
|
let config_block = "\n[channels.whatsapp]\nmode = \"cloud_api\"\nphone_number_id_env = \"WA_PHONE_ID\"\naccess_token_env = \"WA_ACCESS_TOKEN\"\nverify_token_env = \"WA_VERIFY_TOKEN\"\nwebhook_port = 8443\ndefault_agent = \"assistant\"\n";
|
|
maybe_write_channel_config("whatsapp", config_block);
|
|
|
|
for (key, val) in [
|
|
("WA_PHONE_ID", &phone_id),
|
|
("WA_ACCESS_TOKEN", &access_token),
|
|
("WA_VERIFY_TOKEN", &verify_token),
|
|
] {
|
|
if !val.is_empty() {
|
|
match dotenv::save_env_key(key, val) {
|
|
Ok(()) => ui::success(&format!("{key} saved to ~/.openfang/.env")),
|
|
Err(_) => println!(" export {key}={val}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
ui::blank();
|
|
ui::success("WhatsApp configured");
|
|
notify_daemon_restart();
|
|
}
|
|
"email" => {
|
|
ui::section("Setting up Email");
|
|
ui::blank();
|
|
println!(" For Gmail, use an App Password:");
|
|
println!(" https://myaccount.google.com/apppasswords");
|
|
ui::blank();
|
|
|
|
let username = prompt_input(" Email address: ");
|
|
if username.is_empty() {
|
|
ui::error("No email provided. Setup cancelled.");
|
|
return;
|
|
}
|
|
|
|
let password = prompt_input(" App password (or Enter to set later): ");
|
|
|
|
let config_block = format!(
|
|
"\n[channels.email]\nimap_host = \"imap.gmail.com\"\nimap_port = 993\nsmtp_host = \"smtp.gmail.com\"\nsmtp_port = 587\nusername = \"{username}\"\npassword_env = \"EMAIL_PASSWORD\"\npoll_interval = 30\ndefault_agent = \"assistant\"\n"
|
|
);
|
|
maybe_write_channel_config("email", &config_block);
|
|
|
|
if !password.is_empty() {
|
|
match dotenv::save_env_key("EMAIL_PASSWORD", &password) {
|
|
Ok(()) => ui::success("Password saved to ~/.openfang/.env"),
|
|
Err(_) => println!(" export EMAIL_PASSWORD=your_app_password"),
|
|
}
|
|
} else {
|
|
ui::hint("Set later: openfang config set-key email (or export EMAIL_PASSWORD=...)");
|
|
}
|
|
|
|
ui::blank();
|
|
ui::success("Email configured");
|
|
notify_daemon_restart();
|
|
}
|
|
"signal" => {
|
|
ui::section("Setting up Signal");
|
|
ui::blank();
|
|
println!(" Signal requires signal-cli (https://github.com/AsamK/signal-cli).");
|
|
ui::blank();
|
|
println!(" 1. Install signal-cli:");
|
|
println!(" - macOS: brew install signal-cli");
|
|
println!(" - Linux: download from GitHub releases");
|
|
println!(" - Or use the Docker image");
|
|
println!(" 2. Register or link a phone number:");
|
|
println!(" signal-cli -u +1YOURPHONE register");
|
|
println!(" signal-cli -u +1YOURPHONE verify CODE");
|
|
println!(" 3. Start signal-cli in JSON-RPC mode:");
|
|
println!(" signal-cli -u +1YOURPHONE jsonRpc --socket /tmp/signal-cli.sock");
|
|
ui::blank();
|
|
|
|
let phone = prompt_input(" Your phone number (+1XXXX, or Enter to skip): ");
|
|
|
|
let config_block = "\n[channels.signal]\nphone_env = \"SIGNAL_PHONE\"\nsocket_path = \"/tmp/signal-cli.sock\"\ndefault_agent = \"assistant\"\n";
|
|
maybe_write_channel_config("signal", config_block);
|
|
|
|
if !phone.is_empty() {
|
|
match dotenv::save_env_key("SIGNAL_PHONE", &phone) {
|
|
Ok(()) => ui::success("Phone saved to ~/.openfang/.env"),
|
|
Err(_) => println!(" export SIGNAL_PHONE={phone}"),
|
|
}
|
|
}
|
|
|
|
ui::blank();
|
|
ui::success("Signal configured");
|
|
notify_daemon_restart();
|
|
}
|
|
"matrix" => {
|
|
ui::section("Setting up Matrix");
|
|
ui::blank();
|
|
println!(" 1. Create a bot account on your Matrix homeserver");
|
|
println!(" (e.g., register @openfang-bot:matrix.org)");
|
|
println!(" 2. Obtain an access token:");
|
|
println!(" curl -X POST https://matrix.org/_matrix/client/r0/login \\");
|
|
println!(" -d '{{\"type\":\"m.login.password\",\"user\":\"openfang-bot\",\"password\":\"...\"}}'");
|
|
println!(" Copy the access_token from the response.");
|
|
println!(" 3. Invite the bot to rooms you want it to monitor.");
|
|
ui::blank();
|
|
|
|
let homeserver = prompt_input(" Homeserver URL [https://matrix.org]: ");
|
|
let homeserver = if homeserver.is_empty() {
|
|
"https://matrix.org".to_string()
|
|
} else {
|
|
homeserver
|
|
};
|
|
let token = prompt_input(" Access token: ");
|
|
|
|
let config_block = "\n[channels.matrix]\nhomeserver_env = \"MATRIX_HOMESERVER\"\naccess_token_env = \"MATRIX_ACCESS_TOKEN\"\ndefault_agent = \"assistant\"\n";
|
|
maybe_write_channel_config("matrix", config_block);
|
|
|
|
let _ = dotenv::save_env_key("MATRIX_HOMESERVER", &homeserver);
|
|
if !token.is_empty() {
|
|
match dotenv::save_env_key("MATRIX_ACCESS_TOKEN", &token) {
|
|
Ok(()) => ui::success("Token saved to ~/.openfang/.env"),
|
|
Err(_) => println!(" export MATRIX_ACCESS_TOKEN={token}"),
|
|
}
|
|
}
|
|
|
|
ui::blank();
|
|
ui::success("Matrix configured");
|
|
notify_daemon_restart();
|
|
}
|
|
other => {
|
|
ui::error_with_fix(
|
|
&format!("Unknown channel: {other}"),
|
|
"Available: telegram, discord, slack, whatsapp, email, signal, matrix",
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Offer to append a channel config block to config.toml if it doesn't already exist.
|
|
fn maybe_write_channel_config(channel: &str, config_block: &str) {
|
|
let home = openfang_home();
|
|
let config_path = home.join("config.toml");
|
|
|
|
if !config_path.exists() {
|
|
ui::hint("No config.toml found. Run `openfang init` first.");
|
|
return;
|
|
}
|
|
|
|
let existing = std::fs::read_to_string(&config_path).unwrap_or_default();
|
|
let section_header = format!("[channels.{channel}]");
|
|
if existing.contains(§ion_header) {
|
|
ui::check_ok(&format!("{section_header} already in config.toml"));
|
|
return;
|
|
}
|
|
|
|
let answer = prompt_input(" Write to config.toml? [Y/n] ");
|
|
if answer.is_empty() || answer.starts_with('y') || answer.starts_with('Y') {
|
|
let mut content = existing;
|
|
content.push_str(config_block);
|
|
if std::fs::write(&config_path, &content).is_ok() {
|
|
restrict_file_permissions(&config_path);
|
|
ui::check_ok(&format!("Added {section_header} to config.toml"));
|
|
} else {
|
|
ui::check_fail("Failed to write config.toml");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// After channel config changes, warn user if daemon is running.
|
|
fn notify_daemon_restart() {
|
|
if find_daemon().is_some() {
|
|
ui::check_warn("Restart the daemon to activate this channel");
|
|
} else {
|
|
ui::hint("Start the daemon: openfang start");
|
|
}
|
|
}
|
|
|
|
fn cmd_channel_test(channel: &str) {
|
|
if let Some(base) = find_daemon() {
|
|
let client = daemon_client();
|
|
let body = daemon_json(
|
|
client
|
|
.post(format!("{base}/api/channels/{channel}/test"))
|
|
.send(),
|
|
);
|
|
if body.get("status").is_some() {
|
|
println!("Test message sent to {channel}!");
|
|
} else {
|
|
eprintln!(
|
|
"Failed: {}",
|
|
body["error"].as_str().unwrap_or("Unknown error")
|
|
);
|
|
}
|
|
} else {
|
|
eprintln!("Channel test requires a running daemon. Start with: openfang start");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
fn cmd_channel_toggle(channel: &str, enable: bool) {
|
|
let action = if enable { "enabled" } else { "disabled" };
|
|
if let Some(base) = find_daemon() {
|
|
let client = daemon_client();
|
|
let endpoint = if enable { "enable" } else { "disable" };
|
|
let body = daemon_json(
|
|
client
|
|
.post(format!("{base}/api/channels/{channel}/{endpoint}"))
|
|
.send(),
|
|
);
|
|
if body.get("status").is_some() {
|
|
println!("Channel {channel} {action}.");
|
|
} else {
|
|
eprintln!(
|
|
"Failed: {}",
|
|
body["error"].as_str().unwrap_or("Unknown error")
|
|
);
|
|
}
|
|
} else {
|
|
println!("Note: Channel {channel} will be {action} when the daemon starts.");
|
|
println!("Edit ~/.openfang/config.toml to persist this change.");
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Hand commands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn cmd_hand_install(path: &str) {
|
|
let base = require_daemon("hand install");
|
|
let dir = std::path::Path::new(path);
|
|
let toml_path = dir.join("HAND.toml");
|
|
let skill_path = dir.join("SKILL.md");
|
|
|
|
if !toml_path.exists() {
|
|
eprintln!(
|
|
"Error: No HAND.toml found in {}",
|
|
dir.canonicalize()
|
|
.unwrap_or_else(|_| dir.to_path_buf())
|
|
.display()
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
|
|
let toml_content = std::fs::read_to_string(&toml_path).unwrap_or_else(|e| {
|
|
eprintln!("Error reading {}: {e}", toml_path.display());
|
|
std::process::exit(1);
|
|
});
|
|
let skill_content = std::fs::read_to_string(&skill_path).unwrap_or_default();
|
|
|
|
let client = daemon_client();
|
|
let body = daemon_json(
|
|
client
|
|
.post(format!("{base}/api/hands/install"))
|
|
.json(&serde_json::json!({
|
|
"toml_content": toml_content,
|
|
"skill_content": skill_content,
|
|
}))
|
|
.send(),
|
|
);
|
|
|
|
if let Some(err) = body.get("error").and_then(|v| v.as_str()) {
|
|
eprintln!("Error: {err}");
|
|
std::process::exit(1);
|
|
}
|
|
|
|
println!(
|
|
"Installed hand: {} ({})",
|
|
body["name"].as_str().unwrap_or("?"),
|
|
body["id"].as_str().unwrap_or("?"),
|
|
);
|
|
println!(
|
|
"Use `openfang hand activate {}` to start it.",
|
|
body["id"].as_str().unwrap_or("?")
|
|
);
|
|
}
|
|
|
|
fn cmd_hand_list() {
|
|
let base = require_daemon("hand list");
|
|
let client = daemon_client();
|
|
let body = daemon_json(client.get(format!("{base}/api/hands")).send());
|
|
// API returns {"hands": [...]} or a bare array
|
|
let arr_val;
|
|
if let Some(arr) = body.get("hands").and_then(|v| v.as_array()) {
|
|
arr_val = arr.clone();
|
|
} else if let Some(arr) = body.as_array() {
|
|
arr_val = arr.clone();
|
|
} else {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
return;
|
|
}
|
|
if let Some(arr) = Some(&arr_val) {
|
|
if arr.is_empty() {
|
|
println!("No hands available.");
|
|
return;
|
|
}
|
|
println!("{:<14} {:<20} {:<10} DESCRIPTION", "ID", "NAME", "CATEGORY");
|
|
println!("{}", "-".repeat(72));
|
|
for h in arr {
|
|
println!(
|
|
"{:<14} {:<20} {:<10} {}",
|
|
h["id"].as_str().unwrap_or("?"),
|
|
h["name"].as_str().unwrap_or("?"),
|
|
h["category"].as_str().unwrap_or("?"),
|
|
h["description"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.chars()
|
|
.take(40)
|
|
.collect::<String>(),
|
|
);
|
|
}
|
|
println!("\nUse `openfang hand activate <id>` to activate a hand.");
|
|
}
|
|
}
|
|
|
|
fn cmd_hand_active() {
|
|
let base = require_daemon("hand active");
|
|
let client = daemon_client();
|
|
let body = daemon_json(client.get(format!("{base}/api/hands/active")).send());
|
|
// API returns {"instances": [...]} or bare array
|
|
let arr = body
|
|
.get("instances")
|
|
.and_then(|v| v.as_array())
|
|
.or_else(|| body.as_array())
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
if arr.is_empty() {
|
|
println!("No active hands.");
|
|
return;
|
|
}
|
|
println!("{:<38} {:<14} {:<10} AGENT", "INSTANCE", "HAND", "STATUS");
|
|
println!("{}", "-".repeat(72));
|
|
for i in &arr {
|
|
println!(
|
|
"{:<38} {:<14} {:<10} {}",
|
|
i["instance_id"].as_str().unwrap_or("?"),
|
|
i["hand_id"].as_str().unwrap_or("?"),
|
|
i["status"].as_str().unwrap_or("?"),
|
|
i["agent_name"].as_str().unwrap_or("?"),
|
|
);
|
|
}
|
|
}
|
|
|
|
fn cmd_hand_activate(id: &str) {
|
|
let base = require_daemon("hand activate");
|
|
let client = daemon_client();
|
|
let body = daemon_json(
|
|
client
|
|
.post(format!("{base}/api/hands/{id}/activate"))
|
|
.header("content-type", "application/json")
|
|
.body("{}")
|
|
.send(),
|
|
);
|
|
if body.get("instance_id").is_some() {
|
|
println!(
|
|
"Hand '{}' activated (instance: {}, agent: {})",
|
|
id,
|
|
body["instance_id"].as_str().unwrap_or("?"),
|
|
body["agent_name"].as_str().unwrap_or("?"),
|
|
);
|
|
} else {
|
|
eprintln!(
|
|
"Failed to activate hand '{}': {}",
|
|
id,
|
|
body["error"].as_str().unwrap_or("Unknown error")
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
fn cmd_hand_deactivate(id: &str) {
|
|
let base = require_daemon("hand deactivate");
|
|
let client = daemon_client();
|
|
// First find the instance ID for this hand
|
|
let active = daemon_json(client.get(format!("{base}/api/hands/active")).send());
|
|
let arr = active
|
|
.get("instances")
|
|
.and_then(|v| v.as_array())
|
|
.or_else(|| active.as_array())
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
let instance_id = arr.iter().find_map(|i| {
|
|
if i["hand_id"].as_str() == Some(id) {
|
|
i["instance_id"].as_str().map(|s| s.to_string())
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
|
|
match instance_id {
|
|
Some(iid) => {
|
|
let body = daemon_json(
|
|
client
|
|
.delete(format!("{base}/api/hands/instances/{iid}"))
|
|
.send(),
|
|
);
|
|
if body.get("status").is_some() {
|
|
println!("Hand '{id}' deactivated.");
|
|
} else {
|
|
eprintln!(
|
|
"Failed: {}",
|
|
body["error"].as_str().unwrap_or("Unknown error")
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
None => {
|
|
eprintln!("No active instance found for hand '{id}'.");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_hand_info(id: &str) {
|
|
let base = require_daemon("hand info");
|
|
let client = daemon_client();
|
|
let body = daemon_json(client.get(format!("{base}/api/hands/{id}")).send());
|
|
if body.get("error").is_some() {
|
|
eprintln!("Hand not found: {}", body["error"].as_str().unwrap_or(id));
|
|
std::process::exit(1);
|
|
}
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
}
|
|
|
|
fn cmd_hand_check_deps(id: &str) {
|
|
let base = require_daemon("hand check-deps");
|
|
let client = daemon_client();
|
|
let body = daemon_json(
|
|
client
|
|
.post(format!("{base}/api/hands/{id}/check-deps"))
|
|
.send(),
|
|
);
|
|
if body.get("error").is_some() {
|
|
ui::error(&format!(
|
|
"Failed: {}",
|
|
body["error"].as_str().unwrap_or("?")
|
|
));
|
|
} else {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
}
|
|
}
|
|
|
|
fn cmd_hand_install_deps(id: &str) {
|
|
let base = require_daemon("hand install-deps");
|
|
let client = daemon_client();
|
|
let body = daemon_json(
|
|
client
|
|
.post(format!("{base}/api/hands/{id}/install-deps"))
|
|
.send(),
|
|
);
|
|
if body.get("error").is_some() {
|
|
ui::error(&format!(
|
|
"Failed: {}",
|
|
body["error"].as_str().unwrap_or("?")
|
|
));
|
|
} else {
|
|
ui::success(&format!("Dependencies installed for hand '{id}'."));
|
|
if let Some(results) = body.get("results") {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(results).unwrap_or_default()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_hand_pause(id: &str) {
|
|
let base = require_daemon("hand pause");
|
|
let client = daemon_client();
|
|
let body = daemon_json(
|
|
client
|
|
.post(format!("{base}/api/hands/instances/{id}/pause"))
|
|
.send(),
|
|
);
|
|
if body.get("error").is_some() {
|
|
ui::error(&format!(
|
|
"Failed: {}",
|
|
body["error"].as_str().unwrap_or("?")
|
|
));
|
|
} else {
|
|
ui::success(&format!("Hand instance '{id}' paused."));
|
|
}
|
|
}
|
|
|
|
fn cmd_hand_resume(id: &str) {
|
|
let base = require_daemon("hand resume");
|
|
let client = daemon_client();
|
|
let body = daemon_json(
|
|
client
|
|
.post(format!("{base}/api/hands/instances/{id}/resume"))
|
|
.send(),
|
|
);
|
|
if body.get("error").is_some() {
|
|
ui::error(&format!(
|
|
"Failed: {}",
|
|
body["error"].as_str().unwrap_or("?")
|
|
));
|
|
} else {
|
|
ui::success(&format!("Hand instance '{id}' resumed."));
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Provider / API key helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Map a provider name to its conventional environment variable name.
|
|
fn provider_to_env_var(provider: &str) -> String {
|
|
match provider.to_lowercase().as_str() {
|
|
"groq" => "GROQ_API_KEY".to_string(),
|
|
"anthropic" => "ANTHROPIC_API_KEY".to_string(),
|
|
"openai" => "OPENAI_API_KEY".to_string(),
|
|
"gemini" => "GEMINI_API_KEY".to_string(),
|
|
"google" => "GOOGLE_API_KEY".to_string(),
|
|
"deepseek" => "DEEPSEEK_API_KEY".to_string(),
|
|
"openrouter" => "OPENROUTER_API_KEY".to_string(),
|
|
"together" => "TOGETHER_API_KEY".to_string(),
|
|
"mistral" => "MISTRAL_API_KEY".to_string(),
|
|
"fireworks" => "FIREWORKS_API_KEY".to_string(),
|
|
"perplexity" => "PERPLEXITY_API_KEY".to_string(),
|
|
"cohere" => "COHERE_API_KEY".to_string(),
|
|
"xai" => "XAI_API_KEY".to_string(),
|
|
"brave" => "BRAVE_API_KEY".to_string(),
|
|
"tavily" => "TAVILY_API_KEY".to_string(),
|
|
other => format!("{}_API_KEY", other.to_uppercase()),
|
|
}
|
|
}
|
|
|
|
/// Test an API key by hitting the provider's models/health endpoint.
|
|
///
|
|
/// Returns true if the key is accepted (status != 401/403).
|
|
/// Returns true on timeout/network errors (best-effort — don't block setup).
|
|
pub(crate) fn test_api_key(provider: &str, env_var: &str) -> bool {
|
|
let key = match std::env::var(env_var) {
|
|
Ok(k) => k,
|
|
Err(_) => return false,
|
|
};
|
|
|
|
let client = match reqwest::blocking::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(10))
|
|
.build()
|
|
{
|
|
Ok(c) => c,
|
|
Err(_) => return true, // can't build client — assume ok
|
|
};
|
|
|
|
let result = match provider.to_lowercase().as_str() {
|
|
"groq" => client
|
|
.get("https://api.groq.com/openai/v1/models")
|
|
.bearer_auth(&key)
|
|
.send(),
|
|
"anthropic" => client
|
|
.get("https://api.anthropic.com/v1/models")
|
|
.header("x-api-key", &key)
|
|
.header("anthropic-version", "2023-06-01")
|
|
.send(),
|
|
"openai" => client
|
|
.get("https://api.openai.com/v1/models")
|
|
.bearer_auth(&key)
|
|
.send(),
|
|
"gemini" | "google" => client
|
|
.get(format!(
|
|
"https://generativelanguage.googleapis.com/v1beta/models?key={key}"
|
|
))
|
|
.send(),
|
|
"deepseek" => client
|
|
.get("https://api.deepseek.com/models")
|
|
.bearer_auth(&key)
|
|
.send(),
|
|
"openrouter" => client
|
|
.get("https://openrouter.ai/api/v1/models")
|
|
.bearer_auth(&key)
|
|
.send(),
|
|
_ => return true, // unknown provider — skip test
|
|
};
|
|
|
|
match result {
|
|
Ok(resp) => {
|
|
let status = resp.status().as_u16();
|
|
status != 401 && status != 403
|
|
}
|
|
Err(_) => true, // network error — don't block setup
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Background daemon start
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Spawn `openfang start` as a detached background process.
|
|
///
|
|
/// Polls for daemon health for up to 10 seconds. Returns the daemon URL on success.
|
|
pub(crate) fn start_daemon_background() -> Result<String, String> {
|
|
let exe = std::env::current_exe().map_err(|e| format!("Cannot find executable: {e}"))?;
|
|
|
|
#[cfg(windows)]
|
|
{
|
|
use std::os::windows::process::CommandExt;
|
|
const DETACHED_PROCESS: u32 = 0x00000008;
|
|
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
|
std::process::Command::new(&exe)
|
|
.arg("start")
|
|
.stdin(std::process::Stdio::null())
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)
|
|
.spawn()
|
|
.map_err(|e| format!("Failed to spawn daemon: {e}"))?;
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
{
|
|
std::process::Command::new(&exe)
|
|
.arg("start")
|
|
.stdin(std::process::Stdio::null())
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.spawn()
|
|
.map_err(|e| format!("Failed to spawn daemon: {e}"))?;
|
|
}
|
|
|
|
// Poll for daemon readiness
|
|
for _ in 0..20 {
|
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
|
if let Some(url) = find_daemon() {
|
|
return Ok(url);
|
|
}
|
|
}
|
|
|
|
Err("Daemon did not become ready within 10 seconds".to_string())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Config commands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn cmd_config_show() {
|
|
let home = openfang_home();
|
|
let config_path = home.join("config.toml");
|
|
|
|
if !config_path.exists() {
|
|
println!("No configuration found at: {}", config_path.display());
|
|
println!("Run `openfang init` to create one.");
|
|
return;
|
|
}
|
|
|
|
let content = std::fs::read_to_string(&config_path).unwrap_or_else(|e| {
|
|
eprintln!("Error reading config: {e}");
|
|
std::process::exit(1);
|
|
});
|
|
|
|
println!("# {}\n", config_path.display());
|
|
println!("{content}");
|
|
}
|
|
|
|
fn cmd_config_edit() {
|
|
let home = openfang_home();
|
|
let config_path = home.join("config.toml");
|
|
|
|
let editor = std::env::var("EDITOR")
|
|
.or_else(|_| std::env::var("VISUAL"))
|
|
.unwrap_or_else(|_| {
|
|
if cfg!(windows) {
|
|
"notepad".to_string()
|
|
} else {
|
|
"vi".to_string()
|
|
}
|
|
});
|
|
|
|
let status = std::process::Command::new(&editor)
|
|
.arg(&config_path)
|
|
.status();
|
|
|
|
match status {
|
|
Ok(s) if s.success() => {}
|
|
Ok(s) => {
|
|
eprintln!("Editor exited with: {s}");
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Failed to open editor '{editor}': {e}");
|
|
eprintln!("Set $EDITOR to your preferred editor.");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_config_get(key: &str) {
|
|
let home = openfang_home();
|
|
let config_path = home.join("config.toml");
|
|
|
|
if !config_path.exists() {
|
|
ui::error_with_fix("No config file found", "Run `openfang init` first");
|
|
std::process::exit(1);
|
|
}
|
|
|
|
let content = std::fs::read_to_string(&config_path).unwrap_or_else(|e| {
|
|
ui::error(&format!("Failed to read config: {e}"));
|
|
std::process::exit(1);
|
|
});
|
|
|
|
let table: toml::Value = toml::from_str(&content).unwrap_or_else(|e| {
|
|
ui::error_with_fix(
|
|
&format!("Config parse error: {e}"),
|
|
"Fix your config.toml syntax, or run `openfang config edit`",
|
|
);
|
|
std::process::exit(1);
|
|
});
|
|
|
|
// Navigate dotted path
|
|
let mut current = &table;
|
|
for part in key.split('.') {
|
|
match current.get(part) {
|
|
Some(v) => current = v,
|
|
None => {
|
|
ui::error(&format!("Key not found: {key}"));
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Print value
|
|
match current {
|
|
toml::Value::String(s) => println!("{s}"),
|
|
toml::Value::Integer(i) => println!("{i}"),
|
|
toml::Value::Float(f) => println!("{f}"),
|
|
toml::Value::Boolean(b) => println!("{b}"),
|
|
other => println!("{other}"),
|
|
}
|
|
}
|
|
|
|
fn cmd_config_set(key: &str, value: &str) {
|
|
let home = openfang_home();
|
|
let config_path = home.join("config.toml");
|
|
|
|
if !config_path.exists() {
|
|
ui::error_with_fix("No config file found", "Run `openfang init` first");
|
|
std::process::exit(1);
|
|
}
|
|
|
|
let content = std::fs::read_to_string(&config_path).unwrap_or_else(|e| {
|
|
ui::error(&format!("Failed to read config: {e}"));
|
|
std::process::exit(1);
|
|
});
|
|
|
|
let mut table: toml::Value = toml::from_str(&content).unwrap_or_else(|e| {
|
|
ui::error_with_fix(
|
|
&format!("Config parse error: {e}"),
|
|
"Fix your config.toml syntax first",
|
|
);
|
|
std::process::exit(1);
|
|
});
|
|
|
|
// Navigate to parent and set key
|
|
let parts: Vec<&str> = key.split('.').collect();
|
|
if parts.is_empty() {
|
|
ui::error("Empty key");
|
|
std::process::exit(1);
|
|
}
|
|
|
|
let mut current = &mut table;
|
|
for part in &parts[..parts.len() - 1] {
|
|
current = current
|
|
.as_table_mut()
|
|
.and_then(|t| t.get_mut(*part))
|
|
.unwrap_or_else(|| {
|
|
ui::error(&format!("Key path not found: {key}"));
|
|
std::process::exit(1);
|
|
});
|
|
}
|
|
|
|
let last_key = parts[parts.len() - 1];
|
|
|
|
// Validate: single-part keys must be known scalar fields, not sections.
|
|
// Writing a section name as a scalar silently breaks config deserialization.
|
|
if parts.len() == 1 {
|
|
let known_scalars = [
|
|
"home_dir",
|
|
"data_dir",
|
|
"log_level",
|
|
"api_listen",
|
|
"network_enabled",
|
|
"api_key",
|
|
"language",
|
|
"max_cron_jobs",
|
|
"usage_footer",
|
|
"workspaces_dir",
|
|
];
|
|
if !known_scalars.contains(&last_key) {
|
|
ui::error_with_fix(
|
|
&format!("'{last_key}' is a section, not a scalar"),
|
|
&format!("Use dotted notation: {last_key}.field_name"),
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
let tbl = current.as_table_mut().unwrap_or_else(|| {
|
|
ui::error(&format!("Parent of '{key}' is not a table"));
|
|
std::process::exit(1);
|
|
});
|
|
|
|
// Try to preserve type: if the existing value is an integer, parse as int, etc.
|
|
let new_value = if let Some(existing) = tbl.get(last_key) {
|
|
match existing {
|
|
toml::Value::Integer(_) => value
|
|
.parse::<u64>()
|
|
.map(|v| toml::Value::Integer(v as i64))
|
|
.or_else(|_| value.parse::<i64>().map(toml::Value::Integer))
|
|
.unwrap_or_else(|_| toml::Value::String(value.to_string())),
|
|
toml::Value::Float(_) => value
|
|
.parse::<f64>()
|
|
.map(toml::Value::Float)
|
|
.unwrap_or_else(|_| toml::Value::String(value.to_string())),
|
|
toml::Value::Boolean(_) => value
|
|
.parse::<bool>()
|
|
.map(toml::Value::Boolean)
|
|
.unwrap_or_else(|_| toml::Value::String(value.to_string())),
|
|
_ => toml::Value::String(value.to_string()),
|
|
}
|
|
} else {
|
|
// No existing value — infer type from the string content
|
|
if let Ok(b) = value.parse::<bool>() {
|
|
toml::Value::Boolean(b)
|
|
} else if let Ok(i) = value.parse::<u64>() {
|
|
toml::Value::Integer(i as i64)
|
|
} else if let Ok(i) = value.parse::<i64>() {
|
|
toml::Value::Integer(i)
|
|
} else if let Ok(f) = value.parse::<f64>() {
|
|
toml::Value::Float(f)
|
|
} else {
|
|
toml::Value::String(value.to_string())
|
|
}
|
|
};
|
|
|
|
tbl.insert(last_key.to_string(), new_value);
|
|
|
|
// Write back (note: this strips comments — warned in help text)
|
|
let serialized = toml::to_string_pretty(&table).unwrap_or_else(|e| {
|
|
ui::error(&format!("Failed to serialize config: {e}"));
|
|
std::process::exit(1);
|
|
});
|
|
|
|
let _ = std::fs::copy(&config_path, config_path.with_extension("toml.bak"));
|
|
|
|
std::fs::write(&config_path, &serialized).unwrap_or_else(|e| {
|
|
ui::error(&format!("Failed to write config: {e}"));
|
|
std::process::exit(1);
|
|
});
|
|
restrict_file_permissions(&config_path);
|
|
|
|
ui::success(&format!("Set {key} = {value}"));
|
|
}
|
|
|
|
fn cmd_config_unset(key: &str) {
|
|
let home = openfang_home();
|
|
let config_path = home.join("config.toml");
|
|
|
|
if !config_path.exists() {
|
|
ui::error_with_fix("No config file found", "Run `openfang init` first");
|
|
std::process::exit(1);
|
|
}
|
|
|
|
let content = std::fs::read_to_string(&config_path).unwrap_or_else(|e| {
|
|
ui::error(&format!("Failed to read config: {e}"));
|
|
std::process::exit(1);
|
|
});
|
|
|
|
let mut table: toml::Value = toml::from_str(&content).unwrap_or_else(|e| {
|
|
ui::error_with_fix(
|
|
&format!("Config parse error: {e}"),
|
|
"Fix your config.toml syntax first",
|
|
);
|
|
std::process::exit(1);
|
|
});
|
|
|
|
// Navigate to parent table and remove the final key
|
|
let parts: Vec<&str> = key.split('.').collect();
|
|
if parts.is_empty() {
|
|
ui::error("Empty key");
|
|
std::process::exit(1);
|
|
}
|
|
|
|
let mut current = &mut table;
|
|
for part in &parts[..parts.len() - 1] {
|
|
current = current
|
|
.as_table_mut()
|
|
.and_then(|t| t.get_mut(*part))
|
|
.unwrap_or_else(|| {
|
|
ui::error(&format!("Key path not found: {key}"));
|
|
std::process::exit(1);
|
|
});
|
|
}
|
|
|
|
let last_key = parts[parts.len() - 1];
|
|
let tbl = current.as_table_mut().unwrap_or_else(|| {
|
|
ui::error(&format!("Parent of '{key}' is not a table"));
|
|
std::process::exit(1);
|
|
});
|
|
|
|
if tbl.remove(last_key).is_none() {
|
|
ui::error(&format!("Key not found: {key}"));
|
|
std::process::exit(1);
|
|
}
|
|
|
|
// Write back (note: this strips comments — warned in help text)
|
|
let serialized = toml::to_string_pretty(&table).unwrap_or_else(|e| {
|
|
ui::error(&format!("Failed to serialize config: {e}"));
|
|
std::process::exit(1);
|
|
});
|
|
|
|
let _ = std::fs::copy(&config_path, config_path.with_extension("toml.bak"));
|
|
|
|
std::fs::write(&config_path, &serialized).unwrap_or_else(|e| {
|
|
ui::error(&format!("Failed to write config: {e}"));
|
|
std::process::exit(1);
|
|
});
|
|
restrict_file_permissions(&config_path);
|
|
|
|
ui::success(&format!("Removed key: {key}"));
|
|
}
|
|
|
|
fn cmd_config_set_key(provider: &str) {
|
|
let env_var = provider_to_env_var(provider);
|
|
|
|
let key = prompt_input(&format!(" Paste your {provider} API key: "));
|
|
if key.is_empty() {
|
|
ui::error("No key provided. Cancelled.");
|
|
return;
|
|
}
|
|
|
|
// Try vault first (best-effort)
|
|
save_credential_prefer_vault(&env_var, &key);
|
|
|
|
// Always save to dotenv as fallback
|
|
match dotenv::save_env_key(&env_var, &key) {
|
|
Ok(()) => {
|
|
ui::success(&format!("Saved {env_var} to ~/.openfang/.env"));
|
|
// Test the key
|
|
print!(" Testing key... ");
|
|
io::stdout().flush().unwrap();
|
|
if test_api_key(provider, &env_var) {
|
|
println!("{}", "OK".bright_green());
|
|
} else {
|
|
println!("{}", "could not verify (may still work)".bright_yellow());
|
|
}
|
|
}
|
|
Err(e) => {
|
|
ui::error(&format!("Failed to save key: {e}"));
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_config_delete_key(provider: &str) {
|
|
let env_var = provider_to_env_var(provider);
|
|
|
|
// Remove from vault (best-effort)
|
|
{
|
|
let home = openfang_home();
|
|
let vault_path = home.join("vault.enc");
|
|
if vault_path.exists() {
|
|
let mut vault = openfang_extensions::vault::CredentialVault::new(vault_path);
|
|
if vault.unlock().is_ok() {
|
|
let _ = vault.remove(&env_var);
|
|
}
|
|
}
|
|
}
|
|
|
|
match dotenv::remove_env_key(&env_var) {
|
|
Ok(()) => ui::success(&format!("Removed {env_var} from ~/.openfang/.env")),
|
|
Err(e) => {
|
|
ui::error(&format!("Failed to remove key: {e}"));
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_config_test_key(provider: &str) {
|
|
let env_var = provider_to_env_var(provider);
|
|
|
|
if std::env::var(&env_var).is_err() {
|
|
ui::error(&format!("{env_var} not set"));
|
|
ui::hint(&format!("Set it: openfang config set-key {provider}"));
|
|
std::process::exit(1);
|
|
}
|
|
|
|
print!(" Testing {provider} ({env_var})... ");
|
|
io::stdout().flush().unwrap();
|
|
if test_api_key(provider, &env_var) {
|
|
println!("{}", "OK".bright_green());
|
|
} else {
|
|
println!("{}", "FAILED (401/403)".bright_red());
|
|
ui::hint(&format!("Update key: openfang config set-key {provider}"));
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
/// Try to store a credential in the vault first; silently falls through if vault
|
|
/// is not initialized or cannot be unlocked. The caller should always also
|
|
/// write to dotenv as a fallback.
|
|
fn save_credential_prefer_vault(env_var: &str, value: &str) {
|
|
use zeroize::Zeroizing;
|
|
|
|
let home = openfang_home();
|
|
let vault_path = home.join("vault.enc");
|
|
if !vault_path.exists() {
|
|
return;
|
|
}
|
|
let mut vault = openfang_extensions::vault::CredentialVault::new(vault_path);
|
|
if vault.unlock().is_err() {
|
|
return;
|
|
}
|
|
if let Ok(()) = vault.set(env_var.to_string(), Zeroizing::new(value.to_string())) {
|
|
println!(" {}", "Also stored in encrypted vault".dimmed());
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Quick chat (OpenClaw alias)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn cmd_quick_chat(config: Option<PathBuf>, agent: Option<String>) {
|
|
tui::chat_runner::run_chat_tui(config, agent);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub(crate) fn openfang_home() -> PathBuf {
|
|
if let Ok(home) = std::env::var("OPENFANG_HOME") {
|
|
return PathBuf::from(home);
|
|
}
|
|
dirs::home_dir()
|
|
.unwrap_or_else(|| {
|
|
eprintln!("Error: Could not determine home directory");
|
|
std::process::exit(1);
|
|
})
|
|
.join(".openfang")
|
|
}
|
|
|
|
fn prompt_input(prompt: &str) -> String {
|
|
print!("{prompt}");
|
|
io::stdout().flush().unwrap();
|
|
let mut line = String::new();
|
|
io::stdin().lock().read_line(&mut line).unwrap_or(0);
|
|
line.trim().to_string()
|
|
}
|
|
|
|
pub(crate) fn copy_dir_recursive(src: &PathBuf, dst: &PathBuf) {
|
|
std::fs::create_dir_all(dst).unwrap();
|
|
if let Ok(entries) = std::fs::read_dir(src) {
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
let dest_path = dst.join(entry.file_name());
|
|
if path.is_dir() {
|
|
copy_dir_recursive(&path, &dest_path);
|
|
} else {
|
|
let _ = std::fs::copy(&path, &dest_path);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Integration commands (openfang add/remove/integrations)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn cmd_integration_add(name: &str, key: Option<&str>) {
|
|
let home = openfang_home();
|
|
let mut registry = openfang_extensions::registry::IntegrationRegistry::new(&home);
|
|
registry.load_bundled();
|
|
let _ = registry.load_installed();
|
|
|
|
// Check template exists
|
|
let template = match registry.get_template(name) {
|
|
Some(t) => t.clone(),
|
|
None => {
|
|
ui::error(&format!("Unknown integration: '{name}'"));
|
|
println!("\nAvailable integrations:");
|
|
for t in registry.list_templates() {
|
|
println!(" {} {} — {}", t.icon, t.id, t.description);
|
|
}
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
|
|
// Set up credential resolver
|
|
let dotenv_path = home.join(".env");
|
|
let vault_path = home.join("vault.enc");
|
|
let vault = if vault_path.exists() {
|
|
let mut v = openfang_extensions::vault::CredentialVault::new(vault_path);
|
|
if v.unlock().is_ok() {
|
|
Some(v)
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
let mut resolver =
|
|
openfang_extensions::credentials::CredentialResolver::new(vault, Some(&dotenv_path))
|
|
.with_interactive(true);
|
|
|
|
// Build provided keys map
|
|
let mut provided_keys = std::collections::HashMap::new();
|
|
if let Some(key_value) = key {
|
|
// Auto-detect which env var to use (first required_env that's a secret)
|
|
if let Some(env_var) = template.required_env.iter().find(|e| e.is_secret) {
|
|
provided_keys.insert(env_var.name.clone(), key_value.to_string());
|
|
}
|
|
}
|
|
|
|
match openfang_extensions::installer::install_integration(
|
|
&mut registry,
|
|
&mut resolver,
|
|
name,
|
|
&provided_keys,
|
|
) {
|
|
Ok(result) => {
|
|
match &result.status {
|
|
openfang_extensions::IntegrationStatus::Ready => {
|
|
ui::success(&result.message);
|
|
}
|
|
openfang_extensions::IntegrationStatus::Setup => {
|
|
println!("{}", result.message.yellow());
|
|
println!("\nTo add credentials:");
|
|
for env in &template.required_env {
|
|
if env.is_secret {
|
|
println!(" openfang vault set {} # {}", env.name, env.help);
|
|
if let Some(ref url) = env.get_url {
|
|
println!(" Get it here: {url}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ => println!("{}", result.message),
|
|
}
|
|
|
|
// If daemon is running, trigger hot-reload
|
|
if let Some(base_url) = find_daemon() {
|
|
let client = daemon_client();
|
|
let _ = client
|
|
.post(format!("{base_url}/api/integrations/reload"))
|
|
.send();
|
|
}
|
|
}
|
|
Err(e) => {
|
|
ui::error(&e.to_string());
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_integration_remove(name: &str) {
|
|
let home = openfang_home();
|
|
let mut registry = openfang_extensions::registry::IntegrationRegistry::new(&home);
|
|
registry.load_bundled();
|
|
let _ = registry.load_installed();
|
|
|
|
match openfang_extensions::installer::remove_integration(&mut registry, name) {
|
|
Ok(msg) => {
|
|
ui::success(&msg);
|
|
// Hot-reload daemon
|
|
if let Some(base_url) = find_daemon() {
|
|
let client = daemon_client();
|
|
let _ = client
|
|
.post(format!("{base_url}/api/integrations/reload"))
|
|
.send();
|
|
}
|
|
}
|
|
Err(e) => {
|
|
ui::error(&e.to_string());
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_integrations_list(query: Option<&str>) {
|
|
let home = openfang_home();
|
|
let mut registry = openfang_extensions::registry::IntegrationRegistry::new(&home);
|
|
registry.load_bundled();
|
|
let _ = registry.load_installed();
|
|
|
|
let dotenv_path = home.join(".env");
|
|
let resolver =
|
|
openfang_extensions::credentials::CredentialResolver::new(None, Some(&dotenv_path));
|
|
|
|
let entries = if let Some(q) = query {
|
|
openfang_extensions::installer::search_integrations(®istry, q)
|
|
} else {
|
|
openfang_extensions::installer::list_integrations(®istry, &resolver)
|
|
};
|
|
|
|
if entries.is_empty() {
|
|
if let Some(q) = query {
|
|
println!("No integrations matching '{q}'.");
|
|
} else {
|
|
println!("No integrations available.");
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Group by category
|
|
let mut by_category: std::collections::BTreeMap<
|
|
String,
|
|
Vec<&openfang_extensions::installer::IntegrationListEntry>,
|
|
> = std::collections::BTreeMap::new();
|
|
for entry in &entries {
|
|
by_category
|
|
.entry(entry.category.clone())
|
|
.or_default()
|
|
.push(entry);
|
|
}
|
|
|
|
for (category, items) in &by_category {
|
|
println!("\n{}", format!(" {category}").bold());
|
|
for item in items {
|
|
let status_badge = match &item.status {
|
|
openfang_extensions::IntegrationStatus::Ready => "[Ready]".green().to_string(),
|
|
openfang_extensions::IntegrationStatus::Setup => "[Setup]".yellow().to_string(),
|
|
openfang_extensions::IntegrationStatus::Available => {
|
|
"[Available]".dimmed().to_string()
|
|
}
|
|
openfang_extensions::IntegrationStatus::Error(msg) => {
|
|
format!("[Error: {msg}]").red().to_string()
|
|
}
|
|
openfang_extensions::IntegrationStatus::Disabled => {
|
|
"[Disabled]".dimmed().to_string()
|
|
}
|
|
};
|
|
println!(
|
|
" {} {:<20} {:<12} {}",
|
|
item.icon, item.id, status_badge, item.description
|
|
);
|
|
}
|
|
}
|
|
println!();
|
|
println!(
|
|
" {} integrations ({} installed)",
|
|
entries.len(),
|
|
entries
|
|
.iter()
|
|
.filter(|e| matches!(
|
|
e.status,
|
|
openfang_extensions::IntegrationStatus::Ready
|
|
| openfang_extensions::IntegrationStatus::Setup
|
|
))
|
|
.count()
|
|
);
|
|
println!(" Use `openfang add <name>` to install an integration.");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Vault commands (openfang vault init/set/list/remove)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn cmd_vault_init() {
|
|
let home = openfang_home();
|
|
let vault_path = home.join("vault.enc");
|
|
let mut vault = openfang_extensions::vault::CredentialVault::new(vault_path);
|
|
|
|
match vault.init() {
|
|
Ok(()) => ui::success("Credential vault initialized."),
|
|
Err(e) => {
|
|
ui::error(&e.to_string());
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_vault_set(key: &str) {
|
|
use zeroize::Zeroizing;
|
|
|
|
let home = openfang_home();
|
|
let vault_path = home.join("vault.enc");
|
|
let mut vault = openfang_extensions::vault::CredentialVault::new(vault_path);
|
|
|
|
if !vault.exists() {
|
|
ui::error("Vault not initialized. Run: openfang vault init");
|
|
std::process::exit(1);
|
|
}
|
|
|
|
if let Err(e) = vault.unlock() {
|
|
ui::error(&format!("Could not unlock vault: {e}"));
|
|
std::process::exit(1);
|
|
}
|
|
|
|
let value = prompt_input(&format!("Enter value for {key}: "));
|
|
if value.is_empty() {
|
|
ui::error("Empty value — not stored.");
|
|
std::process::exit(1);
|
|
}
|
|
|
|
match vault.set(key.to_string(), Zeroizing::new(value)) {
|
|
Ok(()) => ui::success(&format!("Stored '{key}' in vault.")),
|
|
Err(e) => {
|
|
ui::error(&format!("Failed to store: {e}"));
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_vault_list() {
|
|
let home = openfang_home();
|
|
let vault_path = home.join("vault.enc");
|
|
let mut vault = openfang_extensions::vault::CredentialVault::new(vault_path);
|
|
|
|
if !vault.exists() {
|
|
println!("Vault not initialized. Run: openfang vault init");
|
|
return;
|
|
}
|
|
|
|
if let Err(e) = vault.unlock() {
|
|
ui::error(&format!("Could not unlock vault: {e}"));
|
|
std::process::exit(1);
|
|
}
|
|
|
|
let keys = vault.list_keys();
|
|
if keys.is_empty() {
|
|
println!("Vault is empty.");
|
|
} else {
|
|
println!("Stored credentials ({}):", keys.len());
|
|
for key in keys {
|
|
println!(" {key}");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_vault_remove(key: &str) {
|
|
let home = openfang_home();
|
|
let vault_path = home.join("vault.enc");
|
|
let mut vault = openfang_extensions::vault::CredentialVault::new(vault_path);
|
|
|
|
if !vault.exists() {
|
|
ui::error("Vault not initialized.");
|
|
std::process::exit(1);
|
|
}
|
|
if let Err(e) = vault.unlock() {
|
|
ui::error(&format!("Could not unlock vault: {e}"));
|
|
std::process::exit(1);
|
|
}
|
|
|
|
match vault.remove(key) {
|
|
Ok(true) => ui::success(&format!("Removed '{key}' from vault.")),
|
|
Ok(false) => println!("Key '{key}' not found in vault."),
|
|
Err(e) => {
|
|
ui::error(&format!("Failed to remove: {e}"));
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Scaffold commands (openfang new skill/integration)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn cmd_scaffold(kind: ScaffoldKind) {
|
|
let cwd = std::env::current_dir().unwrap_or_default();
|
|
let result = match kind {
|
|
ScaffoldKind::Skill => {
|
|
openfang_extensions::installer::scaffold_skill(&cwd.join("my-skill"))
|
|
}
|
|
ScaffoldKind::Integration => {
|
|
openfang_extensions::installer::scaffold_integration(&cwd.join("my-integration"))
|
|
}
|
|
};
|
|
match result {
|
|
Ok(msg) => ui::success(&msg),
|
|
Err(e) => {
|
|
ui::error(&e.to_string());
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// New command handlers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn cmd_models_list(provider_filter: Option<&str>, json: bool) {
|
|
if let Some(base) = find_daemon() {
|
|
let client = daemon_client();
|
|
let url = match provider_filter {
|
|
Some(p) => format!("{base}/api/models?provider={p}"),
|
|
None => format!("{base}/api/models"),
|
|
};
|
|
let body = daemon_json(client.get(&url).send());
|
|
if json {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
return;
|
|
}
|
|
if let Some(arr) = body.as_array() {
|
|
if arr.is_empty() {
|
|
println!("No models found.");
|
|
return;
|
|
}
|
|
println!("{:<40} {:<16} {:<8} CONTEXT", "MODEL", "PROVIDER", "TIER");
|
|
println!("{}", "-".repeat(80));
|
|
for m in arr {
|
|
println!(
|
|
"{:<40} {:<16} {:<8} {}",
|
|
m["id"].as_str().unwrap_or("?"),
|
|
m["provider"].as_str().unwrap_or("?"),
|
|
m["tier"].as_str().unwrap_or("?"),
|
|
m["context_window"].as_u64().unwrap_or(0),
|
|
);
|
|
}
|
|
} else {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
}
|
|
} else {
|
|
// Standalone: use ModelCatalog directly
|
|
let catalog = openfang_runtime::model_catalog::ModelCatalog::new();
|
|
let models = catalog.list_models();
|
|
if json {
|
|
let arr: Vec<serde_json::Value> = models
|
|
.iter()
|
|
.filter(|m| provider_filter.is_none_or(|p| m.provider == p))
|
|
.map(|m| {
|
|
serde_json::json!({
|
|
"id": m.id,
|
|
"provider": m.provider,
|
|
"tier": format!("{:?}", m.tier),
|
|
"context_window": m.context_window,
|
|
})
|
|
})
|
|
.collect();
|
|
println!("{}", serde_json::to_string_pretty(&arr).unwrap_or_default());
|
|
return;
|
|
}
|
|
if models.is_empty() {
|
|
println!("No models in catalog.");
|
|
return;
|
|
}
|
|
println!("{:<40} {:<16} {:<8} CONTEXT", "MODEL", "PROVIDER", "TIER");
|
|
println!("{}", "-".repeat(80));
|
|
for m in models {
|
|
if let Some(p) = provider_filter {
|
|
if m.provider != p {
|
|
continue;
|
|
}
|
|
}
|
|
println!(
|
|
"{:<40} {:<16} {:<8} {}",
|
|
m.id,
|
|
m.provider,
|
|
format!("{:?}", m.tier),
|
|
m.context_window,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_models_aliases(json: bool) {
|
|
if let Some(base) = find_daemon() {
|
|
let client = daemon_client();
|
|
let body = daemon_json(client.get(format!("{base}/api/models/aliases")).send());
|
|
if json {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
return;
|
|
}
|
|
if let Some(obj) = body.as_object() {
|
|
println!("{:<30} RESOLVES TO", "ALIAS");
|
|
println!("{}", "-".repeat(60));
|
|
for (alias, target) in obj {
|
|
println!("{:<30} {}", alias, target.as_str().unwrap_or("?"));
|
|
}
|
|
} else {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
}
|
|
} else {
|
|
let catalog = openfang_runtime::model_catalog::ModelCatalog::new();
|
|
let aliases = catalog.list_aliases();
|
|
if json {
|
|
let obj: serde_json::Map<String, serde_json::Value> = aliases
|
|
.iter()
|
|
.map(|(a, t)| (a.to_string(), serde_json::Value::String(t.to_string())))
|
|
.collect();
|
|
println!("{}", serde_json::to_string_pretty(&obj).unwrap_or_default());
|
|
return;
|
|
}
|
|
println!("{:<30} RESOLVES TO", "ALIAS");
|
|
println!("{}", "-".repeat(60));
|
|
for (alias, target) in aliases {
|
|
println!("{:<30} {}", alias, target);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_models_providers(json: bool) {
|
|
if let Some(base) = find_daemon() {
|
|
let client = daemon_client();
|
|
let body = daemon_json(client.get(format!("{base}/api/providers")).send());
|
|
if json {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
return;
|
|
}
|
|
if let Some(arr) = body.as_array() {
|
|
println!(
|
|
"{:<20} {:<12} {:<10} BASE URL",
|
|
"PROVIDER", "AUTH", "MODELS"
|
|
);
|
|
println!("{}", "-".repeat(70));
|
|
for p in arr {
|
|
println!(
|
|
"{:<20} {:<12} {:<10} {}",
|
|
p["id"].as_str().unwrap_or("?"),
|
|
p["auth_status"].as_str().unwrap_or("?"),
|
|
p["model_count"].as_u64().unwrap_or(0),
|
|
p["base_url"].as_str().unwrap_or(""),
|
|
);
|
|
}
|
|
} else {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
}
|
|
} else {
|
|
let catalog = openfang_runtime::model_catalog::ModelCatalog::new();
|
|
let providers = catalog.list_providers();
|
|
if json {
|
|
let arr: Vec<serde_json::Value> = providers
|
|
.iter()
|
|
.map(|p| {
|
|
serde_json::json!({
|
|
"id": p.id,
|
|
"auth_status": format!("{:?}", p.auth_status),
|
|
"model_count": p.model_count,
|
|
"base_url": p.base_url,
|
|
})
|
|
})
|
|
.collect();
|
|
println!("{}", serde_json::to_string_pretty(&arr).unwrap_or_default());
|
|
return;
|
|
}
|
|
println!(
|
|
"{:<20} {:<12} {:<10} BASE URL",
|
|
"PROVIDER", "AUTH", "MODELS"
|
|
);
|
|
println!("{}", "-".repeat(70));
|
|
for p in providers {
|
|
println!(
|
|
"{:<20} {:<12} {:<10} {}",
|
|
p.id,
|
|
format!("{:?}", p.auth_status),
|
|
p.model_count,
|
|
p.base_url,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_models_set(model: Option<String>) {
|
|
let model = match model {
|
|
Some(m) => m,
|
|
None => pick_model(),
|
|
};
|
|
let base = require_daemon("models set");
|
|
let client = daemon_client();
|
|
// Use the config set approach through the API
|
|
let body = daemon_json(
|
|
client
|
|
.post(format!("{base}/api/config/set"))
|
|
.json(&serde_json::json!({"key": "default_model.model", "value": model}))
|
|
.send(),
|
|
);
|
|
if body.get("error").is_some() {
|
|
ui::error(&format!(
|
|
"Failed to set model: {}",
|
|
body["error"].as_str().unwrap_or("?")
|
|
));
|
|
} else {
|
|
ui::success(&format!("Default model set to: {model}"));
|
|
}
|
|
}
|
|
|
|
/// Interactive model picker — shows numbered list, accepts number or model ID.
|
|
fn pick_model() -> String {
|
|
let catalog = openfang_runtime::model_catalog::ModelCatalog::new();
|
|
let models = catalog.list_models();
|
|
|
|
if models.is_empty() {
|
|
ui::error("No models in catalog.");
|
|
std::process::exit(1);
|
|
}
|
|
|
|
// Group by provider for display
|
|
let mut by_provider: std::collections::BTreeMap<
|
|
String,
|
|
Vec<&openfang_types::model_catalog::ModelCatalogEntry>,
|
|
> = std::collections::BTreeMap::new();
|
|
for m in models {
|
|
by_provider.entry(m.provider.clone()).or_default().push(m);
|
|
}
|
|
|
|
ui::section("Select a model");
|
|
ui::blank();
|
|
|
|
let mut numbered: Vec<&str> = Vec::new();
|
|
let mut idx = 1;
|
|
for (provider, provider_models) in &by_provider {
|
|
println!(" {}:", provider.bold());
|
|
for m in provider_models {
|
|
println!(" {idx:>3}. {:<36} {:?}", m.id, m.tier);
|
|
numbered.push(&m.id);
|
|
idx += 1;
|
|
}
|
|
}
|
|
ui::blank();
|
|
|
|
loop {
|
|
let input = prompt_input(" Enter number or model ID: ");
|
|
if input.is_empty() {
|
|
continue;
|
|
}
|
|
// Try as number first
|
|
if let Ok(n) = input.parse::<usize>() {
|
|
if n >= 1 && n <= numbered.len() {
|
|
return numbered[n - 1].to_string();
|
|
}
|
|
ui::error(&format!("Number out of range (1-{})", numbered.len()));
|
|
continue;
|
|
}
|
|
// Accept direct model ID if it exists in catalog
|
|
if models.iter().any(|m| m.id == input) {
|
|
return input;
|
|
}
|
|
// Accept as alias
|
|
if catalog.resolve_alias(&input).is_some() {
|
|
return input;
|
|
}
|
|
// Accept any string (user might know a model not in catalog)
|
|
return input;
|
|
}
|
|
}
|
|
|
|
fn cmd_approvals_list(json: bool) {
|
|
let base = require_daemon("approvals list");
|
|
let client = daemon_client();
|
|
let body = daemon_json(client.get(format!("{base}/api/approvals")).send());
|
|
if json {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
return;
|
|
}
|
|
if let Some(arr) = body.as_array() {
|
|
if arr.is_empty() {
|
|
println!("No pending approvals.");
|
|
return;
|
|
}
|
|
println!("{:<38} {:<16} {:<12} REQUEST", "ID", "AGENT", "TYPE");
|
|
println!("{}", "-".repeat(80));
|
|
for a in arr {
|
|
println!(
|
|
"{:<38} {:<16} {:<12} {}",
|
|
a["id"].as_str().unwrap_or("?"),
|
|
a["agent_name"].as_str().unwrap_or("?"),
|
|
a["approval_type"].as_str().unwrap_or("?"),
|
|
a["description"].as_str().unwrap_or(""),
|
|
);
|
|
}
|
|
} else {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
}
|
|
}
|
|
|
|
fn cmd_approvals_respond(id: &str, approve: bool) {
|
|
let base = require_daemon("approvals");
|
|
let client = daemon_client();
|
|
let endpoint = if approve { "approve" } else { "reject" };
|
|
let body = daemon_json(
|
|
client
|
|
.post(format!("{base}/api/approvals/{id}/{endpoint}"))
|
|
.send(),
|
|
);
|
|
if body.get("error").is_some() {
|
|
ui::error(&format!(
|
|
"Failed: {}",
|
|
body["error"].as_str().unwrap_or("?")
|
|
));
|
|
} else {
|
|
ui::success(&format!("Approval {id} {endpoint}d."));
|
|
}
|
|
}
|
|
|
|
fn cmd_cron_list(json: bool) {
|
|
let base = require_daemon("cron list");
|
|
let client = daemon_client();
|
|
let body = daemon_json(client.get(format!("{base}/api/cron/jobs")).send());
|
|
if json {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
return;
|
|
}
|
|
if let Some(arr) = body.as_array() {
|
|
if arr.is_empty() {
|
|
println!("No scheduled jobs.");
|
|
return;
|
|
}
|
|
println!(
|
|
"{:<38} {:<16} {:<20} {:<8} PROMPT",
|
|
"ID", "AGENT", "SCHEDULE", "ENABLED"
|
|
);
|
|
println!("{}", "-".repeat(100));
|
|
for j in arr {
|
|
println!(
|
|
"{:<38} {:<16} {:<20} {:<8} {}",
|
|
j["id"].as_str().unwrap_or("?"),
|
|
j["agent_id"].as_str().unwrap_or("?"),
|
|
j["cron_expr"].as_str().unwrap_or("?"),
|
|
if j["enabled"].as_bool().unwrap_or(false) {
|
|
"yes"
|
|
} else {
|
|
"no"
|
|
},
|
|
j["prompt"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.chars()
|
|
.take(40)
|
|
.collect::<String>(),
|
|
);
|
|
}
|
|
} else {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
}
|
|
}
|
|
|
|
fn cmd_cron_create(agent: &str, spec: &str, prompt: &str, explicit_name: Option<&str>) {
|
|
let base = require_daemon("cron create");
|
|
let client = daemon_client();
|
|
|
|
// Use explicit name if provided, otherwise derive from agent + prompt
|
|
let name = if let Some(n) = explicit_name {
|
|
n.to_string()
|
|
} else {
|
|
let short_prompt: String = prompt
|
|
.split_whitespace()
|
|
.take(4)
|
|
.collect::<Vec<_>>()
|
|
.join("-")
|
|
.chars()
|
|
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
|
|
.take(64)
|
|
.collect();
|
|
format!(
|
|
"{}-{}",
|
|
agent,
|
|
if short_prompt.is_empty() {
|
|
"job"
|
|
} else {
|
|
&short_prompt
|
|
}
|
|
)
|
|
};
|
|
|
|
let body = daemon_json(
|
|
client
|
|
.post(format!("{base}/api/cron/jobs"))
|
|
.json(&serde_json::json!({
|
|
"agent_id": agent,
|
|
"name": name,
|
|
"schedule": {
|
|
"kind": "cron",
|
|
"expr": spec
|
|
},
|
|
"action": {
|
|
"kind": "agent_turn",
|
|
"message": prompt
|
|
}
|
|
}))
|
|
.send(),
|
|
);
|
|
if let Some(id) = body["id"].as_str() {
|
|
ui::success(&format!("Cron job created: {id}"));
|
|
} else {
|
|
ui::error(&format!(
|
|
"Failed: {}",
|
|
body["error"].as_str().unwrap_or("?")
|
|
));
|
|
}
|
|
}
|
|
|
|
fn cmd_cron_delete(id: &str) {
|
|
let base = require_daemon("cron delete");
|
|
let client = daemon_client();
|
|
let body = daemon_json(client.delete(format!("{base}/api/cron/jobs/{id}")).send());
|
|
if body.get("error").is_some() {
|
|
ui::error(&format!(
|
|
"Failed: {}",
|
|
body["error"].as_str().unwrap_or("?")
|
|
));
|
|
} else {
|
|
ui::success(&format!("Cron job {id} deleted."));
|
|
}
|
|
}
|
|
|
|
fn cmd_cron_toggle(id: &str, enable: bool) {
|
|
let base = require_daemon("cron");
|
|
let client = daemon_client();
|
|
let endpoint = if enable { "enable" } else { "disable" };
|
|
let body = daemon_json(
|
|
client
|
|
.post(format!("{base}/api/cron/jobs/{id}/{endpoint}"))
|
|
.send(),
|
|
);
|
|
if body.get("error").is_some() {
|
|
ui::error(&format!(
|
|
"Failed: {}",
|
|
body["error"].as_str().unwrap_or("?")
|
|
));
|
|
} else {
|
|
ui::success(&format!("Cron job {id} {endpoint}d."));
|
|
}
|
|
}
|
|
|
|
fn cmd_sessions(agent: Option<&str>, json: bool) {
|
|
let base = require_daemon("sessions");
|
|
let client = daemon_client();
|
|
let url = match agent {
|
|
Some(a) => format!("{base}/api/sessions?agent={a}"),
|
|
None => format!("{base}/api/sessions"),
|
|
};
|
|
let body = daemon_json(client.get(&url).send());
|
|
if json {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
return;
|
|
}
|
|
if let Some(arr) = body.as_array() {
|
|
if arr.is_empty() {
|
|
println!("No sessions found.");
|
|
return;
|
|
}
|
|
println!("{:<38} {:<16} {:<8} LAST ACTIVE", "ID", "AGENT", "MSGS");
|
|
println!("{}", "-".repeat(80));
|
|
for s in arr {
|
|
println!(
|
|
"{:<38} {:<16} {:<8} {}",
|
|
s["id"].as_str().unwrap_or("?"),
|
|
s["agent_name"].as_str().unwrap_or("?"),
|
|
s["message_count"].as_u64().unwrap_or(0),
|
|
s["last_active"].as_str().unwrap_or("?"),
|
|
);
|
|
}
|
|
} else {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
}
|
|
}
|
|
|
|
fn cmd_logs(lines: usize, follow: bool) {
|
|
let log_path = cli_openfang_home().join("tui.log");
|
|
|
|
if !log_path.exists() {
|
|
ui::error_with_fix(
|
|
"Log file not found",
|
|
&format!("Expected at: {}", log_path.display()),
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
|
|
if follow {
|
|
// Use tail -f equivalent
|
|
#[cfg(unix)]
|
|
{
|
|
let _ = std::process::Command::new("tail")
|
|
.args(["-f", "-n", &lines.to_string()])
|
|
.arg(&log_path)
|
|
.status();
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
// On Windows, read in a loop
|
|
let content = std::fs::read_to_string(&log_path).unwrap_or_default();
|
|
let all_lines: Vec<&str> = content.lines().collect();
|
|
let start = all_lines.len().saturating_sub(lines);
|
|
for line in &all_lines[start..] {
|
|
println!("{line}");
|
|
}
|
|
println!("--- Following {} (Ctrl+C to stop) ---", log_path.display());
|
|
let mut last_len = content.len();
|
|
loop {
|
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
|
if let Ok(new_content) = std::fs::read_to_string(&log_path) {
|
|
if new_content.len() > last_len {
|
|
print!("{}", &new_content[last_len..]);
|
|
last_len = new_content.len();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
let content = std::fs::read_to_string(&log_path).unwrap_or_default();
|
|
let all_lines: Vec<&str> = content.lines().collect();
|
|
let start = all_lines.len().saturating_sub(lines);
|
|
for line in &all_lines[start..] {
|
|
println!("{line}");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_health(json: bool) {
|
|
match find_daemon() {
|
|
Some(base) => {
|
|
let client = daemon_client();
|
|
let body = daemon_json(client.get(format!("{base}/api/health")).send());
|
|
if json {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
return;
|
|
}
|
|
ui::success("Daemon is healthy");
|
|
if let Some(status) = body["status"].as_str() {
|
|
ui::kv("Status", status);
|
|
}
|
|
if let Some(uptime) = body.get("uptime_secs").and_then(|v| v.as_u64()) {
|
|
let hours = uptime / 3600;
|
|
let mins = (uptime % 3600) / 60;
|
|
ui::kv("Uptime", &format!("{hours}h {mins}m"));
|
|
}
|
|
}
|
|
None => {
|
|
if json {
|
|
println!("{}", serde_json::json!({"error": "daemon not running"}));
|
|
std::process::exit(1);
|
|
}
|
|
ui::error("Daemon is not running.");
|
|
ui::hint("Start it with: openfang start");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_security_status(json: bool) {
|
|
let base = require_daemon("security status");
|
|
let client = daemon_client();
|
|
let body = daemon_json(client.get(format!("{base}/api/health/detail")).send());
|
|
if json {
|
|
let data = serde_json::json!({
|
|
"audit_trail": "merkle_hash_chain_sha256",
|
|
"taint_tracking": "information_flow_labels",
|
|
"wasm_sandbox": "dual_metering_fuel_epoch",
|
|
"wire_protocol": "ofp_hmac_sha256_mutual_auth",
|
|
"api_keys": "zeroizing_auto_wipe",
|
|
"manifests": "ed25519_signed",
|
|
"agent_count": body.get("agent_count").and_then(|v| v.as_u64()),
|
|
});
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&data).unwrap_or_default()
|
|
);
|
|
return;
|
|
}
|
|
ui::section("Security Status");
|
|
ui::blank();
|
|
ui::kv("Audit trail", "Merkle hash chain (SHA-256)");
|
|
ui::kv("Taint tracking", "Information flow labels");
|
|
ui::kv("WASM sandbox", "Dual metering (fuel + epoch)");
|
|
ui::kv("Wire protocol", "OFP HMAC-SHA256 mutual auth");
|
|
ui::kv("API keys", "Zeroizing<String> (auto-wipe on drop)");
|
|
ui::kv("Manifests", "Ed25519 signed");
|
|
if let Some(agents) = body.get("agent_count").and_then(|v| v.as_u64()) {
|
|
ui::kv("Active agents", &agents.to_string());
|
|
}
|
|
}
|
|
|
|
fn cmd_security_audit(limit: usize, json: bool) {
|
|
let base = require_daemon("security audit");
|
|
let client = daemon_client();
|
|
let body = daemon_json(
|
|
client
|
|
.get(format!("{base}/api/audit/recent?limit={limit}"))
|
|
.send(),
|
|
);
|
|
if json {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
return;
|
|
}
|
|
if let Some(arr) = body.as_array() {
|
|
if arr.is_empty() {
|
|
println!("No audit entries.");
|
|
return;
|
|
}
|
|
println!("{:<24} {:<16} {:<12} EVENT", "TIMESTAMP", "AGENT", "TYPE");
|
|
println!("{}", "-".repeat(80));
|
|
for entry in arr {
|
|
println!(
|
|
"{:<24} {:<16} {:<12} {}",
|
|
entry["timestamp"].as_str().unwrap_or("?"),
|
|
entry["agent_name"].as_str().unwrap_or("?"),
|
|
entry["event_type"].as_str().unwrap_or("?"),
|
|
entry["description"].as_str().unwrap_or(""),
|
|
);
|
|
}
|
|
} else {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
}
|
|
}
|
|
|
|
fn cmd_security_verify() {
|
|
let base = require_daemon("security verify");
|
|
let client = daemon_client();
|
|
let body = daemon_json(client.get(format!("{base}/api/audit/verify")).send());
|
|
if body["valid"].as_bool().unwrap_or(false) {
|
|
ui::success("Audit trail integrity verified (Merkle chain valid).");
|
|
} else {
|
|
ui::error("Audit trail integrity check FAILED.");
|
|
if let Some(msg) = body["error"].as_str() {
|
|
ui::hint(msg);
|
|
}
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
fn cmd_memory_list(agent: &str, json: bool) {
|
|
let base = require_daemon("memory list");
|
|
let client = daemon_client();
|
|
let body = daemon_json(
|
|
client
|
|
.get(format!("{base}/api/memory/agents/{agent}/kv"))
|
|
.send(),
|
|
);
|
|
if json {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
return;
|
|
}
|
|
if let Some(arr) = body.as_array() {
|
|
if arr.is_empty() {
|
|
println!("No memory entries for agent '{agent}'.");
|
|
return;
|
|
}
|
|
println!("{:<30} VALUE", "KEY");
|
|
println!("{}", "-".repeat(60));
|
|
for kv in arr {
|
|
println!(
|
|
"{:<30} {}",
|
|
kv["key"].as_str().unwrap_or("?"),
|
|
kv["value"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.chars()
|
|
.take(50)
|
|
.collect::<String>(),
|
|
);
|
|
}
|
|
} else {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
}
|
|
}
|
|
|
|
fn cmd_memory_get(agent: &str, key: &str, json: bool) {
|
|
let base = require_daemon("memory get");
|
|
let client = daemon_client();
|
|
let body = daemon_json(
|
|
client
|
|
.get(format!("{base}/api/memory/agents/{agent}/kv/{key}"))
|
|
.send(),
|
|
);
|
|
if json {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
return;
|
|
}
|
|
if let Some(val) = body["value"].as_str() {
|
|
println!("{val}");
|
|
} else {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
}
|
|
}
|
|
|
|
fn cmd_memory_set(agent: &str, key: &str, value: &str) {
|
|
let base = require_daemon("memory set");
|
|
let client = daemon_client();
|
|
let body = daemon_json(
|
|
client
|
|
.put(format!("{base}/api/memory/agents/{agent}/kv/{key}"))
|
|
.json(&serde_json::json!({"value": value}))
|
|
.send(),
|
|
);
|
|
if body.get("error").is_some() {
|
|
ui::error(&format!(
|
|
"Failed: {}",
|
|
body["error"].as_str().unwrap_or("?")
|
|
));
|
|
} else {
|
|
ui::success(&format!("Set {key} for agent '{agent}'."));
|
|
}
|
|
}
|
|
|
|
fn cmd_memory_delete(agent: &str, key: &str) {
|
|
let base = require_daemon("memory delete");
|
|
let client = daemon_client();
|
|
let body = daemon_json(
|
|
client
|
|
.delete(format!("{base}/api/memory/agents/{agent}/kv/{key}"))
|
|
.send(),
|
|
);
|
|
if body.get("error").is_some() {
|
|
ui::error(&format!(
|
|
"Failed: {}",
|
|
body["error"].as_str().unwrap_or("?")
|
|
));
|
|
} else {
|
|
ui::success(&format!("Deleted key '{key}' for agent '{agent}'."));
|
|
}
|
|
}
|
|
|
|
fn cmd_devices_list(json: bool) {
|
|
let base = require_daemon("devices list");
|
|
let client = daemon_client();
|
|
let body = daemon_json(client.get(format!("{base}/api/pairing/devices")).send());
|
|
if json {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
return;
|
|
}
|
|
if let Some(arr) = body.as_array() {
|
|
if arr.is_empty() {
|
|
println!("No paired devices.");
|
|
return;
|
|
}
|
|
println!("{:<38} {:<20} LAST SEEN", "ID", "NAME");
|
|
println!("{}", "-".repeat(70));
|
|
for d in arr {
|
|
println!(
|
|
"{:<38} {:<20} {}",
|
|
d["id"].as_str().unwrap_or("?"),
|
|
d["name"].as_str().unwrap_or("?"),
|
|
d["last_seen"].as_str().unwrap_or("?"),
|
|
);
|
|
}
|
|
} else {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
}
|
|
}
|
|
|
|
fn cmd_devices_pair() {
|
|
let base = require_daemon("qr");
|
|
let client = daemon_client();
|
|
let body = daemon_json(client.post(format!("{base}/api/pairing/request")).send());
|
|
if let Some(qr) = body["qr_data"].as_str() {
|
|
ui::section("Device Pairing");
|
|
ui::blank();
|
|
// Render a simple text-based QR representation
|
|
println!(" Scan this QR code with the OpenFang mobile app:");
|
|
ui::blank();
|
|
println!(" {qr}");
|
|
ui::blank();
|
|
if let Some(code) = body["pairing_code"].as_str() {
|
|
ui::kv("Pairing code", code);
|
|
}
|
|
if let Some(expires) = body["expires_at"].as_str() {
|
|
ui::kv("Expires", expires);
|
|
}
|
|
} else {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
}
|
|
}
|
|
|
|
fn cmd_devices_remove(id: &str) {
|
|
let base = require_daemon("devices remove");
|
|
let client = daemon_client();
|
|
let body = daemon_json(
|
|
client
|
|
.delete(format!("{base}/api/pairing/devices/{id}"))
|
|
.send(),
|
|
);
|
|
if body.get("error").is_some() {
|
|
ui::error(&format!(
|
|
"Failed: {}",
|
|
body["error"].as_str().unwrap_or("?")
|
|
));
|
|
} else {
|
|
ui::success(&format!("Device {id} removed."));
|
|
}
|
|
}
|
|
|
|
fn cmd_webhooks_list(json: bool) {
|
|
let base = require_daemon("webhooks list");
|
|
let client = daemon_client();
|
|
let body = daemon_json(client.get(format!("{base}/api/triggers")).send());
|
|
if json {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
return;
|
|
}
|
|
if let Some(arr) = body.as_array() {
|
|
if arr.is_empty() {
|
|
println!("No webhooks configured.");
|
|
return;
|
|
}
|
|
println!("{:<38} {:<16} URL", "ID", "AGENT");
|
|
println!("{}", "-".repeat(80));
|
|
for w in arr {
|
|
println!(
|
|
"{:<38} {:<16} {}",
|
|
w["id"].as_str().unwrap_or("?"),
|
|
w["agent_id"].as_str().unwrap_or("?"),
|
|
w["url"].as_str().unwrap_or(""),
|
|
);
|
|
}
|
|
} else {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
}
|
|
}
|
|
|
|
fn cmd_webhooks_create(agent: &str, url: &str) {
|
|
let base = require_daemon("webhooks create");
|
|
let client = daemon_client();
|
|
let body = daemon_json(
|
|
client
|
|
.post(format!("{base}/api/triggers"))
|
|
.json(&serde_json::json!({
|
|
"agent_id": agent,
|
|
"pattern": {"webhook": {"url": url}},
|
|
"prompt_template": "Webhook event: {{event}}",
|
|
}))
|
|
.send(),
|
|
);
|
|
if let Some(id) = body["id"].as_str() {
|
|
ui::success(&format!("Webhook created: {id}"));
|
|
} else {
|
|
ui::error(&format!(
|
|
"Failed: {}",
|
|
body["error"].as_str().unwrap_or("?")
|
|
));
|
|
}
|
|
}
|
|
|
|
fn cmd_webhooks_delete(id: &str) {
|
|
let base = require_daemon("webhooks delete");
|
|
let client = daemon_client();
|
|
let body = daemon_json(client.delete(format!("{base}/api/triggers/{id}")).send());
|
|
if body.get("error").is_some() {
|
|
ui::error(&format!(
|
|
"Failed: {}",
|
|
body["error"].as_str().unwrap_or("?")
|
|
));
|
|
} else {
|
|
ui::success(&format!("Webhook {id} deleted."));
|
|
}
|
|
}
|
|
|
|
fn cmd_webhooks_test(id: &str) {
|
|
let base = require_daemon("webhooks test");
|
|
let client = daemon_client();
|
|
let body = daemon_json(client.post(format!("{base}/api/triggers/{id}/test")).send());
|
|
if body["success"].as_bool().unwrap_or(false) {
|
|
ui::success(&format!("Webhook {id} test payload sent successfully."));
|
|
} else {
|
|
ui::error(&format!(
|
|
"Webhook test failed: {}",
|
|
body["error"].as_str().unwrap_or("?")
|
|
));
|
|
}
|
|
}
|
|
|
|
fn cmd_message(agent: &str, text: &str, json: bool) {
|
|
let base = require_daemon("message");
|
|
let client = daemon_client();
|
|
let body = daemon_json(
|
|
client
|
|
.post(format!("{base}/api/agents/{agent}/message"))
|
|
.json(&serde_json::json!({"message": text}))
|
|
.send(),
|
|
);
|
|
if json {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
} else if let Some(reply) = body["reply"].as_str() {
|
|
println!("{reply}");
|
|
} else if let Some(reply) = body["response"].as_str() {
|
|
println!("{reply}");
|
|
} else {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
);
|
|
}
|
|
}
|
|
|
|
fn cmd_system_info(json: bool) {
|
|
if let Some(base) = find_daemon() {
|
|
let client = daemon_client();
|
|
let body = daemon_json(client.get(format!("{base}/api/status")).send());
|
|
if json {
|
|
let mut data = body.clone();
|
|
if let Some(obj) = data.as_object_mut() {
|
|
obj.insert(
|
|
"version".to_string(),
|
|
serde_json::json!(env!("CARGO_PKG_VERSION")),
|
|
);
|
|
obj.insert("api_url".to_string(), serde_json::json!(base));
|
|
}
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&data).unwrap_or_default()
|
|
);
|
|
return;
|
|
}
|
|
ui::section("OpenFang System Info");
|
|
ui::blank();
|
|
ui::kv("Version", env!("CARGO_PKG_VERSION"));
|
|
ui::kv("Status", body["status"].as_str().unwrap_or("?"));
|
|
ui::kv(
|
|
"Agents",
|
|
&body["agent_count"].as_u64().unwrap_or(0).to_string(),
|
|
);
|
|
ui::kv("Provider", body["default_provider"].as_str().unwrap_or("?"));
|
|
ui::kv("Model", body["default_model"].as_str().unwrap_or("?"));
|
|
ui::kv("API", &base);
|
|
ui::kv("Data dir", body["data_dir"].as_str().unwrap_or("?"));
|
|
ui::kv(
|
|
"Uptime",
|
|
&format!("{}s", body["uptime_seconds"].as_u64().unwrap_or(0)),
|
|
);
|
|
} else {
|
|
if json {
|
|
println!(
|
|
"{}",
|
|
serde_json::json!({
|
|
"version": env!("CARGO_PKG_VERSION"),
|
|
"daemon": "not_running",
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
ui::section("OpenFang System Info");
|
|
ui::blank();
|
|
ui::kv("Version", env!("CARGO_PKG_VERSION"));
|
|
ui::kv_warn("Daemon", "NOT RUNNING");
|
|
ui::hint("Start with: openfang start");
|
|
}
|
|
}
|
|
|
|
fn cmd_system_version(json: bool) {
|
|
if json {
|
|
println!(
|
|
"{}",
|
|
serde_json::json!({"version": env!("CARGO_PKG_VERSION")})
|
|
);
|
|
return;
|
|
}
|
|
println!("openfang {}", env!("CARGO_PKG_VERSION"));
|
|
}
|
|
|
|
fn cmd_reset(confirm: bool) {
|
|
let openfang_dir = cli_openfang_home();
|
|
|
|
if !openfang_dir.exists() {
|
|
println!(
|
|
"Nothing to reset — {} does not exist.",
|
|
openfang_dir.display()
|
|
);
|
|
return;
|
|
}
|
|
|
|
if !confirm {
|
|
println!(" This will delete all data in {}", openfang_dir.display());
|
|
println!(" Including: config, database, agent manifests, credentials.");
|
|
println!();
|
|
let answer = prompt_input(" Are you sure? Type 'yes' to confirm: ");
|
|
if answer.trim() != "yes" {
|
|
println!(" Cancelled.");
|
|
return;
|
|
}
|
|
}
|
|
|
|
match std::fs::remove_dir_all(&openfang_dir) {
|
|
Ok(()) => ui::success(&format!("Removed {}", openfang_dir.display())),
|
|
Err(e) => {
|
|
ui::error(&format!("Failed to remove {}: {e}", openfang_dir.display()));
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Uninstall
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn cmd_uninstall(confirm: bool, keep_config: bool) {
|
|
let openfang_dir = cli_openfang_home();
|
|
let exe_path = std::env::current_exe().ok();
|
|
|
|
// Step 1: Show what will be removed
|
|
println!();
|
|
println!(
|
|
" {}",
|
|
"This will completely uninstall OpenFang from your system."
|
|
.bold()
|
|
.red()
|
|
);
|
|
println!();
|
|
if openfang_dir.exists() {
|
|
if keep_config {
|
|
println!(
|
|
" • Remove data in {} (keeping config files)",
|
|
openfang_dir.display()
|
|
);
|
|
} else {
|
|
println!(" • Remove {}", openfang_dir.display());
|
|
}
|
|
}
|
|
if let Some(ref exe) = exe_path {
|
|
println!(" • Remove binary: {}", exe.display());
|
|
}
|
|
// Check cargo bin path
|
|
let cargo_bin = dirs::home_dir()
|
|
.unwrap_or_else(std::env::temp_dir)
|
|
.join(".cargo")
|
|
.join("bin")
|
|
.join(if cfg!(windows) {
|
|
"openfang.exe"
|
|
} else {
|
|
"openfang"
|
|
});
|
|
if cargo_bin.exists() && exe_path.as_ref().is_none_or(|e| *e != cargo_bin) {
|
|
println!(" • Remove cargo binary: {}", cargo_bin.display());
|
|
}
|
|
println!(" • Remove auto-start entries (if any)");
|
|
println!(" • Clean PATH from shell configs (if any)");
|
|
println!();
|
|
|
|
// Step 2: Confirm
|
|
if !confirm {
|
|
let answer = prompt_input(" Type 'uninstall' to confirm: ");
|
|
if answer.trim() != "uninstall" {
|
|
println!(" Cancelled.");
|
|
return;
|
|
}
|
|
println!();
|
|
}
|
|
|
|
// Step 3: Stop running daemon
|
|
if find_daemon().is_some() {
|
|
println!(" Stopping running daemon...");
|
|
cmd_stop();
|
|
// Give it a moment
|
|
std::thread::sleep(std::time::Duration::from_secs(1));
|
|
// Force kill if still alive
|
|
if find_daemon().is_some() {
|
|
if let Some(info) = read_daemon_info(&openfang_dir) {
|
|
force_kill_pid(info.pid);
|
|
let _ = std::fs::remove_file(openfang_dir.join("daemon.json"));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 4: Remove auto-start entries
|
|
let user_home = dirs::home_dir().unwrap_or_else(std::env::temp_dir);
|
|
remove_autostart_entries(&user_home);
|
|
|
|
// Step 5: Clean PATH from shell configs
|
|
if let Some(ref exe) = exe_path {
|
|
if let Some(bin_dir) = exe.parent() {
|
|
clean_path_entries(&user_home, &bin_dir.to_string_lossy());
|
|
}
|
|
}
|
|
|
|
// Step 6: Remove ~/.openfang/ data
|
|
if openfang_dir.exists() {
|
|
if keep_config {
|
|
remove_dir_except_config(&openfang_dir);
|
|
ui::success("Removed data (kept config files)");
|
|
} else {
|
|
match std::fs::remove_dir_all(&openfang_dir) {
|
|
Ok(()) => ui::success(&format!("Removed {}", openfang_dir.display())),
|
|
Err(e) => ui::error(&format!("Failed to remove {}: {e}", openfang_dir.display())),
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 7: Remove cargo bin copy if it exists and is separate from current exe
|
|
if cargo_bin.exists() && exe_path.as_ref().is_none_or(|e| *e != cargo_bin) {
|
|
match std::fs::remove_file(&cargo_bin) {
|
|
Ok(()) => ui::success(&format!("Removed {}", cargo_bin.display())),
|
|
Err(e) => ui::error(&format!("Failed to remove {}: {e}", cargo_bin.display())),
|
|
}
|
|
}
|
|
|
|
// Step 8: Remove the binary itself (must be last)
|
|
if let Some(exe) = exe_path {
|
|
remove_self_binary(&exe);
|
|
}
|
|
|
|
println!();
|
|
ui::success("OpenFang has been uninstalled. Goodbye!");
|
|
}
|
|
|
|
/// Remove auto-start / launch-agent / systemd entries.
|
|
#[allow(unused_variables)]
|
|
fn remove_autostart_entries(home: &std::path::Path) {
|
|
#[cfg(windows)]
|
|
{
|
|
// Windows: remove from HKCU\Software\Microsoft\Windows\CurrentVersion\Run
|
|
let output = std::process::Command::new("reg")
|
|
.args([
|
|
"delete",
|
|
r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run",
|
|
"/v",
|
|
"OpenFang",
|
|
"/f",
|
|
])
|
|
.output();
|
|
match output {
|
|
Ok(o) if o.status.success() => {
|
|
ui::success("Removed Windows auto-start registry entry");
|
|
}
|
|
_ => {} // Entry didn't exist — that's fine
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
let plist = home.join("Library/LaunchAgents/ai.openfang.desktop.plist");
|
|
if plist.exists() {
|
|
// Unload first
|
|
let _ = std::process::Command::new("launchctl")
|
|
.args(["unload", &plist.to_string_lossy()])
|
|
.output();
|
|
match std::fs::remove_file(&plist) {
|
|
Ok(()) => ui::success("Removed macOS launch agent"),
|
|
Err(e) => ui::error(&format!("Failed to remove launch agent: {e}")),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
let desktop_file = home.join(".config/autostart/OpenFang.desktop");
|
|
if desktop_file.exists() {
|
|
match std::fs::remove_file(&desktop_file) {
|
|
Ok(()) => ui::success("Removed Linux autostart entry"),
|
|
Err(e) => ui::error(&format!("Failed to remove autostart entry: {e}")),
|
|
}
|
|
}
|
|
|
|
// Also check for systemd user service
|
|
let service_file = home.join(".config/systemd/user/openfang.service");
|
|
if service_file.exists() {
|
|
let _ = std::process::Command::new("systemctl")
|
|
.args(["--user", "disable", "--now", "openfang.service"])
|
|
.output();
|
|
match std::fs::remove_file(&service_file) {
|
|
Ok(()) => {
|
|
let _ = std::process::Command::new("systemctl")
|
|
.args(["--user", "daemon-reload"])
|
|
.output();
|
|
ui::success("Removed systemd user service");
|
|
}
|
|
Err(e) => ui::error(&format!("Failed to remove systemd service: {e}")),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Remove lines from shell config files that add openfang to PATH.
|
|
#[allow(unused_variables)]
|
|
fn clean_path_entries(home: &std::path::Path, openfang_dir: &str) {
|
|
#[cfg(not(windows))]
|
|
{
|
|
let shell_files = [
|
|
home.join(".bashrc"),
|
|
home.join(".bash_profile"),
|
|
home.join(".profile"),
|
|
home.join(".zshrc"),
|
|
home.join(".config/fish/config.fish"),
|
|
];
|
|
|
|
for path in &shell_files {
|
|
if !path.exists() {
|
|
continue;
|
|
}
|
|
let Ok(content) = std::fs::read_to_string(path) else {
|
|
continue;
|
|
};
|
|
let filtered: Vec<&str> = content
|
|
.lines()
|
|
.filter(|line| !is_openfang_path_line(line, openfang_dir))
|
|
.collect();
|
|
if filtered.len() < content.lines().count() {
|
|
let new_content = filtered.join("\n");
|
|
// Preserve trailing newline if original had one
|
|
let new_content = if content.ends_with('\n') {
|
|
format!("{new_content}\n")
|
|
} else {
|
|
new_content
|
|
};
|
|
if std::fs::write(path, &new_content).is_ok() {
|
|
ui::success(&format!("Cleaned PATH from {}", path.display()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
{
|
|
// Read User PATH via PowerShell, filter out openfang entries, write back
|
|
let output = std::process::Command::new("powershell")
|
|
.args([
|
|
"-NoProfile",
|
|
"-Command",
|
|
"[Environment]::GetEnvironmentVariable('PATH', 'User')",
|
|
])
|
|
.output();
|
|
if let Ok(out) = output {
|
|
if out.status.success() {
|
|
let current = String::from_utf8_lossy(&out.stdout);
|
|
let current = current.trim();
|
|
if !current.is_empty() {
|
|
let dir_lower = openfang_dir.to_lowercase();
|
|
let filtered: Vec<&str> = current
|
|
.split(';')
|
|
.filter(|entry| {
|
|
let e = entry.trim().to_lowercase();
|
|
!e.is_empty() && !e.contains("openfang") && !e.contains(&dir_lower)
|
|
})
|
|
.collect();
|
|
if filtered.len() < current.split(';').count() {
|
|
let new_path = filtered.join(";");
|
|
let ps_cmd = format!(
|
|
"[Environment]::SetEnvironmentVariable('PATH', '{}', 'User')",
|
|
new_path.replace('\'', "''")
|
|
);
|
|
let result = std::process::Command::new("powershell")
|
|
.args(["-NoProfile", "-Command", &ps_cmd])
|
|
.output();
|
|
if result.is_ok_and(|o| o.status.success()) {
|
|
ui::success("Cleaned PATH from Windows user environment");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns true if a shell config line is an openfang PATH export.
|
|
/// Must match BOTH an openfang reference AND a PATH-setting pattern.
|
|
#[cfg(any(not(windows), test))]
|
|
fn is_openfang_path_line(line: &str, openfang_dir: &str) -> bool {
|
|
let lower = line.to_lowercase();
|
|
let has_openfang = lower.contains("openfang") || lower.contains(&openfang_dir.to_lowercase());
|
|
if !has_openfang {
|
|
return false;
|
|
}
|
|
// Match common PATH-setting patterns
|
|
lower.contains("export path=")
|
|
|| lower.contains("export path =")
|
|
|| lower.starts_with("path=")
|
|
|| lower.contains("set -gx path")
|
|
|| lower.contains("fish_add_path")
|
|
}
|
|
|
|
/// Remove everything in ~/.openfang/ except config files.
|
|
fn remove_dir_except_config(openfang_dir: &std::path::Path) {
|
|
let keep = ["config.toml", ".env", "secrets.env"];
|
|
let Ok(entries) = std::fs::read_dir(openfang_dir) else {
|
|
return;
|
|
};
|
|
for entry in entries.flatten() {
|
|
let name = entry.file_name();
|
|
let name_str = name.to_string_lossy();
|
|
if keep.contains(&name_str.as_ref()) {
|
|
continue;
|
|
}
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
let _ = std::fs::remove_dir_all(&path);
|
|
} else {
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Remove the currently-running binary.
|
|
fn remove_self_binary(exe_path: &std::path::Path) {
|
|
#[cfg(unix)]
|
|
{
|
|
// On Unix, running binaries can be unlinked — the OS keeps the inode
|
|
// alive until the process exits.
|
|
match std::fs::remove_file(exe_path) {
|
|
Ok(()) => ui::success(&format!("Removed {}", exe_path.display())),
|
|
Err(e) => ui::error(&format!(
|
|
"Failed to remove binary {}: {e}",
|
|
exe_path.display()
|
|
)),
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
{
|
|
// Windows locks running executables. Rename first, then spawn a
|
|
// detached process that waits briefly and deletes the renamed file.
|
|
let old_path = exe_path.with_extension("exe.old");
|
|
if std::fs::rename(exe_path, &old_path).is_err() {
|
|
ui::error(&format!(
|
|
"Could not rename binary for deferred deletion: {}",
|
|
exe_path.display()
|
|
));
|
|
return;
|
|
}
|
|
|
|
use std::os::windows::process::CommandExt;
|
|
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
|
|
const DETACHED_PROCESS: u32 = 0x0000_0008;
|
|
|
|
let del_cmd = format!(
|
|
"ping -n 3 127.0.0.1 >nul & del /f /q \"{}\"",
|
|
old_path.display()
|
|
);
|
|
let _ = std::process::Command::new("cmd.exe")
|
|
.args(["/C", &del_cmd])
|
|
.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
|
|
.spawn();
|
|
|
|
ui::success(&format!(
|
|
"Removed {} (deferred cleanup)",
|
|
exe_path.display()
|
|
));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
|
|
// --- Doctor command unit tests ---
|
|
|
|
#[test]
|
|
fn test_doctor_skill_registry_loads_bundled() {
|
|
let skills_dir = std::env::temp_dir().join("openfang-doctor-test-skills");
|
|
let mut skill_reg = openfang_skills::registry::SkillRegistry::new(skills_dir);
|
|
let count = skill_reg.load_bundled();
|
|
assert!(count > 0, "Should load bundled skills");
|
|
assert_eq!(skill_reg.count(), count);
|
|
}
|
|
|
|
#[test]
|
|
fn test_doctor_extension_registry_loads_bundled() {
|
|
let tmp = std::env::temp_dir().join("openfang-doctor-test-ext");
|
|
let _ = std::fs::create_dir_all(&tmp);
|
|
let mut ext_reg = openfang_extensions::registry::IntegrationRegistry::new(&tmp);
|
|
let count = ext_reg.load_bundled();
|
|
assert!(count > 0, "Should load bundled integration templates");
|
|
assert_eq!(ext_reg.template_count(), count);
|
|
}
|
|
|
|
#[test]
|
|
fn test_doctor_config_deser_default() {
|
|
// Default KernelConfig should serialize/deserialize round-trip
|
|
let config = openfang_types::config::KernelConfig::default();
|
|
let toml_str = toml::to_string_pretty(&config).unwrap();
|
|
let parsed: openfang_types::config::KernelConfig = toml::from_str(&toml_str).unwrap();
|
|
assert_eq!(parsed.api_listen, config.api_listen);
|
|
}
|
|
|
|
#[test]
|
|
fn test_doctor_config_include_field() {
|
|
let config_toml = r#"
|
|
api_listen = "127.0.0.1:4200"
|
|
include = ["providers.toml", "agents.toml"]
|
|
|
|
[default_model]
|
|
provider = "groq"
|
|
model = "llama-3.3-70b-versatile"
|
|
api_key_env = "GROQ_API_KEY"
|
|
"#;
|
|
let config: openfang_types::config::KernelConfig = toml::from_str(config_toml).unwrap();
|
|
assert_eq!(config.include.len(), 2);
|
|
assert_eq!(config.include[0], "providers.toml");
|
|
assert_eq!(config.include[1], "agents.toml");
|
|
}
|
|
|
|
#[test]
|
|
fn test_doctor_exec_policy_field() {
|
|
let config_toml = r#"
|
|
api_listen = "127.0.0.1:4200"
|
|
|
|
[exec_policy]
|
|
mode = "allowlist"
|
|
safe_bins = ["ls", "cat", "echo"]
|
|
timeout_secs = 30
|
|
|
|
[default_model]
|
|
provider = "groq"
|
|
model = "llama-3.3-70b-versatile"
|
|
api_key_env = "GROQ_API_KEY"
|
|
"#;
|
|
let config: openfang_types::config::KernelConfig = toml::from_str(config_toml).unwrap();
|
|
assert_eq!(
|
|
config.exec_policy.mode,
|
|
openfang_types::config::ExecSecurityMode::Allowlist
|
|
);
|
|
assert_eq!(config.exec_policy.safe_bins.len(), 3);
|
|
assert_eq!(config.exec_policy.timeout_secs, 30);
|
|
}
|
|
|
|
#[test]
|
|
fn test_doctor_mcp_transport_validation() {
|
|
let config_toml = r#"
|
|
api_listen = "127.0.0.1:4200"
|
|
|
|
[default_model]
|
|
provider = "groq"
|
|
model = "llama-3.3-70b-versatile"
|
|
api_key_env = "GROQ_API_KEY"
|
|
|
|
[[mcp_servers]]
|
|
name = "github"
|
|
timeout_secs = 30
|
|
|
|
[mcp_servers.transport]
|
|
type = "stdio"
|
|
command = "npx"
|
|
args = ["-y", "@modelcontextprotocol/server-github"]
|
|
"#;
|
|
let config: openfang_types::config::KernelConfig = toml::from_str(config_toml).unwrap();
|
|
assert_eq!(config.mcp_servers.len(), 1);
|
|
assert_eq!(config.mcp_servers[0].name, "github");
|
|
match &config.mcp_servers[0].transport {
|
|
openfang_types::config::McpTransportEntry::Stdio { command, args } => {
|
|
assert_eq!(command, "npx");
|
|
assert_eq!(args.len(), 2);
|
|
}
|
|
_ => panic!("Expected Stdio transport"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_doctor_skill_injection_scan_clean() {
|
|
let clean_content = "This is a normal skill prompt with helpful instructions.";
|
|
let warnings = openfang_skills::verify::SkillVerifier::scan_prompt_content(clean_content);
|
|
assert!(warnings.is_empty(), "Clean content should have no warnings");
|
|
}
|
|
|
|
#[test]
|
|
fn test_doctor_hook_event_variants() {
|
|
// Verify all 4 hook event types are constructable
|
|
use openfang_types::agent::HookEvent;
|
|
let events = [
|
|
HookEvent::BeforeToolCall,
|
|
HookEvent::AfterToolCall,
|
|
HookEvent::BeforePromptBuild,
|
|
HookEvent::AgentLoopEnd,
|
|
];
|
|
assert_eq!(events.len(), 4);
|
|
}
|
|
|
|
// --- Uninstall command unit tests ---
|
|
|
|
#[test]
|
|
fn test_uninstall_path_line_filter() {
|
|
use super::is_openfang_path_line;
|
|
let dir = "/home/user/.openfang/bin";
|
|
|
|
// Should match: openfang PATH exports
|
|
assert!(is_openfang_path_line(
|
|
r#"export PATH="$HOME/.openfang/bin:$PATH""#,
|
|
dir
|
|
));
|
|
assert!(is_openfang_path_line(
|
|
r#"export PATH="/home/user/.openfang/bin:$PATH""#,
|
|
dir
|
|
));
|
|
assert!(is_openfang_path_line(
|
|
"set -gx PATH $HOME/.openfang/bin $PATH",
|
|
dir
|
|
));
|
|
assert!(is_openfang_path_line(
|
|
"fish_add_path $HOME/.openfang/bin",
|
|
dir
|
|
));
|
|
|
|
// Should NOT match: unrelated PATH exports
|
|
assert!(!is_openfang_path_line(
|
|
r#"export PATH="$HOME/.cargo/bin:$PATH""#,
|
|
dir
|
|
));
|
|
assert!(!is_openfang_path_line(
|
|
r#"export PATH="/usr/local/bin:$PATH""#,
|
|
dir
|
|
));
|
|
|
|
// Should NOT match: openfang lines that aren't PATH-related
|
|
assert!(!is_openfang_path_line("# openfang config", dir));
|
|
assert!(!is_openfang_path_line("alias of=openfang", dir));
|
|
}
|
|
} |