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