Files
Kigi-CLI/crates/codegen/kigi-tui/src/slash/registry.rs
T
ZacharyZhang-NY 6f31415ed6 §9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed
The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok'
crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party
license archives, README provenance, and the required 'Based on Grok
Build Open Source' attribution, now sourced from version_attribution.txt).

Wire-visible renames (both sides in this repo, changed in lockstep):
- Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode).
- Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* /
  _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps
  a read-side alias for the legacy '_x.ai/session/update' method so
  existing updates.jsonl histories load; writes emit only the new name
  (both directions test-pinned).
- Agent types grok-build* → kigi* with a documented legacy-prefix alias
  at resolution time so persisted sessions keep resolving.
- ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case
  kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build
  implementation dirs renamed to kigi*.
- x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes
  grokday/groknight → kigiday/kiginight (old persisted values fall back
  to the default theme), web_fetch allowlist xAI hosts → kimi.com +
  moonshot platforms, changelog CDN → this repo, grok-build changelog
  archives deleted.
- BYOK default endpoint removed: [endpoints] api_base_url is now truly
  optional with NO default — consumers fail fast with the flag name when
  unset (no silent x.ai egress). Mock harnesses inject it explicitly.
- System-prompt identity fixed: 'released by xAI' → 'an unofficial
  community CLI for Kimi' (template + regenerated encrypted form).

Also repaired pre-existing grok-era test debt found by the sweep: the
stale trace_classify default-model pin, the grok-pager UA label test,
pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY
test could previously reach the real api.moonshot.cn), and the outdated
oauth fixture scope key.

Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings);
FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed;
deny advisories ok.
2026-07-18 02:48:46 -04:00

1137 lines
42 KiB
Rust

//! Command registry -- maps command names/aliases to `SlashCommand` implementations.
//!
//! Design choices:
//!
//! - `String` keys throughout (not `&'static str`) for ACP command support.
//! - `CommandSource` tracks provenance (Builtin vs Acp) for replacement logic.
//! - `set_acp_commands()` replaces ACP-sourced entries without touching builtins.
//! - `rebuild_triggers()` regenerates the trigger list after mutations.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use super::acp_command::AcpSlashCommand;
use super::command::SlashCommand;
/// Source of a command in the registry. Used for precedence and replacement.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandSource {
/// Pager-local builtin (e.g., /exit, /model).
Builtin,
/// Advertised by the shell/agent via ACP AvailableCommandsUpdate.
Acp,
}
/// A trigger entry in the registry -- one per canonical name or alias.
///
/// Triggers are what the fuzzy matcher operates on. Each command produces
/// at least one trigger (canonical name), plus one per alias.
#[derive(Debug, Clone)]
pub struct CommandTrigger {
/// The canonical command name (e.g., "exit").
pub canonical: String,
/// If this trigger is an alias, the alias text. None for canonical triggers.
pub alias: Option<String>,
/// Display text for the dropdown (e.g., "/exit").
pub display: String,
/// Text used for fuzzy matching (alias text or canonical name).
pub match_text: String,
/// Command description.
pub description: String,
/// Usage string.
pub usage: String,
/// Whether this command takes arguments.
pub takes_args: bool,
/// Whether arguments are required (only meaningful when `takes_args` is true).
pub args_required: bool,
/// Index into `CommandRegistry::commands`.
pub command_index: usize,
/// Source of this command.
pub source: CommandSource,
}
impl CommandTrigger {
fn new(
command: &Arc<dyn SlashCommand>,
alias: Option<&str>,
canonical: &str,
command_index: usize,
source: CommandSource,
) -> Self {
let display = format!("/{}", alias.unwrap_or(canonical));
let match_text = alias.unwrap_or(canonical).to_string();
Self {
canonical: canonical.to_string(),
alias: alias.map(|s| s.to_string()),
display,
match_text,
description: command.description().to_string(),
usage: command.usage().to_string(),
takes_args: command.takes_args(),
args_required: command.args_required(),
command_index,
source,
}
}
}
/// Registry of all known slash commands.
///
/// Owns the command objects and provides lookup by name/alias.
/// Supports dynamic mutation via `set_acp_commands()` for runtime
/// ACP command catalog updates.
pub struct CommandRegistry {
commands: Vec<Arc<dyn SlashCommand>>,
sources: Vec<CommandSource>,
key_to_index: HashMap<String, usize>,
triggers: Vec<CommandTrigger>,
/// Commands hidden by name (not shown in dropdown, not executable).
hidden: HashSet<String>,
/// Commands hidden from the completion menu ONLY (no dropdown row, no
/// ghost / palette trigger) while staying resolvable for dispatch via
/// [`Self::get_for_dispatch`]: a fully-typed invocation still executes.
///
/// Registry-level analogue of the per-command `SlashCommand::visible()`
/// gate (the `/gboom` mechanism) for state the command object cannot
/// see. Menu-only: hidden from completion but still executable via
/// [`Self::get_for_dispatch`].
menu_hidden: HashSet<String>,
/// Commands denied for this user (e.g. tier-restricted: `/usage` on the
/// free / X Basic tiers — see
/// [`crate::app::app_view::TIER_RESTRICTED_COMMANDS`]).
///
/// Names are stored normalized (lowercase, no leading `/`) and match a
/// command's canonical name OR any of its aliases, for both builtin and
/// ACP-sourced commands.
///
/// Kept separate from `hidden` so the per-command `set_*_visible`
/// setters can never un-hide a restricted command: the deny list always
/// wins over every other visibility gate.
restricted: HashSet<String>,
/// Names of tools the connected agent has advertised.
///
/// Semantics (fail-closed):
/// - `None`: the toolset is not yet known (no session yet, or the
/// shell hasn't sent an `AvailableCommandsUpdate` carrying a tools
/// list). Commands with non-empty `required_tools()` are HIDDEN.
/// Otherwise the user could submit `/loop` from the home screen
/// and start a session whose model can't actually run it.
/// - `Some(set)`: the agent has advertised exactly this toolset.
/// Commands whose `required_tools()` are not all present in the
/// set are hidden, the same way `hidden` hides commands by name.
available_tools: Option<HashSet<String>>,
}
impl CommandRegistry {
/// Build a registry from builtin commands.
///
/// # Panics
///
/// Panics if two builtin commands share the same canonical name or alias.
pub fn new(builtins: Vec<Arc<dyn SlashCommand>>) -> Self {
let n = builtins.len();
let sources = vec![CommandSource::Builtin; n];
// Fail-closed until the matching `set_*_visible` call reveals them.
let mut hidden = HashSet::new();
hidden.insert("dashboard".to_string());
hidden.insert("recap".to_string());
// `/auto` is fail-closed: hidden until `set_auto_mode_available(true)`.
hidden.insert("auto".to_string());
let mut reg = Self {
commands: builtins,
sources,
key_to_index: HashMap::new(),
triggers: Vec::new(),
hidden,
menu_hidden: HashSet::new(),
restricted: HashSet::new(),
available_tools: None,
};
reg.rebuild_triggers();
reg
}
fn set_command_visible(&mut self, name: &str, visible: bool) {
if visible {
self.hidden.remove(name);
} else {
self.hidden.insert(name.to_string());
}
self.rebuild_triggers();
}
/// Look up a command by canonical name or alias, applying EVERY
/// visibility gate (completion-menu semantics).
/// Returns `None` for hidden commands, menu-hidden commands,
/// restricted commands, or commands whose `required_tools()` are not
/// all in the advertised toolset.
///
/// Dispatch call sites that execute a fully-typed submission must use
/// [`Self::get_for_dispatch`] instead, which ignores the menu-only
/// gate.
pub fn get(&self, key: &str) -> Option<&Arc<dyn SlashCommand>> {
self.get_for_dispatch(key)
.filter(|cmd| !self.menu_hidden.contains(cmd.name()))
}
/// Look up a command by canonical name or alias for EXECUTION of a
/// typed invocation, ignoring the menu-only gate (`menu_hidden`).
///
/// Still returns `None` for hard-hidden commands (feature gates like
/// `/dashboard`, or `/auto` when the auto permission-mode
/// feature is unavailable — those must stay fail-closed), restricted
/// commands, and commands whose `required_tools()` are not all in the
/// advertised toolset.
///
/// Rationale: `menu_hidden` means "don't OFFER this in completion",
/// not "this command doesn't exist". A fully-typed submission must still
/// reach the pager's own handler rather than fall through as an unknown
/// command (the shell may advertise a same-named command with different
/// semantics).
pub fn get_for_dispatch(&self, key: &str) -> Option<&Arc<dyn SlashCommand>> {
self.key_to_index
.get(key)
.and_then(|idx| self.commands.get(*idx))
.filter(|cmd| !self.hidden.contains(cmd.name()))
.filter(|cmd| !self.restricted_match(cmd))
.filter(|cmd| self.tools_satisfied(cmd))
}
/// Normalize a deny-list entry: trim, strip one leading `/`, lowercase.
/// Lets callers write `usage`, `/usage`, or `Usage` interchangeably.
fn normalize_deny_name(name: &str) -> String {
name.trim().trim_start_matches('/').to_lowercase()
}
/// True when the command's canonical name or any alias is on the
/// deny list.
fn restricted_match(&self, cmd: &Arc<dyn SlashCommand>) -> bool {
if self.restricted.is_empty() {
return false;
}
self.restricted.contains(&cmd.name().to_lowercase())
|| cmd
.aliases()
.iter()
.any(|a| self.restricted.contains(&a.to_lowercase()))
}
/// Replace the restricted-command deny list (e.g. tier restrictions).
///
/// Entries are normalized via [`Self::normalize_deny_name`]. Restricted
/// commands stay visible in the dropdown/completion (discoverability)
/// but disappear from `get()` — invoking one shows the subscription upsell
/// instead of executing (see the `dispatch_send_prompt_inner` hook).
/// Pass an empty slice to clear the deny list (e.g. after a tier
/// upgrade mid-session).
pub fn set_restricted_commands(&mut self, names: &[String]) {
self.restricted = names
.iter()
.map(|n| Self::normalize_deny_name(n))
.filter(|n| !n.is_empty())
.collect();
self.rebuild_triggers();
}
/// True when `key` (canonical name or alias, `/` and case ignored)
/// names a command the tier deny list blocks from [`Self::get`]. Lets
/// the dispatcher distinguish a restricted invocation (upsell) from a
/// genuinely unknown one (pass through to the shell/model).
///
/// Deliberately scans `commands` instead of `key_to_index`: a
/// restricted command can still be missing from the key map for
/// *other* reasons (`tools_satisfied` drops tool-gated commands until
/// the toolset handshake lands), and a typed invocation must upsell
/// even then.
pub fn is_restricted(&self, key: &str) -> bool {
if self.restricted.is_empty() {
return false;
}
let key = Self::normalize_deny_name(key);
self.commands
.iter()
.filter(|cmd| self.restricted_match(cmd))
.any(|cmd| {
cmd.name().to_lowercase() == key
|| cmd.aliases().iter().any(|a| a.to_lowercase() == key)
})
}
/// Current deny list (normalized). Used to mirror the gate onto child
/// registries (subagent views), same as the `set_*_visible` gates.
pub fn restricted_commands(&self) -> Vec<String> {
let mut names: Vec<String> = self.restricted.iter().cloned().collect();
names.sort();
names
}
/// True when `cmd.required_tools()` is empty, or the toolset is
/// known and every required tool is in the advertised set.
///
/// When `available_tools == None` (pre-session bootstrap), commands
/// with non-empty `required_tools()` are hidden -- see field doc.
fn tools_satisfied(&self, cmd: &Arc<dyn SlashCommand>) -> bool {
let required = cmd.required_tools();
if required.is_empty() {
return true;
}
match &self.available_tools {
None => false,
Some(set) => required.iter().all(|t| set.contains(*t)),
}
}
/// Returns true if the command (by canonical name or alias) is a builtin.
pub fn is_builtin(&self, key: &str) -> bool {
self.key_to_index
.get(key)
.and_then(|idx| self.sources.get(*idx))
.is_some_and(|s| *s == CommandSource::Builtin)
}
/// All triggers (for fuzzy matching).
pub fn triggers(&self) -> &[CommandTrigger] {
&self.triggers
}
/// Look up a command by its `triggers()` index. Used by the slash
/// controller to resolve a `CommandTrigger.command_index` back to
/// the underlying `SlashCommand` for visibility filtering.
pub fn commands_by_index(&self, index: usize) -> Option<&Arc<dyn SlashCommand>> {
self.commands.get(index)
}
/// Number of unique commands (not triggers).
pub fn command_count(&self) -> usize {
self.commands.len()
}
/// Show or hide the /hooks and /plugins commands.
/// When hidden, they won't appear in the dropdown or be executable.
pub fn set_plugins_visible(&mut self, visible: bool) {
let names = ["hooks", "plugins"];
if visible {
for name in &names {
self.hidden.remove(*name);
}
} else {
for name in &names {
self.hidden.insert((*name).to_string());
}
}
self.rebuild_triggers();
}
/// Update the set of tool names the agent has registered.
///
/// Called by the ACP plumbing whenever the shell advertises a new
/// toolset (typically via `AvailableCommandsUpdate.meta.tools`).
/// Commands whose `required_tools()` aren't all in `tools` are
/// hidden from the dropdown and `get()`. Pass an empty set to
/// hide every tool-gated command.
///
/// API note: once `Some` has been set this method only replaces
/// the set -- it cannot transition the registry back to the
/// `None` "tool list unknown, show everything" bootstrap state.
/// In practice the drain pipeline never delivers a clear; older
/// shells that drop `meta.tools` mid-session will see stale
/// gating until the next update with a tools list arrives. If a
/// real clear path is needed, change the signature to
/// `Option<HashSet<String>>` and rewire `sync_acp_commands`.
///
/// Triggers a full `rebuild_triggers()`. Prefer `set_acp_state`
/// when also updating ACP commands so both mutations share one
/// rebuild.
pub fn set_available_tools(&mut self, tools: HashSet<String>) {
self.apply_available_tools(tools);
self.rebuild_triggers();
}
fn apply_available_tools(&mut self, tools: HashSet<String>) {
self.available_tools = Some(tools);
}
/// Show or hide the /usage command.
/// When hidden, it won't appear in the dropdown or be executable.
pub fn set_usage_visible(&mut self, visible: bool) {
self.set_command_visible("usage", visible);
}
/// Show or hide the `/dashboard` command (feature-flag gating).
///
/// The command is hidden by default (see [`Self::new`]) and revealed here
/// when the dashboard feature flag (`dashboard_enabled()`) is on. When
/// hidden it won't appear in the dropdown or be executable.
pub fn set_dashboard_visible(&mut self, visible: bool) {
self.set_command_visible("dashboard", visible);
}
/// Show or hide the `/recap` command (shell `sessionRecap` gate).
/// Hidden by default in [`Self::new`]; revealed from initialize meta.
pub fn set_recap_visible(&mut self, visible: bool) {
self.set_command_visible("recap", visible);
}
/// Gate `/auto` on the auto permission-mode feature.
///
/// When `available` is false, `/auto` is hard-hidden (fail-closed: neither
/// offered nor executable). `/always-approve` is always offered — both
/// commands are true toggles and stay on the menu while already active.
pub fn set_auto_mode_available(&mut self, available: bool) {
if available {
self.hidden.remove("auto");
} else {
self.hidden.insert("auto".to_string());
}
self.rebuild_triggers();
}
/// Test-only: put `name` in (or out of) the menu-only hide set so unit
/// tests can cover [`Self::get`] vs [`Self::get_for_dispatch`].
#[cfg(test)]
pub(crate) fn set_menu_hidden_for_test(&mut self, name: &str, hidden: bool) {
if hidden {
self.menu_hidden.insert(name.to_string());
} else {
self.menu_hidden.remove(name);
}
self.rebuild_triggers();
}
/// Apply both ACP-sourced commands and the agent's tool list in one
/// shot, then rebuild triggers exactly once.
///
/// `tools = None` means the new payload didn't carry tool info --
/// keep the previous `available_tools` value. `tools = Some(set)`
/// replaces the gated set. This is the preferred entry point from
/// the per-tick ACP sync; calling `set_acp_commands` and
/// `set_available_tools` separately is equivalent but causes two
/// `rebuild_triggers()` per generation bump.
pub fn set_acp_state(
&mut self,
commands: &[agent_client_protocol::AvailableCommand],
tools: Option<HashSet<String>>,
) {
self.apply_acp_commands(commands);
if let Some(tools) = tools {
self.apply_available_tools(tools);
}
self.rebuild_triggers();
}
/// Replace all ACP-sourced commands with a new set.
///
/// Builtin commands are preserved. ACP commands whose name collides
/// with any builtin trigger key (canonical name or alias) are silently
/// skipped.
///
/// Triggers a full `rebuild_triggers()`. Prefer `set_acp_state`
/// when also updating the agent's tool list so both mutations
/// share one rebuild.
pub fn set_acp_commands(&mut self, commands: &[agent_client_protocol::AvailableCommand]) {
self.apply_acp_commands(commands);
self.rebuild_triggers();
}
fn apply_acp_commands(&mut self, commands: &[agent_client_protocol::AvailableCommand]) {
// Remove old ACP-sourced commands.
let mut i = 0;
while i < self.commands.len() {
if self.sources[i] == CommandSource::Acp {
self.commands.remove(i);
self.sources.remove(i);
} else {
i += 1;
}
}
// Build the set of all reserved builtin keys (canonical names + aliases).
let builtin_keys: HashSet<String> = self
.commands
.iter()
.enumerate()
.filter(|(j, _)| self.sources[*j] == CommandSource::Builtin)
.flat_map(|(_, c)| {
std::iter::once(c.name().to_lowercase())
.chain(c.aliases().iter().map(|a| a.to_lowercase()))
})
.collect();
// Names that should never appear as slash commands in pager,
// even if the shell advertises them.
const BLOCKED_NAMES: &[&str] = &[
"help",
// Block individual hook/plugin shell commands — the pager's
// /hooks and /plugins builtins provide a unified modal instead.
"hooks-list",
"hooks-trust",
"hooks-untrust",
"hooks-add",
"hooks-remove",
"reload-plugins",
];
// Add new ACP commands, skipping collisions with builtins and blocked names.
for acp_cmd in commands {
let name_lower = acp_cmd.name.to_lowercase();
if builtin_keys.contains(&name_lower) {
continue;
}
if BLOCKED_NAMES
.iter()
.any(|b| b.eq_ignore_ascii_case(&name_lower))
{
continue;
}
self.commands.push(Arc::new(AcpSlashCommand::from(acp_cmd)));
self.sources.push(CommandSource::Acp);
}
}
/// Regenerate trigger list and key-to-index map from the current commands.
///
/// Called after any mutation (construction, ACP sync).
///
/// # Panics
///
/// Panics if two builtin commands share an alias (programmer error).
fn rebuild_triggers(&mut self) {
self.key_to_index.clear();
self.triggers.clear();
for (idx, command) in self.commands.iter().enumerate() {
let source = self.sources[idx];
let canonical = command.name();
// Skip commands gated by missing tools, using the same
// skip pattern as `hidden`.
if !self.tools_satisfied(command) {
continue;
}
// Skip hidden commands — they don't get triggers or key_to_index entries.
if self.hidden.contains(canonical) {
continue;
}
// Menu-hidden commands keep their key entries (so
// `get_for_dispatch()` resolves a typed invocation) but emit no
// triggers — the inverse of the restricted trade-off below.
let menu_only = self.menu_hidden.contains(canonical);
// Restricted commands (per-user deny list, e.g. tier
// restrictions) deliberately stay listed: they keep their
// triggers/key entries so the dropdown, ghost completion, and
// palette show them like any other command (discoverability).
// Execution is blocked by `get()`'s `restricted_match` filter —
// invoking one shows the subscription upsell instead.
// Insert canonical key.
self.key_to_index.insert(canonical.to_string(), idx);
if !menu_only {
self.triggers
.push(CommandTrigger::new(command, None, canonical, idx, source));
}
// Insert alias keys.
for alias in command.aliases() {
if source == CommandSource::Builtin && self.key_to_index.contains_key(*alias) {
panic!(
"slash command alias '{}' is already registered (builtin collision)",
alias
);
}
self.key_to_index.insert(alias.to_string(), idx);
if !menu_only {
self.triggers.push(CommandTrigger::new(
command,
Some(alias),
canonical,
idx,
source,
));
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::slash::command::{CommandExecCtx, CommandResult};
struct DummyCommand {
name: &'static str,
aliases: &'static [&'static str],
}
impl SlashCommand for DummyCommand {
fn name(&self) -> &str {
self.name
}
fn aliases(&self) -> &[&str] {
self.aliases
}
fn description(&self) -> &str {
"dummy"
}
fn usage(&self) -> &str {
self.name
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
CommandResult::Handled
}
}
struct ToolGatedCommand {
name: &'static str,
required: &'static [&'static str],
}
impl SlashCommand for ToolGatedCommand {
fn name(&self) -> &str {
self.name
}
fn description(&self) -> &str {
"tool-gated"
}
fn usage(&self) -> &str {
self.name
}
fn required_tools(&self) -> &[&str] {
self.required
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
CommandResult::Handled
}
}
fn tool_set<I, S>(names: I) -> HashSet<String>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
names.into_iter().map(Into::into).collect()
}
#[test]
fn lookup_by_canonical_name() {
let cmd: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "test",
aliases: &[],
});
let registry = CommandRegistry::new(vec![cmd]);
assert!(registry.get("test").is_some());
assert!(registry.get("unknown").is_none());
}
#[test]
fn lookup_by_alias() {
let cmd: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "exit",
aliases: &["quit"],
});
let registry = CommandRegistry::new(vec![cmd]);
assert!(registry.get("exit").is_some());
assert!(registry.get("quit").is_some());
// Both resolve to the same command.
assert!(std::ptr::eq(
registry.get("exit").unwrap().as_ref() as *const dyn SlashCommand,
registry.get("quit").unwrap().as_ref() as *const dyn SlashCommand,
));
}
#[test]
#[should_panic(expected = "alias")]
fn registry_panics_on_builtin_alias_collision() {
let cmd_a: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "alpha",
aliases: &["dup"],
});
let cmd_b: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "beta",
aliases: &["dup"],
});
let _ = CommandRegistry::new(vec![cmd_a, cmd_b]);
}
#[test]
fn trigger_count_includes_aliases() {
let cmd: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "exit",
aliases: &["quit", "q"],
});
let registry = CommandRegistry::new(vec![cmd]);
// 1 canonical + 2 aliases = 3 triggers.
assert_eq!(registry.triggers().len(), 3);
assert_eq!(registry.command_count(), 1);
}
#[test]
fn set_acp_commands_replaces_only_acp() {
let builtin: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "exit",
aliases: &["quit"],
});
let mut registry = CommandRegistry::new(vec![builtin]);
assert_eq!(registry.command_count(), 1);
// Add ACP commands.
let acp_cmds = vec![agent_client_protocol::AvailableCommand::new(
"flush".to_string(),
"Flush memory".to_string(),
)];
registry.set_acp_commands(&acp_cmds);
assert_eq!(registry.command_count(), 2);
assert!(registry.get("flush").is_some());
// Replace ACP commands -- flush should be gone, builtin stays.
registry.set_acp_commands(&[]);
assert_eq!(registry.command_count(), 1);
assert!(registry.get("exit").is_some());
assert!(registry.get("flush").is_none());
}
#[test]
fn set_usage_visible_hides_and_restores_usage_command() {
let usage: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "usage",
aliases: &[],
});
let other: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "exit",
aliases: &[],
});
let mut registry = CommandRegistry::new(vec![usage, other]);
// Default: /usage is visible.
assert!(registry.get("usage").is_some());
assert!(registry.triggers().iter().any(|t| t.canonical == "usage"));
// Hiding /usage removes it from lookup and triggers.
registry.set_usage_visible(false);
assert!(registry.get("usage").is_none());
assert!(!registry.triggers().iter().any(|t| t.canonical == "usage"));
// Other commands are unaffected.
assert!(registry.get("exit").is_some());
// Re-enabling restores it.
registry.set_usage_visible(true);
assert!(registry.get("usage").is_some());
assert!(registry.triggers().iter().any(|t| t.canonical == "usage"));
}
#[test]
fn restricted_commands_hide_and_restore() {
let usage: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "usage",
aliases: &[],
});
let other: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "exit",
aliases: &[],
});
let mut registry = CommandRegistry::new(vec![usage, other]);
assert!(registry.get("usage").is_some());
registry.set_restricted_commands(&["usage".to_string()]);
// Execution is blocked …
assert!(registry.get("usage").is_none());
// … but the command stays listed (dropdown/completion
// discoverability — invoking shows the upsell instead).
assert!(registry.triggers().iter().any(|t| t.canonical == "usage"));
// Other commands unaffected.
assert!(registry.get("exit").is_some());
assert_eq!(registry.restricted_commands(), vec!["usage"]);
// Clearing the deny list restores execution.
registry.set_restricted_commands(&[]);
assert!(registry.get("usage").is_some());
assert!(registry.restricted_commands().is_empty());
}
#[test]
fn restricted_entries_are_normalized() {
let usage: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "usage",
aliases: &[],
});
let mut registry = CommandRegistry::new(vec![usage]);
// Leading slash, whitespace, and case are all tolerated; empty
// entries are dropped rather than denying the "" name.
registry.set_restricted_commands(&[" /Usage ".to_string(), String::new(), "/".to_string()]);
assert!(registry.get("usage").is_none());
assert_eq!(registry.restricted_commands(), vec!["usage"]);
}
/// `is_restricted` scans the command list (not `key_to_index`, which
/// can be missing tool-gated commands pre-handshake), resolves
/// aliases, and never matches unknown names.
#[test]
fn is_restricted_resolves_names_and_aliases() {
let usage: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "usage",
aliases: &["cost"],
});
let mut registry = CommandRegistry::new(vec![usage]);
assert!(!registry.is_restricted("usage"), "empty deny list");
registry.set_restricted_commands(&["usage".to_string()]);
assert!(registry.get("usage").is_none(), "hidden from get()");
assert!(registry.is_restricted("usage"));
assert!(registry.is_restricted("cost"), "alias resolves");
assert!(registry.is_restricted("/Usage"), "normalized lookup");
assert!(!registry.is_restricted("frobnicate"), "unknown name");
}
#[test]
fn restricted_matches_aliases_both_ways() {
let cmd: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "exit",
aliases: &["quit"],
});
let mut registry = CommandRegistry::new(vec![cmd]);
// Denying an alias hides the command entirely (canonical too).
registry.set_restricted_commands(&["quit".to_string()]);
assert!(registry.get("exit").is_none());
assert!(registry.get("quit").is_none());
// Denying the canonical name also hides alias lookups.
registry.set_restricted_commands(&["exit".to_string()]);
assert!(registry.get("exit").is_none());
assert!(registry.get("quit").is_none());
}
#[test]
fn restricted_wins_over_visible_setters() {
let usage: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "usage",
aliases: &[],
});
let mut registry = CommandRegistry::new(vec![usage]);
registry.set_restricted_commands(&["usage".to_string()]);
// A later `set_usage_visible(true)` (auth-meta path) must NOT
// resurrect a restricted command.
registry.set_usage_visible(true);
assert!(registry.get("usage").is_none());
}
#[test]
fn restricted_applies_to_acp_commands() {
let builtin: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "exit",
aliases: &[],
});
let mut registry = CommandRegistry::new(vec![builtin]);
registry.set_acp_commands(&[agent_client_protocol::AvailableCommand::new(
"flush".to_string(),
"Flush memory".to_string(),
)]);
assert!(registry.get("flush").is_some());
registry.set_restricted_commands(&["flush".to_string()]);
assert!(registry.get("flush").is_none());
// Deny list survives an ACP catalog resync.
registry.set_acp_commands(&[agent_client_protocol::AvailableCommand::new(
"flush".to_string(),
"Flush memory".to_string(),
)]);
assert!(registry.get("flush").is_none());
}
#[test]
fn dashboard_command_hidden_by_default_and_toggleable() {
let dashboard: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "dashboard",
aliases: &[],
});
let other: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "exit",
aliases: &[],
});
let mut registry = CommandRegistry::new(vec![dashboard, other]);
// Fail-closed: hidden by default (until the feature flag reveals it).
assert!(registry.get("dashboard").is_none());
assert!(
!registry
.triggers()
.iter()
.any(|t| t.canonical == "dashboard")
);
// Unrelated commands are unaffected.
assert!(registry.get("exit").is_some());
// Enabling the feature reveals it.
registry.set_dashboard_visible(true);
assert!(registry.get("dashboard").is_some());
assert!(
registry
.triggers()
.iter()
.any(|t| t.canonical == "dashboard")
);
// Hiding again removes it.
registry.set_dashboard_visible(false);
assert!(registry.get("dashboard").is_none());
}
#[test]
fn acp_command_colliding_with_builtin_alias_is_skipped() {
let builtin: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "exit",
aliases: &["quit"],
});
let mut registry = CommandRegistry::new(vec![builtin]);
// "quit" collides with the builtin alias.
let acp_cmds = vec![agent_client_protocol::AvailableCommand::new(
"quit".to_string(),
"Should be skipped".to_string(),
)];
registry.set_acp_commands(&acp_cmds);
// Still only the builtin.
assert_eq!(registry.command_count(), 1);
}
#[test]
fn command_without_required_tools_is_always_visible() {
let plain: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "exit",
aliases: &[],
});
let mut reg = CommandRegistry::new(vec![plain]);
// Default (None) -> visible.
assert!(reg.get("exit").is_some());
// Empty advertised toolset still doesn't hide a no-requirements command.
reg.set_available_tools(HashSet::new());
assert!(reg.get("exit").is_some());
assert!(reg.triggers().iter().any(|t| t.canonical == "exit"));
}
#[test]
fn tool_gated_command_hidden_when_toolset_unknown() {
let gated: Arc<dyn SlashCommand> = Arc::new(ToolGatedCommand {
name: "loop",
required: &["scheduler_create"],
});
let reg = CommandRegistry::new(vec![gated]);
// Tool list not yet known: fail-closed so the user can't submit
// /loop from the home screen and start a session whose model
// can't actually run scheduler_create.
assert!(reg.get("loop").is_none());
assert!(!reg.triggers().iter().any(|t| t.canonical == "loop"));
}
#[test]
fn tool_gated_command_hidden_when_required_tool_missing() {
let gated: Arc<dyn SlashCommand> = Arc::new(ToolGatedCommand {
name: "loop",
required: &["scheduler_create"],
});
let plain: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "exit",
aliases: &[],
});
let mut reg = CommandRegistry::new(vec![gated, plain]);
// Advertise a toolset missing `scheduler_create`.
reg.set_available_tools(tool_set(["read_file"]));
assert!(reg.get("loop").is_none());
assert!(!reg.triggers().iter().any(|t| t.canonical == "loop"));
// Plain command is unaffected.
assert!(reg.get("exit").is_some());
}
#[test]
fn tool_gated_command_reappears_after_tool_added() {
let gated: Arc<dyn SlashCommand> = Arc::new(ToolGatedCommand {
name: "loop",
required: &["scheduler_create"],
});
let mut reg = CommandRegistry::new(vec![gated]);
reg.set_available_tools(HashSet::new());
assert!(reg.get("loop").is_none());
// Add the tool -- command becomes visible again.
reg.set_available_tools(tool_set(["scheduler_create"]));
assert!(reg.get("loop").is_some());
assert!(reg.triggers().iter().any(|t| t.canonical == "loop"));
}
#[test]
fn multi_tool_command_requires_all_tools() {
let gated: Arc<dyn SlashCommand> = Arc::new(ToolGatedCommand {
name: "multi",
required: &["a", "b"],
});
let mut reg = CommandRegistry::new(vec![gated]);
// Only one of two tools present -> hidden.
reg.set_available_tools(tool_set(["a"]));
assert!(reg.get("multi").is_none());
// Both tools present -> visible.
reg.set_available_tools(tool_set(["a", "b"]));
assert!(reg.get("multi").is_some());
// Superset is fine.
reg.set_available_tools(tool_set(["a", "b", "c"]));
assert!(reg.get("multi").is_some());
}
/// Builds a registry with `always-approve` (+ a `yolo` alias to cover
/// alias key handling), `auto`, and a bystander `exit`.
fn permission_mode_registry() -> CommandRegistry {
let always_approve: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "always-approve",
aliases: &["yolo"],
});
let auto: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "auto",
aliases: &[],
});
let exit: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "exit",
aliases: &[],
});
CommandRegistry::new(vec![always_approve, auto, exit])
}
/// Menu-only hide: command disappears from `get()` / triggers but a
/// typed submission still resolves via `get_for_dispatch()` (including
/// aliases).
#[test]
fn menu_hidden_is_menu_only_and_still_dispatches() {
let mut reg = permission_mode_registry();
reg.set_auto_mode_available(true);
reg.set_menu_hidden_for_test("always-approve", true);
assert!(
reg.get("always-approve").is_none(),
"menu lookup hides the command"
);
assert!(
!reg.triggers()
.iter()
.any(|t| t.canonical == "always-approve"),
"no completion trigger while menu-hidden"
);
assert!(
reg.get_for_dispatch("always-approve").is_some(),
"typed invocation must still resolve for dispatch"
);
assert!(
reg.get_for_dispatch("yolo").is_some(),
"aliases of a menu-hidden command must still resolve for dispatch"
);
// Bystanders unaffected.
assert!(reg.get("exit").is_some());
assert!(reg.get("auto").is_some());
reg.set_menu_hidden_for_test("always-approve", false);
assert!(reg.get("always-approve").is_some());
assert!(
reg.triggers()
.iter()
.any(|t| t.canonical == "always-approve")
);
}
/// The `/auto` feature gate stays HARD (fail-closed): gated off, `/auto`
/// is neither offered nor executable — `get_for_dispatch` must NOT
/// resurrect feature-hidden commands. `/always-approve` is ungated.
#[test]
fn auto_feature_gate_blocks_dispatch_resolution() {
let mut reg = permission_mode_registry();
// Fail-closed default from `new()`: /auto starts hard-hidden.
assert!(reg.get_for_dispatch("auto").is_none());
assert!(reg.get("auto").is_none());
// Gate on: offered and dispatchable. Always-approve always was.
reg.set_auto_mode_available(true);
assert!(reg.get("auto").is_some());
assert!(reg.get_for_dispatch("auto").is_some());
assert!(reg.get("always-approve").is_some());
assert!(reg.get_for_dispatch("always-approve").is_some());
// Gate off again: /auto gone everywhere; /always-approve stays.
reg.set_auto_mode_available(false);
assert!(reg.get("auto").is_none());
assert!(reg.get_for_dispatch("auto").is_none());
assert!(!reg.triggers().iter().any(|t| t.canonical == "auto"));
assert!(reg.get("always-approve").is_some());
assert!(reg.get_for_dispatch("always-approve").is_some());
}
/// `get_for_dispatch` only bypasses the menu-only hide: hard-hidden
/// (feature-gated), tier-restricted, and tool-gated commands stay
/// unresolvable for dispatch, exactly like `get()`.
#[test]
fn get_for_dispatch_respects_hard_gates() {
// Hard-hidden by name (e.g. /dashboard default).
let dashboard: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "dashboard",
aliases: &[],
});
// Tier-restricted.
let usage: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "usage",
aliases: &[],
});
// Tool-gated (toolset unknown → fail-closed).
let gated: Arc<dyn SlashCommand> = Arc::new(ToolGatedCommand {
name: "loop",
required: &["scheduler_create"],
});
let mut reg = CommandRegistry::new(vec![dashboard, usage, gated]);
reg.set_dashboard_visible(false);
reg.set_restricted_commands(&["usage".to_string()]);
assert!(
reg.get_for_dispatch("dashboard").is_none(),
"hidden stays hard"
);
assert!(
reg.get_for_dispatch("usage").is_none(),
"restricted stays blocked (upsell path owns it)"
);
assert!(
reg.get_for_dispatch("loop").is_none(),
"tool-gated stays fail-closed pre-handshake"
);
}
#[test]
fn commands_by_index_in_range_returns_some_out_of_range_returns_none() {
let alpha: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "alpha",
aliases: &[],
});
let beta: Arc<dyn SlashCommand> = Arc::new(DummyCommand {
name: "beta",
aliases: &[],
});
let registry = CommandRegistry::new(vec![alpha, beta]);
// In-range indices resolve to the matching command.
assert_eq!(
registry.commands_by_index(0).map(|c| c.name()),
Some("alpha"),
);
assert_eq!(
registry.commands_by_index(1).map(|c| c.name()),
Some("beta"),
);
// Out-of-range returns None (boundary + far-out).
assert!(registry.commands_by_index(2).is_none());
assert!(registry.commands_by_index(usize::MAX).is_none());
}
}