返回 CodeWhale
user_registry.rs
根目录 / crates / tui / src / commands / user_registry.rs
1 //! Dedicated registry for user-defined markdown slash commands.
2 //!
3 //! This module owns the user-command boundary. Built-in command metadata and
4 //! dispatch remain in the normal command registry; user commands are loaded
5 //! from markdown files into this registry and are attempted before built-ins.
6
7 use std::collections::{HashMap, HashSet};
8 use std::path::{Path, PathBuf};
9 use std::sync::{OnceLock, RwLock};
10 use std::time::SystemTime;
11
12 use crate::tools::goal::GoalStatus;
13 use crate::tui::app::{App, AppAction};
14
15 use super::CommandResult;
16 use super::user_commands;
17
18 static USER_COMMAND_REGISTRY: OnceLock<RwLock<UserCommandRegistryState>> = OnceLock::new();
19
20 #[derive(Debug, Clone, Default)]
21 struct UserCommandRegistryState {
22 initialized: bool,
23 workspace: Option<PathBuf>,
24 command_dirs_snapshot: Vec<CommandDirSnapshot>,
25 plugin_workspace: Option<PathBuf>,
26 plugin_sources: Vec<crate::plugins::runtime::PluginComponentSource>,
27 plugin_errors: Vec<String>,
28 registry: UserCommandRegistry,
29 }
30
31 #[derive(Debug, Clone, PartialEq, Eq)]
32 struct CommandDirSnapshot {
33 path: PathBuf,
34 modified: Option<SystemTime>,
35 files: Vec<CommandFileSnapshot>,
36 }
37
38 #[derive(Debug, Clone, PartialEq, Eq)]
39 struct CommandFileSnapshot {
40 path: PathBuf,
41 modified: Option<SystemTime>,
42 len: u64,
43 }
44
45 #[derive(Debug, Clone, PartialEq, Eq)]
46 pub struct UserCommandMetadata {
47 pub name: String,
48 pub body: String,
49 pub description: Option<String>,
50 pub usage: Option<String>,
51 pub arguments: Option<String>,
52 pub argument_hint: Option<String>,
53 pub allowed_tools: Option<Vec<String>>,
54 pub pausable: bool,
55 pub aliases: Vec<String>,
56 pub hidden: bool,
57 pub plugin_authority: Option<crate::plugins::types::PluginAuthority>,
58 }
59
60 impl UserCommandMetadata {
61 /// User-facing invocation syntax. `argument-hint` remains the legacy
62 /// fallback for existing command files; `arguments` is the final fallback
63 /// when no complete `usage` string is supplied.
64 pub(crate) fn display_usage(&self) -> Option<&str> {
65 [&self.usage, &self.argument_hint, &self.arguments]
66 .into_iter()
67 .filter_map(Option::as_deref)
68 .find(|value| !value.trim().is_empty())
69 .map(str::trim)
70 }
71
72 /// Whether selecting this command should leave the composer open for
73 /// arguments. These fields describe presentation only; dispatch keeps the
74 /// existing permissive `$ARGUMENTS`/`$1` template semantics.
75 pub(crate) fn takes_arguments(&self) -> bool {
76 self.arguments
77 .as_deref()
78 .is_some_and(|value| !value.trim().is_empty())
79 // Preserve the legacy contract exactly: the presence of
80 // `argument-hint`, including an explicitly empty value, made the
81 // palette insert rather than immediately execute the command.
82 || self.argument_hint.is_some()
83 || self
84 .usage
85 .as_deref()
86 .is_some_and(|usage| usage_describes_arguments(&self.name, usage))
87 }
88 }
89
90 #[derive(Debug, Clone, PartialEq, Eq)]
91 pub struct LoadError {
92 pub path: PathBuf,
93 pub message: String,
94 }
95
96 #[derive(Debug, Clone, Default)]
97 pub struct UserCommandRegistry {
98 commands: HashMap<String, UserCommandMetadata>,
99 aliases: HashMap<String, String>,
100 load_errors: Vec<LoadError>,
101 invalid_commands: HashMap<String, String>,
102 }
103
104 impl UserCommandRegistry {
105 pub fn new() -> Self {
106 Self::default()
107 }
108
109 #[cfg(test)]
110 pub fn load(workspace: Option<&Path>) -> Self {
111 // The user_commands module is the permanent lower-level file scanning
112 // and parsing boundary; this registry owns metadata, shadowing, and
113 // dispatch. See docs/architecture/command-dispatch.md.
114 Self::load_with_sources(
115 &user_commands::commands_dirs(workspace),
116 &user_commands::workflow_dirs(workspace),
117 &[],
118 &[],
119 )
120 }
121
122 pub(crate) fn load_with_sources(
123 md_dirs: &[PathBuf],
124 workflow_dirs: &[PathBuf],
125 plugin_sources: &[crate::plugins::runtime::PluginComponentSource],
126 plugin_errors: &[String],
127 ) -> Self {
128 let mut registry = Self::load_from_paths(md_dirs);
129
130 // Saved workflows become slash commands after explicit .md commands,
131 // so a hand-written command with the same name always wins without a
132 // noisy duplicate-definition warning.
133 let mut workflow_entries: Vec<CommandSourceEntry> = Vec::new();
134 for dir in workflow_dirs {
135 for (name, content, path) in user_commands::load_workflow_commands_from_dir(dir) {
136 if registry.get(&name).is_none()
137 && !workflow_entries
138 .iter()
139 .any(|existing| existing.name == name)
140 {
141 workflow_entries.push(CommandSourceEntry::plain(name, content, path));
142 }
143 }
144 }
145 registry.load_from_entries(workflow_entries);
146 for error in plugin_errors {
147 registry.record_load_error(PathBuf::from("plugin-runtime"), error.clone());
148 }
149 let mut plugin_entries = Vec::new();
150 for source in plugin_sources {
151 for (name, content, path) in
152 user_commands::load_command_entries_from_component(&source.path)
153 {
154 plugin_entries.push(CommandSourceEntry {
155 name,
156 content,
157 path,
158 plugin_authority: Some(source.authority.clone()),
159 });
160 }
161 }
162 registry.load_from_entries(plugin_entries);
163 registry
164 }
165
166 pub(crate) fn load_from_paths(paths: &[PathBuf]) -> Self {
167 let mut loaded = Vec::new();
168 let mut seen = HashSet::new();
169 let mut registry = Self::new();
170
171 for dir in paths {
172 let mut directory_commands = user_commands::load_commands_from_dir(dir);
173 directory_commands.sort_by(|a, b| a.0.cmp(&b.0));
174 for (name, content) in directory_commands {
175 let canonical = normalize_name(&name);
176 if seen.insert(canonical.clone()) {
177 loaded.push(CommandSourceEntry::plain(
178 name,
179 content,
180 dir.join(format!("{canonical}.md")),
181 ));
182 } else {
183 registry.record_load_error(
184 dir.join(format!("{canonical}.md")),
185 format!(
186 "User command '/{canonical}' is defined more than once; using the first definition"
187 ),
188 );
189 }
190 }
191 }
192 registry.load_from_entries(loaded);
193 registry
194 }
195
196 #[cfg(test)]
197 pub fn from_loaded(commands: Vec<(String, String)>) -> Self {
198 let mut registry = Self::new();
199 let loaded = commands
200 .into_iter()
201 .map(|(name, content)| {
202 let path = PathBuf::from(format!("{}.md", normalize_name(&name)));
203 CommandSourceEntry::plain(name, content, path)
204 })
205 .collect();
206 registry.load_from_entries(loaded);
207 registry
208 }
209
210 fn load_from_entries(&mut self, commands: Vec<CommandSourceEntry>) {
211 let parsed_commands = commands
212 .into_iter()
213 .map(|entry| {
214 let (mut metadata, errors) =
215 parse_metadata(entry.name, &entry.content, &entry.path);
216 metadata.plugin_authority = entry.plugin_authority;
217 let path = entry.path;
218 (metadata, errors, path)
219 })
220 .collect::<Vec<_>>();
221 let canonical_names = parsed_commands
222 .iter()
223 .map(|(metadata, _, _)| metadata.name.clone())
224 .collect::<HashSet<_>>();
225
226 for (mut metadata, errors, path) in parsed_commands {
227 for error in &errors {
228 self.record_load_error(error.path.clone(), error.message.clone());
229 }
230
231 if self.commands.contains_key(&metadata.name) {
232 self.record_load_error(
233 path.clone(),
234 format!(
235 "User command '/{}' is defined more than once; using the first definition",
236 metadata.name
237 ),
238 );
239 continue;
240 }
241
242 // A malformed losing duplicate must not poison the valid command
243 // that already won precedence. Only the selected definition owns
244 // the dispatch-time error for its canonical name and aliases.
245 for error in errors {
246 self.invalid_commands
247 .entry(metadata.name.clone())
248 .or_insert(error.message);
249 }
250
251 let mut accepted_aliases = Vec::with_capacity(metadata.aliases.len());
252 for alias in &metadata.aliases {
253 let alias = alias.to_ascii_lowercase();
254 if canonical_names.contains(&alias) {
255 self.record_load_error(
256 path.clone(),
257 format!(
258 "User command alias '/{alias}' for '/{}' duplicates canonical user command '/{alias}'; ignoring this alias",
259 metadata.name
260 ),
261 );
262 continue;
263 }
264 if let Some(existing) = self.aliases.get(&alias) {
265 self.record_load_error(
266 path.clone(),
267 format!(
268 "User command alias '/{alias}' for '/{}' duplicates user command '/{existing}'; using the first alias definition",
269 metadata.name
270 ),
271 );
272 continue;
273 }
274 self.aliases.insert(alias.clone(), metadata.name.clone());
275 accepted_aliases.push(alias);
276 }
277 // Discovery surfaces consume metadata directly. Keep it aligned
278 // with the dispatch map so a rejected alias is never advertised
279 // by help, command palettes, or slash completion.
280 metadata.aliases = accepted_aliases;
281
282 self.commands.insert(metadata.name.clone(), metadata);
283 }
284 }
285
286 fn record_load_error(&mut self, path: PathBuf, message: String) {
287 self.load_errors.push(LoadError { path, message });
288 }
289
290 pub fn get(&self, name: &str) -> Option<&UserCommandMetadata> {
291 self.get_unchecked(name)
292 .filter(|command| plugin_command_is_current(command))
293 }
294
295 fn get_unchecked(&self, name: &str) -> Option<&UserCommandMetadata> {
296 let key = normalize_name(name);
297 self.commands.get(&key).or_else(|| {
298 self.aliases
299 .get(&key)
300 .and_then(|canonical| self.commands.get(canonical))
301 })
302 }
303
304 #[cfg(test)]
305 pub fn get_by_alias(&self, alias: &str) -> Option<&UserCommandMetadata> {
306 let key = normalize_name(alias);
307 self.aliases
308 .get(&key)
309 .and_then(|canonical| self.commands.get(canonical))
310 .filter(|command| plugin_command_is_current(command))
311 }
312
313 #[cfg(test)]
314 pub fn names(&self) -> Vec<String> {
315 let mut names: Vec<String> = self
316 .commands
317 .values()
318 .filter(|command| plugin_command_is_current(command))
319 .map(|command| command.name.clone())
320 .collect();
321 names.sort();
322 names
323 }
324
325 pub fn iter(&self) -> impl Iterator<Item = &UserCommandMetadata> {
326 self.commands
327 .values()
328 .filter(|command| plugin_command_is_current(command))
329 }
330
331 #[cfg(test)]
332 pub fn is_valid(&self) -> bool {
333 self.load_errors.is_empty()
334 }
335
336 #[cfg(test)]
337 pub fn load_errors(&self) -> &[LoadError] {
338 &self.load_errors
339 }
340
341 fn dispatch_error(&self, name: &str) -> Option<String> {
342 let key = normalize_name(name);
343 self.invalid_commands.get(&key).cloned().or_else(|| {
344 self.aliases
345 .get(&key)
346 .and_then(|canonical| self.invalid_commands.get(canonical))
347 .cloned()
348 })
349 }
350 }
351
352 fn parse_metadata(
353 name: String,
354 content: &str,
355 path: &Path,
356 ) -> (UserCommandMetadata, Vec<LoadError>) {
357 let filename_name = normalize_name(&name);
358 let (metadata, body) = user_commands::parse_frontmatter(content);
359 let mut command = UserCommandMetadata {
360 name: filename_name.clone(),
361 body: body.to_string(),
362 description: None,
363 usage: None,
364 arguments: None,
365 argument_hint: None,
366 allowed_tools: None,
367 pausable: false,
368 aliases: Vec::new(),
369 hidden: false,
370 plugin_authority: None,
371 };
372 let mut configured_name = None;
373
374 for (key, value) in metadata {
375 match key.as_str() {
376 "name" => configured_name = Some(value),
377 "description" => command.description = Some(value),
378 "usage" => command.usage = Some(value),
379 "arguments" => command.arguments = Some(value),
380 "argument-hint" => command.argument_hint = Some(value),
381 "allowed-tools" => {
382 command.allowed_tools = Some(user_commands::parse_allowed_tools(&value));
383 }
384 "pausable" => command.pausable = value.trim().eq_ignore_ascii_case("true"),
385 "aliases" | "alias" => {
386 command.aliases = value
387 .split(',')
388 .map(normalize_name)
389 .filter(|alias| !alias.is_empty())
390 .collect();
391 }
392 "hidden" => command.hidden = value.trim().eq_ignore_ascii_case("true"),
393 _ => {}
394 }
395 }
396
397 let mut errors = Vec::new();
398 if let Some(configured_name) = configured_name {
399 if let Some(normalized) = normalize_configured_name(&configured_name) {
400 command.name = normalized;
401 } else {
402 errors.push(LoadError {
403 path: path.to_path_buf(),
404 message: format!(
405 "User command '/{filename_name}' has invalid frontmatter name {configured_name:?}; expected one slash-command token"
406 ),
407 });
408 }
409 }
410 errors.extend(validate_command_content(&command.name, content, path));
411
412 (command, errors)
413 }
414
415 #[derive(Debug, Clone)]
416 struct CommandSourceEntry {
417 name: String,
418 content: String,
419 path: PathBuf,
420 plugin_authority: Option<crate::plugins::types::PluginAuthority>,
421 }
422
423 impl CommandSourceEntry {
424 fn plain(name: String, content: String, path: PathBuf) -> Self {
425 Self {
426 name,
427 content,
428 path,
429 plugin_authority: None,
430 }
431 }
432 }
433
434 fn plugin_command_is_current(command: &UserCommandMetadata) -> bool {
435 command.plugin_authority.as_ref().is_none_or(|authority| {
436 crate::plugins::registry::verify_plugin_state_authority(authority).is_ok()
437 })
438 }
439
440 fn validate_command_content(canonical: &str, content: &str, path: &Path) -> Vec<LoadError> {
441 let mut errors = Vec::new();
442 if canonical.is_empty() {
443 errors.push(LoadError {
444 path: path.to_path_buf(),
445 message: "User command has an empty command name".to_string(),
446 });
447 }
448 if content.trim().is_empty() {
449 errors.push(LoadError {
450 path: path.to_path_buf(),
451 message: format!("User command '/{canonical}' is empty"),
452 });
453 }
454
455 let Some(first_line_end) = content.find('\n') else {
456 return errors;
457 };
458 let first = content[..first_line_end].trim_end_matches('\r');
459 if !is_frontmatter_delimiter(first.trim()) {
460 return errors;
461 }
462
463 let mut saw_closing = false;
464 for raw_line in content[first_line_end + 1..].split_inclusive('\n') {
465 let line = raw_line.trim_end_matches(['\r', '\n']);
466 let trimmed = line.trim();
467 if is_frontmatter_delimiter(trimmed) {
468 saw_closing = true;
469 break;
470 }
471 if trimmed.is_empty() {
472 continue;
473 }
474 if let Some((key, _)) = line.split_once(':')
475 && !key.trim().is_empty()
476 {
477 continue;
478 }
479 errors.push(LoadError {
480 path: path.to_path_buf(),
481 message: format!(
482 "User command '/{canonical}' has invalid frontmatter line {trimmed:?}; expected key: value"
483 ),
484 });
485 break;
486 }
487
488 if !saw_closing {
489 errors.push(LoadError {
490 path: path.to_path_buf(),
491 message: format!(
492 "User command '/{canonical}' has invalid frontmatter; missing closing --- delimiter"
493 ),
494 });
495 }
496
497 errors
498 }
499
500 fn is_frontmatter_delimiter(value: &str) -> bool {
501 value.chars().all(|ch| ch == '-') && value.len() >= 3
502 }
503
504 fn normalize_name(name: &str) -> String {
505 name.trim().trim_start_matches('/').to_ascii_lowercase()
506 }
507
508 fn normalize_configured_name(name: &str) -> Option<String> {
509 let name = name.trim();
510 let name = name.strip_prefix('/').unwrap_or(name);
511 (!name.is_empty() && !name.contains('/') && !name.contains(char::is_whitespace))
512 .then(|| name.to_ascii_lowercase())
513 }
514
515 pub(crate) fn usage_describes_arguments(name: &str, usage: &str) -> bool {
516 let usage = usage.trim();
517 if usage.is_empty() {
518 return false;
519 }
520 let bare_usage = usage.trim_start_matches('/');
521 !bare_usage.eq_ignore_ascii_case(name)
522 }
523
524 fn normalize_workspace(workspace: Option<&Path>) -> Option<PathBuf> {
525 workspace.map(Path::to_path_buf)
526 }
527
528 #[cfg(test)]
529 fn command_dirs_snapshot(workspace: Option<&Path>) -> Vec<CommandDirSnapshot> {
530 command_dirs_snapshot_with_plugins(workspace, &[])
531 }
532
533 fn command_dirs_snapshot_with_plugins(
534 workspace: Option<&Path>,
535 plugin_sources: &[crate::plugins::runtime::PluginComponentSource],
536 ) -> Vec<CommandDirSnapshot> {
537 user_commands::commands_dirs(workspace)
538 .into_iter()
539 .map(|path| snapshot_dir(path, |name| name.ends_with(".md")))
540 .chain(
541 user_commands::workflow_dirs(workspace)
542 .into_iter()
543 .map(|path| {
544 snapshot_dir(path, |name| {
545 name.ends_with(user_commands::WORKFLOW_SOURCE_SUFFIX)
546 })
547 }),
548 )
549 .chain(
550 plugin_sources
551 .iter()
552 .map(|source| snapshot_dir(source.path.clone(), |name| name.ends_with(".md"))),
553 )
554 .collect()
555 }
556
557 fn snapshot_dir(path: PathBuf, matches: impl Fn(&str) -> bool) -> CommandDirSnapshot {
558 let modified = std::fs::metadata(&path)
559 .and_then(|metadata| metadata.modified())
560 .ok();
561 let mut files = Vec::new();
562 if let Ok(entries) = std::fs::read_dir(&path) {
563 for entry in entries.flatten() {
564 let file_path = entry.path();
565 let Some(file_name) = file_path.file_name().and_then(|name| name.to_str()) else {
566 continue;
567 };
568 if !matches(file_name) {
569 continue;
570 }
571 let Ok(metadata) = entry.metadata() else {
572 continue;
573 };
574 files.push(CommandFileSnapshot {
575 path: file_path,
576 modified: metadata.modified().ok(),
577 len: metadata.len(),
578 });
579 }
580 }
581 files.sort_by(|a, b| a.path.cmp(&b.path));
582 CommandDirSnapshot {
583 path,
584 modified,
585 files,
586 }
587 }
588
589 fn registry_lock() -> &'static RwLock<UserCommandRegistryState> {
590 USER_COMMAND_REGISTRY.get_or_init(|| RwLock::new(UserCommandRegistryState::default()))
591 }
592
593 fn registry_needs_reload(
594 guard: &UserCommandRegistryState,
595 workspace: &Option<PathBuf>,
596 snapshot: &[CommandDirSnapshot],
597 ) -> bool {
598 !guard.initialized || guard.workspace != *workspace || guard.command_dirs_snapshot != snapshot
599 }
600
601 #[cfg(test)]
602 pub fn reload(workspace: Option<&Path>) {
603 let workspace = normalize_workspace(workspace);
604 let snapshot = command_dirs_snapshot(workspace.as_deref());
605 reload_with_snapshot(workspace, snapshot);
606 }
607
608 #[cfg(test)]
609 fn reload_with_snapshot(workspace: Option<PathBuf>, snapshot: Vec<CommandDirSnapshot>) {
610 let replacement = UserCommandRegistry::load(workspace.as_deref());
611 let mut guard = registry_lock()
612 .write()
613 .expect("user command registry lock poisoned");
614 guard.initialized = true;
615 guard.workspace = workspace;
616 guard.command_dirs_snapshot = snapshot;
617 guard.registry = replacement;
618 }
619
620 #[cfg(test)]
621 pub fn current_registry() -> UserCommandRegistry {
622 registry_lock()
623 .read()
624 .expect("user command registry lock poisoned")
625 .registry
626 .clone()
627 }
628
629 #[cfg(test)]
630 pub fn registry_for_workspace(workspace: Option<&Path>) -> UserCommandRegistry {
631 with_registry_for_workspace(workspace, Clone::clone)
632 }
633
634 pub fn with_registry_for_workspace<R>(
635 workspace: Option<&Path>,
636 f: impl FnOnce(&UserCommandRegistry) -> R,
637 ) -> R {
638 let workspace = normalize_workspace(workspace);
639 let lock = registry_lock();
640 let (plugin_sources, plugin_errors) = {
641 let guard = lock.read().expect("user command registry lock poisoned");
642 if guard.plugin_workspace == workspace {
643 (guard.plugin_sources.clone(), guard.plugin_errors.clone())
644 } else {
645 (Vec::new(), Vec::new())
646 }
647 };
648 let snapshot = command_dirs_snapshot_with_plugins(workspace.as_deref(), &plugin_sources);
649 {
650 let guard = lock.read().expect("user command registry lock poisoned");
651 if !registry_needs_reload(&guard, &workspace, &snapshot) {
652 return f(&guard.registry);
653 }
654 }
655
656 let replacement = UserCommandRegistry::load_with_sources(
657 &user_commands::commands_dirs(workspace.as_deref()),
658 &user_commands::workflow_dirs(workspace.as_deref()),
659 &plugin_sources,
660 &plugin_errors,
661 );
662 let mut guard = lock.write().expect("user command registry lock poisoned");
663 if registry_needs_reload(&guard, &workspace, &snapshot) {
664 guard.initialized = true;
665 guard.workspace = workspace;
666 guard.command_dirs_snapshot = snapshot;
667 guard.registry = replacement;
668 }
669 f(&guard.registry)
670 }
671
672 /// Install the current workspace's reviewed plugin command snapshot into the
673 /// existing process-global command registry. The next read rebuilds the
674 /// catalogue atomically; dispatch still revalidates authority immediately
675 /// before expanding the command body.
676 pub fn install_plugin_registry(
677 workspace: &Path,
678 plugins: &crate::plugins::PluginRegistry,
679 ) -> Vec<String> {
680 let (sources, errors) = crate::plugins::runtime::active_component_sources(
681 plugins,
682 crate::plugins::activation::PluginActivationCapability::Commands,
683 );
684 let mut guard = registry_lock()
685 .write()
686 .expect("user command registry lock poisoned");
687 guard.initialized = false;
688 guard.plugin_workspace = Some(workspace.to_path_buf());
689 guard.plugin_sources = sources;
690 guard.plugin_errors = errors.clone();
691 errors
692 }
693
694 pub fn try_dispatch(app: &mut App, input: &str) -> Option<CommandResult> {
695 let parts: Vec<&str> = input.trim().splitn(2, ' ').collect();
696 let command = normalize_name(parts.first().copied().unwrap_or_default());
697 let args = parts.get(1).copied().unwrap_or("").trim();
698
699 let (dispatch_error, metadata) =
700 with_registry_for_workspace(Some(&app.workspace), |registry| {
701 // Dispatch must see a just-revoked plugin command long enough to
702 // return a visible authority error. Discovery and palettes use
703 // `get`/`iter`, which hide it immediately.
704 let metadata = registry.get_unchecked(&command).cloned();
705 let dispatch_error = metadata
706 .as_ref()
707 .and_then(|_| registry.dispatch_error(&command));
708 (dispatch_error, metadata)
709 });
710 if let Some(error) = dispatch_error {
711 return Some(CommandResult::error(error));
712 }
713
714 let metadata = metadata?;
715 if let Some(authority) = metadata.plugin_authority.as_ref()
716 && let Err(reason) = crate::plugins::registry::verify_plugin_component_authority(
717 authority,
718 crate::plugins::activation::PluginActivationCapability::Commands,
719 )
720 {
721 return Some(CommandResult::error(format!(
722 "Plugin command '/{}' was denied: {reason}. Reload, review, trust, and enable the bundle before retrying.",
723 metadata.name
724 )));
725 }
726
727 app.goal.objective = None;
728 app.goal.started_at = None;
729 app.goal.status = GoalStatus::Active;
730 app.goal.token_budget = None;
731 app.goal.tokens_used = 0;
732 app.goal.time_used_seconds = 0;
733 app.goal.continuation_count = 0;
734 app.active_allowed_tools = None;
735 app.pausable = false;
736 app.paused = false;
737 app.paused_goal_objective = None;
738 // These command paths run on the async UI task, so the contention retry
739 // yields instead of parking a worker with `thread::sleep`. The critical
740 // sections are microsecond-scale; a still-contended lock logs below.
741 let mut todos_cleared = false;
742 for _ in 0..10 {
743 if let Ok(mut todos) = app.todos.try_lock() {
744 todos.clear();
745 todos_cleared = true;
746 break;
747 }
748 std::thread::yield_now();
749 }
750 if !todos_cleared {
751 tracing::warn!(target: "commands", "todos lock contended or poisoned — previous todos not cleared");
752 }
753
754 let mut plan_cleared = false;
755 for _ in 0..10 {
756 if let Ok(mut plan) = app.plan_state.try_lock() {
757 *plan = crate::tools::plan::PlanState::default();
758 plan_cleared = true;
759 break;
760 }
761 std::thread::yield_now();
762 }
763 if !plan_cleared {
764 tracing::warn!(target: "commands", "plan_state lock contended or poisoned — previous plan not cleared");
765 }
766
767 if let Some(description) = metadata.description.clone() {
768 app.goal.objective = Some(description);
769 app.goal.started_at = Some(std::time::Instant::now());
770 }
771 if let Some(tools) = metadata.allowed_tools.clone() {
772 app.active_allowed_tools = Some(tools);
773 }
774 app.pausable = metadata.pausable;
775
776 let message = user_commands::apply_template(&metadata.body, args);
777 Some(CommandResult::action(AppAction::SendMessage(message)))
778 }
779
780 #[cfg(test)]
781 mod tests {
782 use super::*;
783 use tempfile::TempDir;
784
785 #[test]
786 fn saved_workflows_become_arg_taking_slash_commands() {
787 let tmp = TempDir::new().expect("tempdir");
788 let workflow_dir = tmp.path().join("workflows");
789 std::fs::create_dir_all(&workflow_dir).expect("workflow dir");
790 std::fs::write(
791 workflow_dir.join("pr-review.workflow.js"),
792 "// Review a PR across dimensions and verify findings\nphase('scan');\n",
793 )
794 .expect("write workflow");
795
796 let registry = UserCommandRegistry::load_with_sources(
797 &[],
798 std::slice::from_ref(&workflow_dir),
799 &[],
800 &[],
801 );
802 let command = registry.get("pr-review").expect("workflow command");
803 assert_eq!(
804 command.description.as_deref(),
805 Some("Review a PR across dimensions and verify findings")
806 );
807 assert!(command.takes_arguments(), "workflows accept custom args");
808 assert!(
809 command.body.contains("source_path=")
810 && command.body.contains(
811 &workflow_dir
812 .join("pr-review.workflow.js")
813 .display()
814 .to_string()
815 ),
816 "body must point the workflow tool at the saved source: {}",
817 command.body
818 );
819 assert!(
820 command.body.contains("$ARGUMENTS"),
821 "slash arguments must forward into the run: {}",
822 command.body
823 );
824 }
825
826 #[test]
827 fn explicit_md_commands_shadow_same_named_workflows_quietly() {
828 let tmp = TempDir::new().expect("tempdir");
829 let md_dir = tmp.path().join("commands");
830 let workflow_dir = tmp.path().join("workflows");
831 std::fs::create_dir_all(&md_dir).expect("md dir");
832 std::fs::create_dir_all(&workflow_dir).expect("workflow dir");
833 std::fs::write(md_dir.join("triage.md"), "hand-written triage $ARGUMENTS")
834 .expect("write md command");
835 std::fs::write(workflow_dir.join("triage.workflow.js"), "phase('x');\n")
836 .expect("write workflow");
837
838 let registry = UserCommandRegistry::load_with_sources(&[md_dir], &[workflow_dir], &[], &[]);
839 let command = registry.get("triage").expect("command");
840 assert_eq!(command.body, "hand-written triage $ARGUMENTS");
841 assert!(
842 registry.load_errors().is_empty(),
843 "shadowing a workflow is silent, not a duplicate-definition warning: {:?}",
844 registry.load_errors()
845 );
846 }
847
848 #[test]
849 fn registry_loads_markdown_metadata() {
850 let registry = UserCommandRegistry::from_loaded(vec![(
851 "review".to_string(),
852 "---\ndescription: Review code\nusage: /review <file>\narguments: <file>\nargument-hint: <legacy-file>\nallowed-tools: read, grep\npausable: true\n---\nReview $ARGUMENTS".to_string(),
853 )]);
854
855 let command = registry.get("review").expect("command loaded");
856 assert_eq!(command.description.as_deref(), Some("Review code"));
857 assert_eq!(command.usage.as_deref(), Some("/review <file>"));
858 assert_eq!(command.arguments.as_deref(), Some("<file>"));
859 assert_eq!(command.argument_hint.as_deref(), Some("<legacy-file>"));
860 assert_eq!(command.display_usage(), Some("/review <file>"));
861 assert!(command.takes_arguments());
862 assert_eq!(
863 command.allowed_tools,
864 Some(vec!["read".to_string(), "grep".to_string()])
865 );
866 assert!(command.pausable);
867 assert_eq!(command.body, "Review $ARGUMENTS");
868 }
869
870 #[test]
871 fn frontmatter_name_replaces_filename_canonical_name() {
872 let registry = UserCommandRegistry::from_loaded(vec![(
873 "workflow-file".to_string(),
874 "---\nname: /Review-Target\ndescription: Review target\n---\nreview $ARGUMENTS"
875 .to_string(),
876 )]);
877
878 let command = registry.get("review-target").expect("renamed command");
879 assert_eq!(command.name, "review-target");
880 assert_eq!(command.body, "review $ARGUMENTS");
881 assert!(
882 registry.get("workflow-file").is_none(),
883 "the filename is only a default; retaining it requires an explicit alias"
884 );
885 }
886
887 #[test]
888 fn filename_remains_the_default_name_without_frontmatter_override() {
889 let registry = UserCommandRegistry::from_loaded(vec![(
890 "Filename-Default".to_string(),
891 "plain body".to_string(),
892 )]);
893
894 assert_eq!(registry.names(), vec!["filename-default"]);
895 assert_eq!(
896 registry.get("/filename-default").unwrap().body,
897 "plain body"
898 );
899 }
900
901 #[test]
902 fn registry_names_are_sorted() {
903 let registry = UserCommandRegistry::from_loaded(vec![
904 ("zeta".to_string(), "Z".to_string()),
905 ("alpha".to_string(), "A".to_string()),
906 ]);
907 assert_eq!(registry.names(), vec!["alpha", "zeta"]);
908 }
909
910 #[test]
911 fn registry_loads_from_paths_with_first_name_wins() {
912 let first = TempDir::new().unwrap();
913 let second = TempDir::new().unwrap();
914 std::fs::write(first.path().join("shadow.md"), "first").unwrap();
915 std::fs::write(second.path().join("shadow.md"), "second").unwrap();
916
917 let registry = UserCommandRegistry::load_from_paths(&[
918 first.path().to_path_buf(),
919 second.path().to_path_buf(),
920 ]);
921
922 assert_eq!(registry.get("shadow").unwrap().body, "first");
923 }
924
925 #[test]
926 fn frontmatter_name_collision_uses_directory_then_filename_precedence() {
927 let first = TempDir::new().unwrap();
928 let second = TempDir::new().unwrap();
929 std::fs::write(
930 first.path().join("z-workspace.md"),
931 "---\nname: shared\n---\nworkspace body",
932 )
933 .unwrap();
934 std::fs::write(
935 second.path().join("a-global.md"),
936 "---\nname: shared\n---\nglobal body",
937 )
938 .unwrap();
939
940 let registry = UserCommandRegistry::load_from_paths(&[
941 first.path().to_path_buf(),
942 second.path().to_path_buf(),
943 ]);
944
945 assert_eq!(registry.get("shared").unwrap().body, "workspace body");
946 assert!(registry.load_errors().iter().any(|error| {
947 error.message.contains("User command '/shared'")
948 && error.message.contains("defined more than once")
949 }));
950 }
951
952 #[test]
953 fn alias_lookup_uses_metadata_aliases() {
954 let registry = UserCommandRegistry::from_loaded(vec![(
955 "canonical".to_string(),
956 "---\naliases: short, other\n---\nBody".to_string(),
957 )]);
958 assert_eq!(registry.get_by_alias("short").unwrap().name, "canonical");
959 assert_eq!(registry.get("/other").unwrap().body, "Body");
960 }
961
962 #[test]
963 fn reload_and_current_registry_compile_sentinel() {
964 reload(None);
965 let registry = current_registry();
966 assert!(registry.is_valid());
967 }
968
969 fn write_workspace_command(workspace: &Path, name: &str, content: &str) {
970 let dir = workspace.join(".codewhale").join("commands");
971 std::fs::create_dir_all(&dir).expect("create commands dir");
972 std::fs::write(dir.join(format!("{name}.md")), content).expect("write command");
973 }
974
975 fn test_app(workspace: PathBuf) -> App {
976 let options = crate::tui::app::TuiOptions {
977 ..crate::test_support::test_tui_options(workspace)
978 };
979 App::new(options, &crate::config::Config::default())
980 }
981
982 fn sent_message(result: CommandResult) -> String {
983 match result.action {
984 Some(AppAction::SendMessage(message)) => message,
985 other => panic!("expected SendMessage action, got {other:?}"),
986 }
987 }
988
989 #[test]
990 fn plugin_command_dispatch_survives_restart_and_revocation_is_visible() {
991 let _lock = crate::test_support::lock_test_env();
992 let fixture = crate::plugins::test_fixture::DeclarativePluginFixture::new();
993 let mut app = test_app(fixture.workspace.clone());
994 install_plugin_registry(&fixture.workspace, &fixture.registry);
995
996 let result = try_dispatch(&mut app, "/plugin-hello ocean")
997 .expect("active plugin command dispatches");
998 assert!(!result.is_error);
999 assert_eq!(sent_message(result), "hello from plugin ocean");
1000
1001 let inactive = fixture.revoke_from_fresh_registry();
1002 let denied = try_dispatch(&mut app, "/plugin-hello ocean")
1003 .expect("stale command returns a visible denial");
1004 assert!(denied.is_error);
1005 assert!(
1006 denied
1007 .message
1008 .as_deref()
1009 .is_some_and(|message| message.contains("was denied")),
1010 "{denied:?}"
1011 );
1012
1013 install_plugin_registry(&fixture.workspace, &inactive);
1014 assert!(
1015 with_registry_for_workspace(Some(&fixture.workspace), |registry| {
1016 registry.get("plugin-hello").is_none()
1017 }),
1018 "a reload removes revoked plugin commands"
1019 );
1020 }
1021
1022 #[test]
1023 fn dispatch_prefers_user_command_over_builtin_with_same_name() {
1024 let tmp = TempDir::new().unwrap();
1025 write_workspace_command(tmp.path(), "help", "custom help $ARGUMENTS");
1026 let mut app = test_app(tmp.path().to_path_buf());
1027
1028 let result = crate::commands::execute("/help links", &mut app);
1029
1030 assert!(!result.is_error);
1031 assert_eq!(sent_message(result), "custom help links");
1032 }
1033
1034 #[test]
1035 fn dispatch_prefers_user_alias_over_builtin_alias() {
1036 let tmp = TempDir::new().unwrap();
1037 write_workspace_command(
1038 tmp.path(),
1039 "attach-review",
1040 "---\nalias: image\n---\ncustom alias $ARGUMENTS",
1041 );
1042 let mut app = test_app(tmp.path().to_path_buf());
1043
1044 let result = crate::commands::execute("/image screenshot.png", &mut app);
1045
1046 assert!(!result.is_error, "{:?}", result.message);
1047 assert_eq!(sent_message(result), "custom alias screenshot.png");
1048 }
1049
1050 #[test]
1051 fn hidden_user_commands_still_dispatch_directly() {
1052 let tmp = TempDir::new().unwrap();
1053 write_workspace_command(
1054 tmp.path(),
1055 "internal-workflow",
1056 "---\nname: secret\nhidden: true\ndescription: Internal workflow\n---\nsecret $ARGUMENTS",
1057 );
1058 let mut app = test_app(tmp.path().to_path_buf());
1059
1060 let result = crate::commands::execute("/secret now", &mut app);
1061
1062 assert!(!result.is_error);
1063 assert_eq!(sent_message(result), "secret now");
1064 assert_eq!(app.goal.objective.as_deref(), Some("Internal workflow"));
1065 }
1066
1067 #[test]
1068 fn dispatch_uses_frontmatter_name_arguments_and_allowed_tools() {
1069 let tmp = TempDir::new().unwrap();
1070 write_workspace_command(
1071 tmp.path(),
1072 "deploy-workflow",
1073 "---\nname: ship\nusage: /ship <target>\narguments: <target>\nallowed-tools: Read_File, Grep_Files\n---\nship $1 with $ARGUMENTS",
1074 );
1075 let mut app = test_app(tmp.path().to_path_buf());
1076
1077 let result = crate::commands::execute("/ship moon base", &mut app);
1078
1079 assert!(!result.is_error, "{:?}", result.message);
1080 assert_eq!(sent_message(result), "ship moon with moon base");
1081 assert_eq!(
1082 app.active_allowed_tools,
1083 Some(vec!["read_file".to_string(), "grep_files".to_string()])
1084 );
1085 assert!(
1086 try_dispatch(&mut app, "/deploy-workflow").is_none(),
1087 "the source filename must not remain an implicit dispatch alias"
1088 );
1089 }
1090
1091 #[test]
1092 fn empty_allowed_tools_frontmatter_blocks_all_tools() {
1093 let tmp = TempDir::new().unwrap();
1094 write_workspace_command(
1095 tmp.path(),
1096 "locked",
1097 "---\nallowed-tools: \"\"\n---\nrun nothing",
1098 );
1099 let mut app = test_app(tmp.path().to_path_buf());
1100
1101 let result = crate::commands::execute("/locked", &mut app);
1102
1103 assert!(!result.is_error);
1104 assert_eq!(app.active_allowed_tools, Some(Vec::new()));
1105 }
1106
1107 #[test]
1108 fn dispatch_clears_previous_command_state() {
1109 let tmp = TempDir::new().unwrap();
1110 write_workspace_command(tmp.path(), "plain", "plain command");
1111 let mut app = test_app(tmp.path().to_path_buf());
1112
1113 app.goal.objective = Some("old objective".to_string());
1114 app.goal.started_at = Some(std::time::Instant::now());
1115 app.goal.status = crate::tools::goal::GoalStatus::Blocked;
1116 app.goal.token_budget = Some(42);
1117 app.goal.tokens_used = 100;
1118 app.goal.time_used_seconds = 5;
1119 app.goal.continuation_count = 2;
1120 app.active_allowed_tools = Some(vec!["bash".to_string()]);
1121 app.pausable = true;
1122 app.paused = true;
1123 app.paused_goal_objective = Some("old objective".to_string());
1124 {
1125 let mut todos = app.todos.try_lock().expect("todos lock");
1126 todos.add(
1127 "leftover task".to_string(),
1128 crate::tools::todo::TodoStatus::Pending,
1129 );
1130 }
1131 {
1132 let mut plan = app.plan_state.try_lock().expect("plan_state lock");
1133 plan.update(crate::tools::plan::UpdatePlanArgs {
1134 title: Some("leftover plan".to_string()),
1135 objective: Some("old goal".to_string()),
1136 ..Default::default()
1137 });
1138 }
1139
1140 let result = crate::commands::execute("/plain", &mut app);
1141
1142 assert!(!result.is_error);
1143 assert_eq!(app.goal.objective, None);
1144 assert_eq!(app.goal.started_at, None);
1145 assert_eq!(app.goal.status, crate::tools::goal::GoalStatus::Active);
1146 assert_eq!(app.goal.token_budget, None);
1147 assert_eq!(app.goal.tokens_used, 0);
1148 assert_eq!(app.goal.time_used_seconds, 0);
1149 assert_eq!(app.goal.continuation_count, 0);
1150 assert_eq!(app.active_allowed_tools, None);
1151 assert!(!app.pausable);
1152 assert!(!app.paused);
1153 assert!(app.paused_goal_objective.is_none());
1154 assert!(
1155 app.todos
1156 .try_lock()
1157 .expect("todos lock")
1158 .snapshot()
1159 .items
1160 .is_empty(),
1161 "previous command's todos must be cleared on new command dispatch"
1162 );
1163 assert!(
1164 app.plan_state
1165 .try_lock()
1166 .expect("plan_state lock")
1167 .snapshot()
1168 .is_empty(),
1169 "previous command's plan must be cleared on new command dispatch"
1170 );
1171 }
1172
1173 #[test]
1174 fn duplicate_user_alias_keeps_first_command_and_records_user_command_error() {
1175 let registry = UserCommandRegistry::from_loaded(vec![
1176 (
1177 "first".to_string(),
1178 "---\nalias: shared\n---\nfirst body".to_string(),
1179 ),
1180 (
1181 "second".to_string(),
1182 "---\nalias: shared\n---\nsecond body".to_string(),
1183 ),
1184 ]);
1185
1186 let command = registry.get("shared").expect("alias resolves");
1187 assert_eq!(command.name, "first");
1188 assert_eq!(command.body, "first body");
1189 assert_eq!(command.aliases, ["shared"]);
1190 assert!(
1191 registry.get("second").unwrap().aliases.is_empty(),
1192 "the losing command must not advertise an alias it does not own"
1193 );
1194 assert!(
1195 registry.load_errors().iter().any(|error| error
1196 .message
1197 .contains("User command alias '/shared'")
1198 && error.message.contains("/second")),
1199 "duplicate alias should be recorded as a user-command load error: {:?}",
1200 registry.load_errors()
1201 );
1202 }
1203
1204 #[test]
1205 fn alias_conflicting_with_canonical_user_command_is_rejected_consistently() {
1206 let registry = UserCommandRegistry::from_loaded(vec![
1207 (
1208 "alpha".to_string(),
1209 "---\nalias: beta\n---\nalpha body".to_string(),
1210 ),
1211 (
1212 "renamed-beta".to_string(),
1213 "---\nname: beta\n---\nbeta body".to_string(),
1214 ),
1215 ]);
1216
1217 let command = registry.get("beta").expect("canonical command resolves");
1218 assert_eq!(command.name, "beta");
1219 assert_eq!(command.body, "beta body");
1220 assert!(
1221 registry.get("alpha").unwrap().aliases.is_empty(),
1222 "a canonical-name collision must be absent from alias metadata"
1223 );
1224 assert!(
1225 registry.load_errors().iter().any(|error| error
1226 .message
1227 .contains("User command alias '/beta'")
1228 && error
1229 .message
1230 .contains("duplicates canonical user command '/beta'")),
1231 "alias/canonical conflict should be recorded: {:?}",
1232 registry.load_errors()
1233 );
1234 }
1235
1236 #[test]
1237 fn duplicate_user_command_name_records_user_command_error() {
1238 let registry = UserCommandRegistry::from_loaded(vec![
1239 ("review".to_string(), "first".to_string()),
1240 ("review".to_string(), "second".to_string()),
1241 ]);
1242
1243 assert_eq!(registry.get("review").unwrap().body, "first");
1244 assert!(
1245 registry
1246 .load_errors()
1247 .iter()
1248 .any(|error| error.message.contains("User command '/review'")
1249 && error.message.contains("defined more than once")),
1250 "duplicate name should be recorded as a user-command load error: {:?}",
1251 registry.load_errors()
1252 );
1253 }
1254
1255 #[test]
1256 fn malformed_losing_name_override_does_not_poison_valid_winner() {
1257 let registry = UserCommandRegistry::from_loaded(vec![
1258 (
1259 "first-file".to_string(),
1260 "---\nname: shared\n---\nfirst body".to_string(),
1261 ),
1262 (
1263 "second-file".to_string(),
1264 "---\nname: shared\nnot valid frontmatter\n---\nsecond body".to_string(),
1265 ),
1266 ]);
1267
1268 assert_eq!(registry.get("shared").unwrap().body, "first body");
1269 assert_eq!(registry.dispatch_error("shared"), None);
1270 assert!(registry.load_errors().iter().any(|error| {
1271 error.message.contains("invalid frontmatter") && error.path.ends_with("second-file.md")
1272 }));
1273 assert!(registry.load_errors().iter().any(|error| {
1274 error.message.contains("defined more than once")
1275 && error.path.ends_with("second-file.md")
1276 }));
1277 }
1278
1279 #[test]
1280 fn invalid_frontmatter_dispatch_returns_user_command_error_without_builtin_fallback() {
1281 let tmp = TempDir::new().unwrap();
1282 write_workspace_command(
1283 tmp.path(),
1284 "help",
1285 "---\ndescription: Custom help\nnot valid yaml\n---\ncustom help",
1286 );
1287 let mut app = test_app(tmp.path().to_path_buf());
1288
1289 let result = crate::commands::execute("/help", &mut app);
1290
1291 assert!(result.is_error);
1292 let message = result.message.expect("error message");
1293 assert!(message.contains("User command '/help'"), "{message}");
1294 assert!(message.contains("invalid frontmatter"), "{message}");
1295 }
1296
1297 #[test]
1298 fn malformed_file_is_recoverable_and_valid_sibling_still_dispatches() {
1299 let tmp = TempDir::new().unwrap();
1300 write_workspace_command(
1301 tmp.path(),
1302 "broken",
1303 "---\ndescription: Broken\nnot valid frontmatter\n---\nbroken body",
1304 );
1305 write_workspace_command(
1306 tmp.path(),
1307 "healthy",
1308 "---\ndescription: Healthy\n---\nhealthy $ARGUMENTS",
1309 );
1310 let mut app = test_app(tmp.path().to_path_buf());
1311
1312 let healthy = crate::commands::execute("/healthy now", &mut app);
1313 assert!(!healthy.is_error, "{:?}", healthy.message);
1314 assert_eq!(sent_message(healthy), "healthy now");
1315
1316 let broken = crate::commands::execute("/broken", &mut app);
1317 assert!(broken.is_error);
1318 assert!(
1319 broken
1320 .message
1321 .as_deref()
1322 .is_some_and(|message| message.contains("invalid frontmatter"))
1323 );
1324 }
1325
1326 #[test]
1327 fn invalid_frontmatter_name_is_recoverable_under_filename_default() {
1328 let registry = UserCommandRegistry::from_loaded(vec![(
1329 "recoverable".to_string(),
1330 "---\nname: two words\n---\nbody".to_string(),
1331 )]);
1332
1333 assert!(registry.get("recoverable").is_some());
1334 assert!(registry.dispatch_error("recoverable").is_some());
1335 assert!(registry.load_errors().iter().any(|error| {
1336 error
1337 .message
1338 .contains("invalid frontmatter name \"two words\"")
1339 }));
1340 }
1341
1342 #[test]
1343 fn frontmatter_line_with_empty_key_is_invalid() {
1344 let registry = UserCommandRegistry::from_loaded(vec![(
1345 "bad".to_string(),
1346 "---\n: value\n---\nbody".to_string(),
1347 )]);
1348
1349 assert!(
1350 registry.load_errors().iter().any(|error| error
1351 .message
1352 .contains("invalid frontmatter line \": value\"")),
1353 "empty frontmatter key should be invalid: {:?}",
1354 registry.load_errors()
1355 );
1356 }
1357
1358 #[test]
1359 fn registry_reloads_when_existing_command_file_changes() {
1360 let tmp = TempDir::new().unwrap();
1361 write_workspace_command(tmp.path(), "live", "first");
1362
1363 assert_eq!(
1364 registry_for_workspace(Some(tmp.path()))
1365 .get("live")
1366 .unwrap()
1367 .body,
1368 "first"
1369 );
1370
1371 write_workspace_command(tmp.path(), "live", "second body with different length");
1372
1373 assert_eq!(
1374 registry_for_workspace(Some(tmp.path()))
1375 .get("live")
1376 .unwrap()
1377 .body,
1378 "second body with different length"
1379 );
1380 }
1381
1382 #[test]
1383 fn empty_user_command_dispatch_returns_user_command_error() {
1384 let tmp = TempDir::new().unwrap();
1385 write_workspace_command(tmp.path(), "empty", "\n\t ");
1386 let mut app = test_app(tmp.path().to_path_buf());
1387
1388 let result = crate::commands::execute("/empty", &mut app);
1389
1390 assert!(result.is_error);
1391 let message = result.message.expect("error message");
1392 assert!(message.contains("User command '/empty'"), "{message}");
1393 assert!(message.contains("empty"), "{message}");
1394 }
1395 }
1396
1396 lines RUST