mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-14 00:47:51 +00:00
command registry
This commit is contained in:
Generated
+15
-14
@@ -3959,7 +3959,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-api"
|
||||
version = "0.5.9"
|
||||
version = "0.5.10"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"async-trait",
|
||||
@@ -4001,7 +4001,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-channels"
|
||||
version = "0.5.9"
|
||||
version = "0.5.10"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"async-trait",
|
||||
@@ -4040,7 +4040,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-cli"
|
||||
version = "0.5.9"
|
||||
version = "0.5.10"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -4068,7 +4068,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-desktop"
|
||||
version = "0.5.9"
|
||||
version = "0.5.10"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"open",
|
||||
@@ -4094,7 +4094,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-extensions"
|
||||
version = "0.5.9"
|
||||
version = "0.5.10"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -4122,7 +4122,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-hands"
|
||||
version = "0.5.9"
|
||||
version = "0.5.10"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap",
|
||||
@@ -4140,7 +4140,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-kernel"
|
||||
version = "0.5.9"
|
||||
version = "0.5.10"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4179,7 +4179,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-memory"
|
||||
version = "0.5.9"
|
||||
version = "0.5.10"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4199,7 +4199,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-migrate"
|
||||
version = "0.5.9"
|
||||
version = "0.5.10"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 6.0.0",
|
||||
@@ -4218,7 +4218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-runtime"
|
||||
version = "0.5.9"
|
||||
version = "0.5.10"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -4254,7 +4254,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-skills"
|
||||
version = "0.5.9"
|
||||
version = "0.5.10"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
@@ -4277,9 +4277,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-types"
|
||||
version = "0.5.9"
|
||||
version = "0.5.10"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bitflags 2.11.0",
|
||||
"chrono",
|
||||
"dirs 6.0.0",
|
||||
"ed25519-dalek",
|
||||
@@ -4296,7 +4297,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-wire"
|
||||
version = "0.5.9"
|
||||
version = "0.5.10"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -9230,7 +9231,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.5.9"
|
||||
version = "0.5.10"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
|
||||
@@ -23,6 +23,7 @@ use openfang_runtime::kernel_handle::KernelHandle;
|
||||
use openfang_runtime::llm_driver::StreamEvent;
|
||||
use openfang_runtime::llm_errors;
|
||||
use openfang_types::agent::AgentId;
|
||||
use openfang_types::commands::{self, Surfaces};
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
@@ -853,8 +854,17 @@ async fn handle_command(
|
||||
args: &str,
|
||||
verbose: &Arc<AtomicU8>,
|
||||
) -> serde_json::Value {
|
||||
match cmd {
|
||||
"new" | "reset" => match state.kernel.reset_session(agent_id) {
|
||||
// Canonicalise through the unified command registry. This resolves aliases
|
||||
// (e.g. `reset` -> `new`) and is case-insensitive. If the command is not
|
||||
// registered on the WEB surface, fall through to the existing match so any
|
||||
// legacy/un-registered handlers still work byte-identically.
|
||||
let canonical: &str = commands::resolve(cmd)
|
||||
.filter(|def| def.surfaces.contains(Surfaces::WEB))
|
||||
.map(|def| def.name)
|
||||
.unwrap_or(cmd);
|
||||
|
||||
match canonical {
|
||||
"new" => match state.kernel.reset_session(agent_id) {
|
||||
Ok(()) => {
|
||||
serde_json::json!({"type": "command_result", "command": cmd, "message": "Session reset. Chat history cleared."})
|
||||
}
|
||||
@@ -1023,7 +1033,20 @@ async fn handle_command(
|
||||
};
|
||||
serde_json::json!({"type": "command_result", "command": cmd, "message": msg})
|
||||
}
|
||||
_ => serde_json::json!({"type": "error", "content": format!("Unknown command: {cmd}")}),
|
||||
"help" => {
|
||||
serde_json::json!({
|
||||
"type": "command_result",
|
||||
"command": cmd,
|
||||
"message": commands::render_help(Surfaces::WEB),
|
||||
})
|
||||
}
|
||||
_ => serde_json::json!({
|
||||
"type": "error",
|
||||
"content": format!(
|
||||
"Unknown command: /{cmd}\n\n{}",
|
||||
commands::render_help(Surfaces::WEB)
|
||||
),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ use dashmap::DashMap;
|
||||
use futures::StreamExt;
|
||||
use openfang_types::agent::AgentId;
|
||||
use openfang_types::approval::ApprovalRequest;
|
||||
use openfang_types::commands::{self as slash_commands, Surfaces};
|
||||
use openfang_types::config::{ChannelOverrides, DmPolicy, GroupPolicy, OutputFormat, PrefixStyle};
|
||||
use openfang_types::message::ContentBlock;
|
||||
use std::sync::Arc;
|
||||
@@ -243,6 +244,22 @@ pub trait ChannelBridgeHandle: Send + Sync {
|
||||
// Default: no tracking
|
||||
}
|
||||
|
||||
/// Send a plain text message to a specific recipient via a registered
|
||||
/// channel adapter.
|
||||
///
|
||||
/// Used by the cron multi-destination delivery engine to fan out job
|
||||
/// output across channels. `channel_type` is the adapter key (e.g.
|
||||
/// `"telegram"`, `"slack"`). Default implementation returns an error so
|
||||
/// test doubles don't accidentally claim success.
|
||||
async fn send_channel_message(
|
||||
&self,
|
||||
_channel_type: &str,
|
||||
_recipient: &str,
|
||||
_message: &str,
|
||||
) -> Result<(), String> {
|
||||
Err("send_channel_message not implemented on this bridge".to_string())
|
||||
}
|
||||
|
||||
/// Check if auto-reply is enabled and the message should trigger one.
|
||||
/// Returns Some(reply_text) if auto-reply fires, None otherwise.
|
||||
async fn check_auto_reply(&self, _agent_id: AgentId, _message: &str) -> Option<String> {
|
||||
@@ -1599,7 +1616,15 @@ async fn handle_command(
|
||||
router: &Arc<AgentRouter>,
|
||||
sender: &ChannelUser,
|
||||
) -> String {
|
||||
match name {
|
||||
// Canonicalise through the unified command registry: aliases resolve to
|
||||
// their canonical name and matching is case-insensitive. If the command
|
||||
// is not registered on CHANNEL the original string is passed through so
|
||||
// any legacy / channel-specific names continue to work unchanged.
|
||||
let canonical: &str = slash_commands::resolve(name)
|
||||
.filter(|def| def.surfaces.contains(Surfaces::CHANNEL))
|
||||
.map(|def| def.name)
|
||||
.unwrap_or(name);
|
||||
match canonical {
|
||||
"start" => {
|
||||
let agents = handle.list_agents().await.unwrap_or_default();
|
||||
let mut msg = "Welcome to OpenFang! I connect you to AI agents.\n\nAvailable agents:\n"
|
||||
@@ -1829,7 +1854,10 @@ async fn handle_command(
|
||||
"peers" => handle.peers_text().await,
|
||||
"a2a" => handle.a2a_agents_text().await,
|
||||
|
||||
_ => format!("Unknown command: /{name}"),
|
||||
_ => format!(
|
||||
"Unknown command: /{name}\n\n{}",
|
||||
slash_commands::render_help(Surfaces::CHANNEL)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ use event::{AppEvent, BackendRef};
|
||||
use openfang_kernel::OpenFangKernel;
|
||||
use openfang_runtime::llm_driver::StreamEvent;
|
||||
use openfang_types::agent::AgentId;
|
||||
use openfang_types::commands::{self, Surfaces};
|
||||
use screens::{
|
||||
agents, audit, channels, chat, comms, dashboard, extensions, hands, logs, memory, peers,
|
||||
security, sessions, settings, skills, templates, triggers, usage, welcome, wizard, workflows,
|
||||
@@ -2003,22 +2004,19 @@ impl App {
|
||||
|
||||
fn handle_slash_command(&mut self, cmd: &str) {
|
||||
let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
|
||||
match parts[0] {
|
||||
"/exit" | "/quit" => self.handle_chat_action(chat::ChatAction::Back),
|
||||
// Canonicalise through the unified command registry: `/quit` -> `exit`,
|
||||
// `/NEW` -> `new`, etc. Unregistered commands fall through unchanged so
|
||||
// this refactor does not break any existing surface-specific behaviour.
|
||||
let canonical_head: String = commands::resolve(parts[0])
|
||||
.filter(|def| def.surfaces.contains(Surfaces::CLI))
|
||||
.map(|def| format!("/{}", def.name))
|
||||
.unwrap_or_else(|| parts[0].to_string());
|
||||
match canonical_head.as_str() {
|
||||
"/exit" => self.handle_chat_action(chat::ChatAction::Back),
|
||||
"/help" => {
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
[
|
||||
"/help \u{2014} show this help",
|
||||
"/model \u{2014} open model picker (Ctrl+M)",
|
||||
"/model <name> \u{2014} switch to model directly",
|
||||
"/status \u{2014} connection & agent info",
|
||||
"/agents \u{2014} list running agents",
|
||||
"/clear \u{2014} clear chat history",
|
||||
"/kill \u{2014} kill the current agent",
|
||||
"/exit \u{2014} end chat session",
|
||||
]
|
||||
.join("\n"),
|
||||
commands::render_help(Surfaces::CLI),
|
||||
);
|
||||
}
|
||||
"/status" => {
|
||||
@@ -2185,9 +2183,10 @@ impl App {
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
let help = commands::render_help(Surfaces::CLI);
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Unknown command: {}. Type /help", parts[0]),
|
||||
format!("Unknown command: {}\n\n{}", parts[0], help),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ ed25519-dalek = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
bitflags = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
rmp-serde = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,679 @@
|
||||
//! Unified slash command registry.
|
||||
//!
|
||||
//! This module is the single source of truth for every slash command that can be
|
||||
//! dispatched across CLI, channel adapters (Telegram/Slack/etc.), and the web
|
||||
//! chat (WebSocket).
|
||||
//!
|
||||
//! Each dispatch site (there are three: `openfang-cli/src/tui/mod.rs`,
|
||||
//! `openfang-channels/src/bridge.rs`, `openfang-api/src/ws.rs`) retains its own
|
||||
//! handler logic. The registry is added as a front-door so command names and
|
||||
//! aliases can be canonicalised once and help / autocomplete is generated from
|
||||
//! a single list.
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```
|
||||
//! use openfang_types::commands::{self, Surfaces};
|
||||
//!
|
||||
//! let def = commands::resolve("NEW").expect("new is registered");
|
||||
//! assert_eq!(def.name, "new");
|
||||
//! assert!(def.surfaces.contains(Surfaces::CHANNEL));
|
||||
//!
|
||||
//! // Autocomplete
|
||||
//! let matches = commands::autocomplete("ne", Surfaces::CHANNEL);
|
||||
//! assert!(matches.iter().any(|m| *m == "new"));
|
||||
//!
|
||||
//! // Unknown commands surface the help text so dispatch sites can return a
|
||||
//! // helpful error message.
|
||||
//! assert!(commands::resolve("not-a-real-command").is_none());
|
||||
//! let help = commands::render_help(Surfaces::CLI);
|
||||
//! assert!(help.contains("/help"));
|
||||
//! ```
|
||||
|
||||
use bitflags::bitflags;
|
||||
use serde::Serialize;
|
||||
|
||||
/// Command category used for help grouping.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize)]
|
||||
pub enum CommandCategory {
|
||||
/// Greet/welcome/help commands.
|
||||
General,
|
||||
/// Session-level commands (reset history, compact, switch model, cancel run, etc.).
|
||||
Session,
|
||||
/// Model selection & provider info.
|
||||
Model,
|
||||
/// Memory / storage commands.
|
||||
Memory,
|
||||
/// Control-plane commands (kill agent, spawn, stop).
|
||||
Control,
|
||||
/// Read-only information (status, list agents, list skills, etc.).
|
||||
Info,
|
||||
/// Automation: workflows, triggers, schedules, approvals.
|
||||
Automation,
|
||||
/// Monitoring: budget, peers, external agents.
|
||||
Monitoring,
|
||||
}
|
||||
|
||||
impl CommandCategory {
|
||||
/// Stable human-readable label (used for help section headers).
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
CommandCategory::General => "General",
|
||||
CommandCategory::Session => "Session",
|
||||
CommandCategory::Model => "Model",
|
||||
CommandCategory::Memory => "Memory",
|
||||
CommandCategory::Control => "Control",
|
||||
CommandCategory::Info => "Info",
|
||||
CommandCategory::Automation => "Automation",
|
||||
CommandCategory::Monitoring => "Monitoring",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bitflags! {
|
||||
/// Which surfaces a command is visible / dispatchable on.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Surfaces: u8 {
|
||||
const CLI = 0b001;
|
||||
const CHANNEL = 0b010;
|
||||
const WEB = 0b100;
|
||||
const ALL = Self::CLI.bits() | Self::CHANNEL.bits() | Self::WEB.bits();
|
||||
}
|
||||
}
|
||||
|
||||
/// A single command definition.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct CommandDef {
|
||||
/// Canonical name (no leading slash).
|
||||
pub name: &'static str,
|
||||
/// Zero or more aliases resolving to the same command.
|
||||
pub aliases: &'static [&'static str],
|
||||
/// One-line description used for help rendering.
|
||||
pub description: &'static str,
|
||||
/// Grouping category.
|
||||
pub category: CommandCategory,
|
||||
/// Surfaces where the command is visible.
|
||||
pub surfaces: Surfaces,
|
||||
/// Whether the command requires an active agent in the current session.
|
||||
pub requires_agent: bool,
|
||||
}
|
||||
|
||||
/// Every slash command registered in OpenFang.
|
||||
///
|
||||
/// Keep this list in sync with the three dispatch sites:
|
||||
/// - `openfang-cli/src/tui/mod.rs::handle_slash_command`
|
||||
/// - `openfang-channels/src/bridge.rs::handle_command`
|
||||
/// - `openfang-api/src/ws.rs::handle_command`
|
||||
pub const COMMAND_REGISTRY: &[CommandDef] = &[
|
||||
// ── General ────────────────────────────────────────────────────────────
|
||||
CommandDef {
|
||||
name: "help",
|
||||
aliases: &[],
|
||||
description: "Show available commands",
|
||||
category: CommandCategory::General,
|
||||
surfaces: Surfaces::ALL,
|
||||
requires_agent: false,
|
||||
},
|
||||
CommandDef {
|
||||
name: "start",
|
||||
aliases: &[],
|
||||
description: "Show welcome message",
|
||||
category: CommandCategory::General,
|
||||
surfaces: Surfaces::CHANNEL,
|
||||
requires_agent: false,
|
||||
},
|
||||
CommandDef {
|
||||
name: "exit",
|
||||
aliases: &["quit"],
|
||||
description: "End chat session / disconnect from agent",
|
||||
category: CommandCategory::General,
|
||||
surfaces: Surfaces::CLI,
|
||||
requires_agent: false,
|
||||
},
|
||||
// ── Session ────────────────────────────────────────────────────────────
|
||||
CommandDef {
|
||||
name: "new",
|
||||
aliases: &["reset"],
|
||||
description: "Reset session (clear history)",
|
||||
category: CommandCategory::Session,
|
||||
surfaces: Surfaces::CHANNEL.union(Surfaces::WEB),
|
||||
requires_agent: true,
|
||||
},
|
||||
CommandDef {
|
||||
name: "clear",
|
||||
aliases: &[],
|
||||
description: "Clear chat display",
|
||||
category: CommandCategory::Session,
|
||||
surfaces: Surfaces::CLI,
|
||||
requires_agent: false,
|
||||
},
|
||||
CommandDef {
|
||||
name: "compact",
|
||||
aliases: &[],
|
||||
description: "Trigger LLM session compaction",
|
||||
category: CommandCategory::Session,
|
||||
surfaces: Surfaces::CHANNEL.union(Surfaces::WEB),
|
||||
requires_agent: true,
|
||||
},
|
||||
CommandDef {
|
||||
name: "stop",
|
||||
aliases: &[],
|
||||
description: "Cancel current agent run",
|
||||
category: CommandCategory::Session,
|
||||
surfaces: Surfaces::CHANNEL.union(Surfaces::WEB),
|
||||
requires_agent: true,
|
||||
},
|
||||
CommandDef {
|
||||
name: "usage",
|
||||
aliases: &[],
|
||||
description: "Show session token usage and cost",
|
||||
category: CommandCategory::Session,
|
||||
surfaces: Surfaces::CHANNEL.union(Surfaces::WEB),
|
||||
requires_agent: true,
|
||||
},
|
||||
CommandDef {
|
||||
name: "think",
|
||||
aliases: &[],
|
||||
description: "Toggle extended thinking",
|
||||
category: CommandCategory::Session,
|
||||
surfaces: Surfaces::CHANNEL,
|
||||
requires_agent: true,
|
||||
},
|
||||
CommandDef {
|
||||
name: "context",
|
||||
aliases: &[],
|
||||
description: "Show context window usage & pressure",
|
||||
category: CommandCategory::Session,
|
||||
surfaces: Surfaces::WEB,
|
||||
requires_agent: true,
|
||||
},
|
||||
CommandDef {
|
||||
name: "verbose",
|
||||
aliases: &[],
|
||||
description: "Cycle tool detail level (off|on|full)",
|
||||
category: CommandCategory::Session,
|
||||
surfaces: Surfaces::WEB,
|
||||
requires_agent: false,
|
||||
},
|
||||
CommandDef {
|
||||
name: "queue",
|
||||
aliases: &[],
|
||||
description: "Check if agent is processing",
|
||||
category: CommandCategory::Session,
|
||||
surfaces: Surfaces::WEB,
|
||||
requires_agent: true,
|
||||
},
|
||||
// ── Model ──────────────────────────────────────────────────────────────
|
||||
CommandDef {
|
||||
name: "model",
|
||||
aliases: &[],
|
||||
description: "Show or switch model",
|
||||
category: CommandCategory::Model,
|
||||
surfaces: Surfaces::ALL,
|
||||
requires_agent: true,
|
||||
},
|
||||
CommandDef {
|
||||
name: "models",
|
||||
aliases: &[],
|
||||
description: "List available AI models",
|
||||
category: CommandCategory::Model,
|
||||
surfaces: Surfaces::CHANNEL,
|
||||
requires_agent: false,
|
||||
},
|
||||
CommandDef {
|
||||
name: "providers",
|
||||
aliases: &[],
|
||||
description: "Show configured providers",
|
||||
category: CommandCategory::Model,
|
||||
surfaces: Surfaces::CHANNEL,
|
||||
requires_agent: false,
|
||||
},
|
||||
// ── Control ────────────────────────────────────────────────────────────
|
||||
CommandDef {
|
||||
name: "kill",
|
||||
aliases: &[],
|
||||
description: "Kill the current agent",
|
||||
category: CommandCategory::Control,
|
||||
surfaces: Surfaces::CLI,
|
||||
requires_agent: true,
|
||||
},
|
||||
// ── Info ───────────────────────────────────────────────────────────────
|
||||
CommandDef {
|
||||
name: "status",
|
||||
aliases: &[],
|
||||
description: "Show system/connection status",
|
||||
category: CommandCategory::Info,
|
||||
surfaces: Surfaces::CLI.union(Surfaces::CHANNEL),
|
||||
requires_agent: false,
|
||||
},
|
||||
CommandDef {
|
||||
name: "agents",
|
||||
aliases: &[],
|
||||
description: "List running agents",
|
||||
category: CommandCategory::Info,
|
||||
surfaces: Surfaces::CLI.union(Surfaces::CHANNEL),
|
||||
requires_agent: false,
|
||||
},
|
||||
CommandDef {
|
||||
name: "agent",
|
||||
aliases: &[],
|
||||
description: "Select agent (/agent <name>)",
|
||||
category: CommandCategory::Info,
|
||||
surfaces: Surfaces::CHANNEL,
|
||||
requires_agent: false,
|
||||
},
|
||||
CommandDef {
|
||||
name: "skills",
|
||||
aliases: &[],
|
||||
description: "List installed skills",
|
||||
category: CommandCategory::Info,
|
||||
surfaces: Surfaces::CHANNEL,
|
||||
requires_agent: false,
|
||||
},
|
||||
CommandDef {
|
||||
name: "hands",
|
||||
aliases: &[],
|
||||
description: "List available and active hands",
|
||||
category: CommandCategory::Info,
|
||||
surfaces: Surfaces::CLI.union(Surfaces::CHANNEL),
|
||||
requires_agent: false,
|
||||
},
|
||||
// ── Automation ─────────────────────────────────────────────────────────
|
||||
CommandDef {
|
||||
name: "workflows",
|
||||
aliases: &[],
|
||||
description: "List workflows",
|
||||
category: CommandCategory::Automation,
|
||||
surfaces: Surfaces::CHANNEL,
|
||||
requires_agent: false,
|
||||
},
|
||||
CommandDef {
|
||||
name: "workflow",
|
||||
aliases: &[],
|
||||
description: "Run workflow (/workflow run <name> [input])",
|
||||
category: CommandCategory::Automation,
|
||||
surfaces: Surfaces::CHANNEL,
|
||||
requires_agent: false,
|
||||
},
|
||||
CommandDef {
|
||||
name: "triggers",
|
||||
aliases: &[],
|
||||
description: "List event triggers",
|
||||
category: CommandCategory::Automation,
|
||||
surfaces: Surfaces::CHANNEL,
|
||||
requires_agent: false,
|
||||
},
|
||||
CommandDef {
|
||||
name: "trigger",
|
||||
aliases: &[],
|
||||
description: "Manage triggers (/trigger add|del ...)",
|
||||
category: CommandCategory::Automation,
|
||||
surfaces: Surfaces::CHANNEL,
|
||||
requires_agent: false,
|
||||
},
|
||||
CommandDef {
|
||||
name: "schedules",
|
||||
aliases: &[],
|
||||
description: "List cron jobs",
|
||||
category: CommandCategory::Automation,
|
||||
surfaces: Surfaces::CHANNEL,
|
||||
requires_agent: false,
|
||||
},
|
||||
CommandDef {
|
||||
name: "schedule",
|
||||
aliases: &[],
|
||||
description: "Manage schedules (/schedule add|del|run ...)",
|
||||
category: CommandCategory::Automation,
|
||||
surfaces: Surfaces::CHANNEL,
|
||||
requires_agent: false,
|
||||
},
|
||||
CommandDef {
|
||||
name: "approvals",
|
||||
aliases: &[],
|
||||
description: "List pending approvals",
|
||||
category: CommandCategory::Automation,
|
||||
surfaces: Surfaces::CHANNEL,
|
||||
requires_agent: false,
|
||||
},
|
||||
CommandDef {
|
||||
name: "approve",
|
||||
aliases: &[],
|
||||
description: "Approve request (/approve <id>)",
|
||||
category: CommandCategory::Automation,
|
||||
surfaces: Surfaces::CHANNEL,
|
||||
requires_agent: false,
|
||||
},
|
||||
CommandDef {
|
||||
name: "reject",
|
||||
aliases: &[],
|
||||
description: "Reject request (/reject <id>)",
|
||||
category: CommandCategory::Automation,
|
||||
surfaces: Surfaces::CHANNEL,
|
||||
requires_agent: false,
|
||||
},
|
||||
// ── Monitoring ─────────────────────────────────────────────────────────
|
||||
CommandDef {
|
||||
name: "budget",
|
||||
aliases: &[],
|
||||
description: "Show spending limits and costs",
|
||||
category: CommandCategory::Monitoring,
|
||||
surfaces: Surfaces::CHANNEL.union(Surfaces::WEB),
|
||||
requires_agent: false,
|
||||
},
|
||||
CommandDef {
|
||||
name: "peers",
|
||||
aliases: &[],
|
||||
description: "Show OFP peer network status",
|
||||
category: CommandCategory::Monitoring,
|
||||
surfaces: Surfaces::CHANNEL.union(Surfaces::WEB),
|
||||
requires_agent: false,
|
||||
},
|
||||
CommandDef {
|
||||
name: "a2a",
|
||||
aliases: &[],
|
||||
description: "List discovered external A2A agents",
|
||||
category: CommandCategory::Monitoring,
|
||||
surfaces: Surfaces::CHANNEL.union(Surfaces::WEB),
|
||||
requires_agent: false,
|
||||
},
|
||||
];
|
||||
|
||||
/// Strip a single leading `/` if present.
|
||||
#[inline]
|
||||
fn strip_leading_slash(s: &str) -> &str {
|
||||
s.strip_prefix('/').unwrap_or(s)
|
||||
}
|
||||
|
||||
/// Look up a command by its canonical name or any alias (case-insensitive).
|
||||
///
|
||||
/// A leading `/` on the input is tolerated so callers do not have to strip it.
|
||||
///
|
||||
/// Returns `None` if the name doesn't match any registered command.
|
||||
pub fn resolve(name: &str) -> Option<&'static CommandDef> {
|
||||
let needle = strip_leading_slash(name).trim();
|
||||
if needle.is_empty() {
|
||||
return None;
|
||||
}
|
||||
COMMAND_REGISTRY.iter().find(|def| {
|
||||
def.name.eq_ignore_ascii_case(needle)
|
||||
|| def
|
||||
.aliases
|
||||
.iter()
|
||||
.any(|alias| alias.eq_ignore_ascii_case(needle))
|
||||
})
|
||||
}
|
||||
|
||||
/// Iterator over every command visible on the given surface(s).
|
||||
pub fn list_for_surface(s: Surfaces) -> impl Iterator<Item = &'static CommandDef> {
|
||||
COMMAND_REGISTRY
|
||||
.iter()
|
||||
.filter(move |def| def.surfaces.intersects(s))
|
||||
}
|
||||
|
||||
/// Render a formatted help string grouped by [`CommandCategory`].
|
||||
///
|
||||
/// The output lists every command visible on `s` alongside its description and
|
||||
/// aliases. Designed to be printed verbatim into chat.
|
||||
pub fn render_help(s: Surfaces) -> String {
|
||||
// Stable category order for consistent help output.
|
||||
const CATEGORIES: &[CommandCategory] = &[
|
||||
CommandCategory::General,
|
||||
CommandCategory::Session,
|
||||
CommandCategory::Model,
|
||||
CommandCategory::Control,
|
||||
CommandCategory::Memory,
|
||||
CommandCategory::Info,
|
||||
CommandCategory::Automation,
|
||||
CommandCategory::Monitoring,
|
||||
];
|
||||
|
||||
let mut out = String::from("Available commands:");
|
||||
for category in CATEGORIES {
|
||||
let cmds: Vec<&CommandDef> = list_for_surface(s)
|
||||
.filter(|def| def.category == *category)
|
||||
.collect();
|
||||
if cmds.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push_str("\n\n");
|
||||
out.push_str(category.label());
|
||||
out.push_str(":\n");
|
||||
for def in cmds {
|
||||
out.push_str(" /");
|
||||
out.push_str(def.name);
|
||||
if !def.aliases.is_empty() {
|
||||
out.push_str(" (aliases: ");
|
||||
for (i, a) in def.aliases.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
out.push('/');
|
||||
out.push_str(a);
|
||||
}
|
||||
out.push(')');
|
||||
}
|
||||
out.push_str(" — ");
|
||||
out.push_str(def.description);
|
||||
out.push('\n');
|
||||
}
|
||||
// Trim final newline from this category block.
|
||||
out.pop();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Return every command name or alias on `s` that starts with `prefix`
|
||||
/// (case-insensitive, no leading slash).
|
||||
pub fn autocomplete(prefix: &str, s: Surfaces) -> Vec<&'static str> {
|
||||
let needle = strip_leading_slash(prefix).to_ascii_lowercase();
|
||||
let mut out: Vec<&'static str> = Vec::new();
|
||||
for def in list_for_surface(s) {
|
||||
if def.name.to_ascii_lowercase().starts_with(&needle) {
|
||||
out.push(def.name);
|
||||
}
|
||||
for alias in def.aliases {
|
||||
if alias.to_ascii_lowercase().starts_with(&needle) {
|
||||
out.push(alias);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// No command name or alias may appear twice across the registry.
|
||||
#[test]
|
||||
fn no_duplicate_names_or_aliases() {
|
||||
let mut seen: HashSet<&'static str> = HashSet::new();
|
||||
for def in COMMAND_REGISTRY {
|
||||
assert!(
|
||||
seen.insert(def.name),
|
||||
"duplicate command name in registry: /{}",
|
||||
def.name
|
||||
);
|
||||
for alias in def.aliases {
|
||||
assert!(
|
||||
seen.insert(alias),
|
||||
"duplicate command alias in registry: /{} (for /{})",
|
||||
alias,
|
||||
def.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_canonical_name() {
|
||||
let def = resolve("new").expect("`new` must resolve");
|
||||
assert_eq!(def.name, "new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_is_case_insensitive() {
|
||||
let a = resolve("new").unwrap();
|
||||
let b = resolve("NEW").unwrap();
|
||||
let c = resolve("New").unwrap();
|
||||
assert_eq!(a.name, b.name);
|
||||
assert_eq!(a.name, c.name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_tolerates_leading_slash() {
|
||||
let a = resolve("/new").unwrap();
|
||||
let b = resolve("new").unwrap();
|
||||
assert_eq!(a.name, b.name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_alias_hits_same_command() {
|
||||
// `reset` is registered as an alias of `new`.
|
||||
let via_name = resolve("new").unwrap();
|
||||
let via_alias = resolve("reset").unwrap();
|
||||
assert_eq!(via_name.name, via_alias.name);
|
||||
// And `quit` is an alias of `exit`.
|
||||
let via_alias = resolve("quit").unwrap();
|
||||
assert_eq!(via_alias.name, "exit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_unknown_returns_none() {
|
||||
assert!(resolve("definitely-not-a-command").is_none());
|
||||
assert!(resolve("").is_none());
|
||||
assert!(resolve("/").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_for_surface_channel_only_includes_channel_commands() {
|
||||
for def in list_for_surface(Surfaces::CHANNEL) {
|
||||
assert!(
|
||||
def.surfaces.contains(Surfaces::CHANNEL),
|
||||
"/{} was returned for CHANNEL but doesn't declare CHANNEL surface",
|
||||
def.name
|
||||
);
|
||||
}
|
||||
// Sanity: `start` is channel-only, `kill` is CLI-only.
|
||||
let channel_names: Vec<&'static str> = list_for_surface(Surfaces::CHANNEL)
|
||||
.map(|d| d.name)
|
||||
.collect();
|
||||
assert!(channel_names.contains(&"start"));
|
||||
assert!(!channel_names.contains(&"kill"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_for_surface_cli_includes_cli_only_commands() {
|
||||
let cli: Vec<&'static str> = list_for_surface(Surfaces::CLI).map(|d| d.name).collect();
|
||||
assert!(cli.contains(&"kill"));
|
||||
assert!(cli.contains(&"clear"));
|
||||
assert!(cli.contains(&"exit"));
|
||||
// `start` is channel-only, should not appear on CLI.
|
||||
assert!(!cli.contains(&"start"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_for_surface_web_includes_web_only_commands() {
|
||||
let web: Vec<&'static str> = list_for_surface(Surfaces::WEB).map(|d| d.name).collect();
|
||||
assert!(web.contains(&"context"));
|
||||
assert!(web.contains(&"verbose"));
|
||||
assert!(web.contains(&"queue"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_help_mentions_every_command_on_surface() {
|
||||
for surface in [Surfaces::CLI, Surfaces::CHANNEL, Surfaces::WEB] {
|
||||
let help = render_help(surface);
|
||||
for def in list_for_surface(surface) {
|
||||
let needle = format!("/{}", def.name);
|
||||
assert!(
|
||||
help.contains(&needle),
|
||||
"help for {:?} is missing `{}`",
|
||||
surface,
|
||||
needle
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_help_groups_by_category() {
|
||||
let help = render_help(Surfaces::CHANNEL);
|
||||
// At minimum we expect the general + session + info sections.
|
||||
assert!(help.contains("General:"));
|
||||
assert!(help.contains("Session:"));
|
||||
assert!(help.contains("Info:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autocomplete_prefix_matches_canonical_name() {
|
||||
let matches = autocomplete("ne", Surfaces::CHANNEL);
|
||||
assert!(
|
||||
matches.iter().any(|m| *m == "new"),
|
||||
"autocomplete(`ne`) must include `new`, got {matches:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autocomplete_prefix_matches_alias() {
|
||||
let matches = autocomplete("res", Surfaces::CHANNEL);
|
||||
assert!(
|
||||
matches.iter().any(|m| *m == "reset"),
|
||||
"autocomplete(`res`) must include `reset` (alias of new), got {matches:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autocomplete_empty_prefix_returns_all_for_surface() {
|
||||
let matches = autocomplete("", Surfaces::CLI);
|
||||
let cli_total = list_for_surface(Surfaces::CLI).count()
|
||||
+ list_for_surface(Surfaces::CLI)
|
||||
.map(|d| d.aliases.len())
|
||||
.sum::<usize>();
|
||||
assert_eq!(matches.len(), cli_total);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autocomplete_respects_surface_filter() {
|
||||
// `kill` is CLI-only; it must not appear in CHANNEL autocomplete.
|
||||
let channel_matches = autocomplete("ki", Surfaces::CHANNEL);
|
||||
assert!(!channel_matches.iter().any(|m| *m == "kill"));
|
||||
let cli_matches = autocomplete("ki", Surfaces::CLI);
|
||||
assert!(cli_matches.iter().any(|m| *m == "kill"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autocomplete_tolerates_leading_slash() {
|
||||
let a = autocomplete("/new", Surfaces::CHANNEL);
|
||||
let b = autocomplete("new", Surfaces::CHANNEL);
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
/// Every command must declare at least one surface.
|
||||
#[test]
|
||||
fn every_command_has_at_least_one_surface() {
|
||||
for def in COMMAND_REGISTRY {
|
||||
assert!(
|
||||
!def.surfaces.is_empty(),
|
||||
"/{} declares no surfaces",
|
||||
def.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Unknown-command error path rendering — doc-example-style integration check.
|
||||
#[test]
|
||||
fn unknown_command_help_rendering_is_useful() {
|
||||
// Simulated "dispatch unknown command" error path.
|
||||
let name = "zzz-not-a-command";
|
||||
let def = resolve(name);
|
||||
assert!(def.is_none());
|
||||
let help = render_help(Surfaces::CHANNEL);
|
||||
let err = format!("Unknown command: /{name}\n\n{help}");
|
||||
assert!(err.contains("Unknown command:"));
|
||||
assert!(err.contains("/help"));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
pub mod agent;
|
||||
pub mod approval;
|
||||
pub mod capability;
|
||||
pub mod commands;
|
||||
pub mod comms;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
|
||||
Reference in New Issue
Block a user