diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index 952fc932..8b0c8aa8 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -6,7 +6,6 @@ use axum::http::StatusCode; use axum::response::IntoResponse; use axum::Json; use dashmap::DashMap; -use openfang_channels::bridge::channel_command_specs; use openfang_kernel::triggers::{TriggerId, TriggerPattern}; use openfang_kernel::workflow::{ ErrorMode, StepAgent, StepMode, Workflow, WorkflowId, WorkflowStep, @@ -10746,40 +10745,82 @@ pub async fn pairing_notify( .into_response() } -/// GET /api/commands — List available chat commands (for dynamic slash menu). -pub async fn list_commands(State(state): State>) -> impl IntoResponse { - let mut commands: Vec = channel_command_specs() - .iter() - .map(|spec| { +/// GET /api/commands?surface=web|cli|channel|all — List slash commands from the +/// unified command registry, filtered by surface. +/// +/// Query params: +/// - `surface`: one of `web` (default), `cli`, `channel`, `all`. +/// +/// Returns: +/// ```json +/// { +/// "surface": "web", +/// "commands": [ +/// { +/// "name": "new", +/// "aliases": ["reset"], +/// "description": "Reset session (clear history)", +/// "category": "session", +/// "requires_agent": true +/// }, +/// ... +/// ] +/// } +/// ``` +/// +/// Unknown surface values return 400. +pub async fn list_commands(Query(params): Query) -> impl IntoResponse { + use openfang_types::commands::{self, CommandCategory, Surfaces}; + + let surface_raw = params.surface.as_deref().unwrap_or("web"); + let surface = match surface_raw.to_ascii_lowercase().as_str() { + "web" => Surfaces::WEB, + "cli" => Surfaces::CLI, + "channel" => Surfaces::CHANNEL, + "all" => Surfaces::ALL, + other => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": format!( + "Unknown surface '{other}'. Valid: web, cli, channel, all." + ), + })), + ) + .into_response(); + } + }; + + let category_slug = |c: CommandCategory| -> &'static str { + match c { + CommandCategory::General => "general", + CommandCategory::Session => "session", + CommandCategory::Model => "model", + CommandCategory::Memory => "memory", + CommandCategory::Control => "control", + CommandCategory::Info => "info", + CommandCategory::Automation => "automation", + CommandCategory::Monitoring => "monitoring", + } + }; + + let commands: Vec = commands::list_for_surface(surface) + .map(|def| { serde_json::json!({ - "cmd": format!("/{}", spec.name), - "desc": spec.desc, - "source": "channel", + "name": def.name, + "aliases": def.aliases, + "description": def.description, + "category": category_slug(def.category), + "requires_agent": def.requires_agent, }) }) .collect(); - commands.extend([ - serde_json::json!({"cmd": "/context", "desc": "Show context window usage & pressure"}), - serde_json::json!({"cmd": "/verbose", "desc": "Cycle tool detail level (/verbose [off|on|full])"}), - serde_json::json!({"cmd": "/queue", "desc": "Check if agent is processing"}), - serde_json::json!({"cmd": "/clear", "desc": "Clear chat display"}), - serde_json::json!({"cmd": "/exit", "desc": "Disconnect from agent"}), - ]); - - // Add skill-registered tool names as potential commands - if let Ok(registry) = state.kernel.skill_registry.read() { - for skill in registry.list() { - let desc: String = skill.manifest.skill.description.chars().take(80).collect(); - commands.push(serde_json::json!({ - "cmd": format!("/{}", skill.manifest.skill.name), - "desc": if desc.is_empty() { format!("Skill: {}", skill.manifest.skill.name) } else { desc }, - "source": "skill", - })); - } - } - - Json(serde_json::json!({"commands": commands})) + Json(serde_json::json!({ + "surface": surface_raw.to_ascii_lowercase(), + "commands": commands, + })) + .into_response() } /// SECURITY: Validate webhook bearer token using constant-time comparison. diff --git a/crates/openfang-api/src/types.rs b/crates/openfang-api/src/types.rs index e607c5b4..9ab6ee6e 100644 --- a/crates/openfang-api/src/types.rs +++ b/crates/openfang-api/src/types.rs @@ -107,3 +107,11 @@ pub struct ClawHubInstallRequest { /// ClawHub skill slug (e.g., "github-helper"). pub slug: String, } + +/// Query parameters for `GET /api/commands`. +#[derive(Debug, Deserialize)] +pub struct CommandsQuery { + /// Surface filter: `web` (default), `cli`, `channel`, or `all`. + #[serde(default)] + pub surface: Option, +} diff --git a/crates/openfang-api/static/js/pages/chat.js b/crates/openfang-api/static/js/pages/chat.js index 20ecdc0c..fd47a7fa 100644 --- a/crates/openfang-api/static/js/pages/chat.js +++ b/crates/openfang-api/static/js/pages/chat.js @@ -304,22 +304,38 @@ function chatPage() { this._slashCommandsLoaded = true; }, - // Fetch dynamic slash commands from server + // Fetch slash commands from the unified registry (/api/commands?surface=web). + // Replaces the hardcoded initSlashCommands() list once loaded — ensures + // the help panel and autocomplete stay in sync with the backend registry. fetchCommands: function() { var self = this; - OpenFangAPI.get('/api/commands').then(function(data) { - if (data.commands && data.commands.length) { - // Build a set of known cmds to avoid duplicates - var existing = {}; - self.slashCommands.forEach(function(c) { existing[c.cmd] = true; }); - data.commands.forEach(function(c) { - if (!existing[c.cmd]) { - self.slashCommands.push({ cmd: c.cmd, desc: c.desc || '', source: c.source || 'server' }); - existing[c.cmd] = true; - } - }); - } - }).catch(function() { /* silent — use hardcoded list */ }); + OpenFangAPI.get('/api/commands?surface=web').then(function(data) { + var cmds = (data && data.commands) || []; + if (!cmds.length) return; + self.slashCommands = cmds.map(function(c) { + // Prefer unified-registry shape { name, aliases, description, category, requires_agent }. + // Fall back to legacy { cmd, desc } shape so older shims keep working. + if (c.name) { + return { + cmd: '/' + c.name, + desc: c.description || '', + category: c.category || 'general', + aliases: c.aliases || [], + requires_agent: !!c.requires_agent, + source: 'registry' + }; + } + return { + cmd: c.cmd, + desc: c.desc || '', + category: c.category || 'general', + aliases: c.aliases || [], + requires_agent: !!c.requires_agent, + source: c.source || 'server' + }; + }); + self._slashCommandsLoaded = true; + }).catch(function() { /* silent — keep hardcoded fallback list */ }); }, get filteredSlashCommands() { @@ -330,6 +346,44 @@ function chatPage() { }); }, + // Render `/help` output grouped by category, mirroring the + // backend's render_help(Surfaces::WEB). Falls back to a flat list if + // categories are not populated (pre-fetch hardcoded list). + renderHelpText: function() { + var order = ['general', 'session', 'model', 'control', 'memory', 'info', 'automation', 'monitoring']; + var labels = { + general: 'General', session: 'Session', model: 'Model', control: 'Control', + memory: 'Memory', info: 'Info', automation: 'Automation', monitoring: 'Monitoring' + }; + var anyCategorised = this.slashCommands.some(function(c) { return c.category; }); + if (!anyCategorised) { + return this.slashCommands.map(function(c) { + return '`' + c.cmd + '` \u2014 ' + c.desc; + }).join('\n'); + } + var groups = {}; + this.slashCommands.forEach(function(c) { + var cat = c.category || 'general'; + if (!groups[cat]) groups[cat] = []; + groups[cat].push(c); + }); + var lines = ['**Available commands:**']; + order.forEach(function(cat) { + var list = groups[cat]; + if (!list || !list.length) return; + lines.push(''); + lines.push('**' + (labels[cat] || cat) + '**'); + list.forEach(function(c) { + var aliasText = ''; + if (c.aliases && c.aliases.length) { + aliasText = ' (aliases: ' + c.aliases.map(function(a) { return '/' + a; }).join(', ') + ')'; + } + lines.push('- `' + c.cmd + '`' + aliasText + ' \u2014 ' + c.desc); + }); + }); + return lines.join('\n'); + }, + // Clear any stuck typing indicator after 120s _resetTypingTimeout: function() { var self = this; @@ -355,7 +409,7 @@ function chatPage() { cmdArgs = cmdArgs || ''; switch (cmd) { case '/help': - self.messages.push({ id: ++msgId, role: 'system', text: self.slashCommands.map(function(c) { return '`' + c.cmd + '` — ' + c.desc; }).join('\n'), meta: '', tools: [] }); + self.messages.push({ id: ++msgId, role: 'system', text: self.renderHelpText(), meta: '', tools: [] }); self.scrollToBottom(); break; case '/agents': diff --git a/crates/openfang-api/tests/api_integration_test.rs b/crates/openfang-api/tests/api_integration_test.rs index b6d3b7c8..51182990 100644 --- a/crates/openfang-api/tests/api_integration_test.rs +++ b/crates/openfang-api/tests/api_integration_test.rs @@ -122,6 +122,7 @@ async fn start_test_server_with_provider( axum::routing::get(routes::list_workflow_runs), ) .route("/api/shutdown", axum::routing::post(routes::shutdown)) + .route("/api/commands", axum::routing::get(routes::list_commands)) .layer(axum::middleware::from_fn(middleware::request_logging)) .layer(TraceLayer::new_for_http()) .layer(CorsLayer::permissive()) @@ -983,3 +984,161 @@ async fn test_auth_disabled_when_no_key() { .unwrap(); assert_eq!(resp.status(), 200); } + +// --------------------------------------------------------------------------- +// /api/commands — unified command registry endpoint +// --------------------------------------------------------------------------- + +/// Default (no surface query) returns web-surface commands. +#[tokio::test] +async fn test_commands_default_returns_web() { + let server = start_test_server().await; + let client = reqwest::Client::new(); + + let resp = client + .get(format!("{}/api/commands", server.base_url)) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["surface"], "web"); + + let commands = body["commands"].as_array().expect("commands is array"); + assert!(!commands.is_empty(), "web surface should have commands"); + + // Every entry has the documented shape. + for c in commands { + assert!(c["name"].is_string()); + assert!(c["aliases"].is_array()); + assert!(c["description"].is_string()); + assert!(c["category"].is_string()); + assert!(c["requires_agent"].is_boolean()); + } + + // Sanity: web surface must include `/help` and `/verbose` and must NOT + // include CLI-only `/kill`. + let names: Vec<&str> = commands + .iter() + .map(|c| c["name"].as_str().unwrap()) + .collect(); + assert!(names.contains(&"help")); + assert!(names.contains(&"verbose")); + assert!(!names.contains(&"kill")); +} + +/// `?surface=cli` returns CLI-only commands and includes the alias array. +#[tokio::test] +async fn test_commands_cli_surface() { + let server = start_test_server().await; + let client = reqwest::Client::new(); + + let resp = client + .get(format!("{}/api/commands?surface=cli", server.base_url)) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["surface"], "cli"); + + let commands = body["commands"].as_array().unwrap(); + let names: Vec<&str> = commands + .iter() + .map(|c| c["name"].as_str().unwrap()) + .collect(); + assert!(names.contains(&"kill")); + assert!(names.contains(&"clear")); + assert!(names.contains(&"exit")); + // `start` is channel-only — must not appear on CLI. + assert!(!names.contains(&"start")); + + // `/exit` carries the `quit` alias. + let exit = commands + .iter() + .find(|c| c["name"] == "exit") + .expect("exit command must be present on CLI"); + let aliases = exit["aliases"].as_array().unwrap(); + assert!( + aliases.iter().any(|a| a == "quit"), + "quit alias should be attached to /exit" + ); +} + +/// `?surface=all` includes commands from every surface. +#[tokio::test] +async fn test_commands_all_surface() { + let server = start_test_server().await; + let client = reqwest::Client::new(); + + let resp = client + .get(format!("{}/api/commands?surface=all", server.base_url)) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["surface"], "all"); + + let names: Vec<&str> = body["commands"] + .as_array() + .unwrap() + .iter() + .map(|c| c["name"].as_str().unwrap()) + .collect(); + + // Surface-specific probes: all three unique-per-surface commands appear. + assert!(names.contains(&"kill"), "CLI-only /kill missing from /all"); + assert!( + names.contains(&"start"), + "channel-only /start missing from /all" + ); + assert!( + names.contains(&"verbose"), + "web-only /verbose missing from /all" + ); +} + +/// `?surface=channel` returns channel commands only. +#[tokio::test] +async fn test_commands_channel_surface() { + let server = start_test_server().await; + let client = reqwest::Client::new(); + + let resp = client + .get(format!("{}/api/commands?surface=channel", server.base_url)) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["surface"], "channel"); + + let names: Vec<&str> = body["commands"] + .as_array() + .unwrap() + .iter() + .map(|c| c["name"].as_str().unwrap()) + .collect(); + assert!(names.contains(&"start")); + // CLI-only must not appear here. + assert!(!names.contains(&"kill")); +} + +/// Unknown surface returns 400 with a JSON error body. +#[tokio::test] +async fn test_commands_invalid_surface_400() { + let server = start_test_server().await; + let client = reqwest::Client::new(); + + let resp = client + .get(format!("{}/api/commands?surface=bogus", server.base_url)) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 400); + let body: serde_json::Value = resp.json().await.unwrap(); + let err = body["error"].as_str().unwrap_or_default(); + assert!(err.contains("bogus"), "error should mention the bad value: {err}"); +}