返回 CodeWhale
user_commands.rs
根目录 / crates / tui / src / commands / user_commands.rs
1 //! User-defined slash commands from `~/.codewhale/commands/<name>.md` and
2 //! workspace-local `<workspace>/.codewhale/commands/<name>.md`.
3 //!
4 //! Users drop `.md` files into a commands directory and the filename
5 //! (without `.md` extension) becomes the default slash-command name. A
6 //! frontmatter `name` may replace it. When invoked, the file contents are sent
7 //! as a user message.
8 //!
9 //! Files may include optional YAML-like frontmatter between `---` markers.
10 //! Supported fields are `name`, `description`, `usage`, `arguments`,
11 //! `argument-hint`, `allowed-tools`, `pausable`, `alias`/`aliases`, and `hidden`.
12 //! Frontmatter is stripped before the command body is sent to the model.
13 //!
14 //! ## Precedence
15 //!
16 //! Workspace-local directories shadow user-global by name:
17 //!
18 //! 1. `<workspace>/.codewhale/commands/` (project-local, highest)
19 //! 2. `<workspace>/.deepseek/commands/` (legacy project-local)
20 //! 3. `<workspace>/.claude/commands/` (Claude Code interop)
21 //! 4. `<workspace>/.cursor/commands/` (Cursor interop)
22 //! 5. `~/.codewhale/commands/` (user-global)
23 //! 6. `~/.deepseek/commands/` (legacy user-global)
24 //!
25 //! ## Permanent Role
26 //!
27 //! This module is the lower-level scanning, frontmatter parsing, and template
28 //! layer for [`super::user_registry::UserCommandRegistry`]. Runtime dispatch
29 //! lives in `user_registry.rs`; this file remains as the shared file I/O and
30 //! parsing boundary documented in `docs/architecture/command-dispatch.md`.
31
32 #[cfg(test)]
33 use std::collections::HashSet;
34 use std::path::{Path, PathBuf};
35
36 #[cfg(test)]
37 use crate::tui::app::{App, AppAction};
38
39 #[cfg(test)]
40 use super::CommandResult;
41
42 /// Path to the global user commands directory: `~/.codewhale/commands/`.
43 fn global_commands_dir() -> PathBuf {
44 let home = crate::config::effective_home_dir().unwrap_or_else(|| PathBuf::from("~"));
45 home.join(".codewhale").join("commands")
46 }
47
48 fn legacy_global_commands_dir() -> PathBuf {
49 let home = crate::config::effective_home_dir().unwrap_or_else(|| PathBuf::from("~"));
50 home.join(".deepseek").join("commands")
51 }
52
53 /// Return all candidate commands directories in precedence order.
54 pub(crate) fn commands_dirs(workspace: Option<&Path>) -> Vec<PathBuf> {
55 let mut dirs = Vec::new();
56 if let Some(ws) = workspace {
57 dirs.push(ws.join(".codewhale").join("commands"));
58 dirs.push(ws.join(".deepseek").join("commands"));
59 dirs.push(ws.join(".claude").join("commands"));
60 dirs.push(ws.join(".cursor").join("commands"));
61 }
62 dirs.push(global_commands_dir());
63 dirs.push(legacy_global_commands_dir());
64 dirs
65 }
66
67 /// Saved-workflow slash commands (#4121 packaging): `*.workflow.js` files
68 /// under these directories become `/name` commands that start the workflow
69 /// through the `workflow` tool with the slash arguments forwarded as the
70 /// run's `args`. Workspace definitions shadow the user-global store.
71 pub(crate) fn workflow_dirs(workspace: Option<&Path>) -> Vec<PathBuf> {
72 let mut dirs = Vec::new();
73 if let Some(ws) = workspace {
74 dirs.push(ws.join(".codewhale").join("workflows"));
75 }
76 let home = crate::config::effective_home_dir().unwrap_or_else(|| PathBuf::from("~"));
77 dirs.push(home.join(".codewhale").join("workflows"));
78 dirs
79 }
80
81 /// Canonical saved-workflow source suffix.
82 pub(crate) const WORKFLOW_SOURCE_SUFFIX: &str = ".workflow.js";
83
84 /// Scan one workflow directory and synthesize a markdown command definition
85 /// per `*.workflow.js` file. Returns `(name, content, source_path)` tuples;
86 /// unreadable entries are skipped.
87 pub(crate) fn load_workflow_commands_from_dir(dir: &Path) -> Vec<(String, String, PathBuf)> {
88 let mut commands = Vec::new();
89 if !dir.is_dir() {
90 return commands;
91 }
92 let Ok(entries) = std::fs::read_dir(dir) else {
93 return commands;
94 };
95 for entry in entries.flatten() {
96 let path = entry.path();
97 let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
98 continue;
99 };
100 let Some(stem) = file_name.strip_suffix(WORKFLOW_SOURCE_SUFFIX) else {
101 continue;
102 };
103 let name = stem.to_lowercase();
104 if name.is_empty() {
105 continue;
106 }
107 let description = std::fs::read_to_string(&path)
108 .ok()
109 .and_then(|source| workflow_headline(&source))
110 .unwrap_or_else(|| format!("Run the saved workflow {name}"));
111 let content = synthesize_workflow_command(&name, &description, &path);
112 commands.push((name, content, path));
113 }
114 commands.sort_by(|a, b| a.0.cmp(&b.0));
115 commands
116 }
117
118 /// First `//` comment line of a workflow source, used as the command
119 /// description in palettes and help.
120 fn workflow_headline(source: &str) -> Option<String> {
121 source.lines().find_map(|line| {
122 let comment = line.trim().strip_prefix("//")?.trim();
123 (!comment.is_empty()).then(|| comment.split_whitespace().collect::<Vec<_>>().join(" "))
124 })
125 }
126
127 fn synthesize_workflow_command(name: &str, description: &str, path: &Path) -> String {
128 // Frontmatter values must stay single-line; the headline is already one
129 // line but defend against pathological sources.
130 let description = description.replace(['\r', '\n'], " ");
131 format!(
132 "---\ndescription: {description}\nusage: /{name} [args...]\narguments: forwarded to the workflow run's args\n---\nStart the saved workflow `{name}` now: call the `workflow` tool with action=\"start\", source_path=\"{path}\", and args built from this argument text: $ARGUMENTS\nIf the argument text is empty, start the run without args. Report the run_id, monitor with the workflow tool's status action, and when the run settles present its receipt summary (status, phases, failures, artifacts). The durable report lands under .codewhale/reports/<run_id>.md.",
133 path = path.display(),
134 )
135 }
136
137 /// Scan a single commands directory for `.md` files and return
138 /// `(name, content)` pairs. Errors are silently skipped.
139 pub(crate) fn load_commands_from_dir(dir: &Path) -> Vec<(String, String)> {
140 load_command_entries_from_component(dir)
141 .into_iter()
142 .map(|(name, content, _)| (name, content))
143 .collect()
144 }
145
146 /// Load one reviewed command component from an immutable plugin snapshot.
147 /// Components may name either one markdown file or a directory of markdown
148 /// files; every returned entry retains the exact staged path for diagnostics.
149 pub(crate) fn load_command_entries_from_component(
150 component: &Path,
151 ) -> Vec<(String, String, PathBuf)> {
152 if component.is_file() {
153 if component.extension().and_then(|value| value.to_str()) != Some("md") {
154 return Vec::new();
155 }
156 let Some(stem) = component.file_stem().and_then(|value| value.to_str()) else {
157 return Vec::new();
158 };
159 return std::fs::read_to_string(component)
160 .ok()
161 .map(|content| vec![(stem.to_lowercase(), content, component.to_path_buf())])
162 .unwrap_or_default();
163 }
164
165 let mut commands: Vec<(String, String, PathBuf)> = Vec::new();
166
167 if !component.is_dir() {
168 return Vec::new();
169 }
170
171 let entries = match std::fs::read_dir(component) {
172 Ok(entries) => entries,
173 Err(_) => return Vec::new(),
174 };
175
176 for entry in entries.flatten() {
177 let path = entry.path();
178 if path.extension().and_then(|e| e.to_str()) != Some("md") {
179 continue;
180 }
181 let stem = match path.file_stem().and_then(|s| s.to_str()) {
182 Some(stem) => stem.to_lowercase(),
183 None => continue,
184 };
185 let content = match std::fs::read_to_string(&path) {
186 Ok(c) => c,
187 Err(_) => continue,
188 };
189 commands.push((stem, content, path));
190 }
191 commands.sort_by(|left, right| left.0.cmp(&right.0));
192 commands
193 }
194
195 /// Scan every candidate commands directory and return merged
196 /// `(name, content)` pairs. Workspace-local directories shadow
197 /// user-global by name — the first occurrence of a name wins.
198 ///
199 /// Pass `None` for the workspace to scan only the global directory
200 /// (backward-compatible with callers that don't have workspace context).
201 #[cfg(test)]
202 pub fn load_user_commands(workspace: Option<&Path>) -> Vec<(String, String)> {
203 let mut seen: HashSet<String> = HashSet::new();
204 let mut commands: Vec<(String, String)> = Vec::new();
205
206 for dir in commands_dirs(workspace) {
207 for (name, content) in load_commands_from_dir(&dir) {
208 if seen.insert(name.clone()) {
209 commands.push((name, content));
210 }
211 }
212 }
213
214 // Sort by name for deterministic ordering.
215 commands.sort_by(|a, b| a.0.cmp(&b.0));
216 commands
217 }
218
219 pub(crate) fn parse_frontmatter(content: &str) -> (Vec<(String, String)>, &str) {
220 let Some(first_line_end) = content.find('\n') else {
221 return (Vec::new(), content);
222 };
223 let first = content[..first_line_end].trim_end_matches('\r');
224
225 if first.trim().chars().all(|ch| ch == '-') && first.trim().len() >= 3 {
226 let mut metadata = Vec::new();
227 let mut offset = first_line_end + 1;
228 let mut unclosed_body_start = None;
229 for raw_line in content[offset..].split_inclusive('\n') {
230 let line_start = offset;
231 let line = raw_line.trim_end_matches(['\r', '\n']);
232 offset += raw_line.len();
233 let trimmed = line.trim();
234 if unclosed_body_start.is_none() {
235 if trimmed.chars().all(|ch| ch == '-') && trimmed.len() >= 3 {
236 let body = content[offset..].trim_start_matches(['\r', '\n']);
237 return (metadata, body);
238 }
239 if let Some((key, value)) = line.split_once(':') {
240 let key = key.trim().to_ascii_lowercase();
241 let raw_value = value.trim();
242 let value = if key == "allowed-tools" {
243 raw_value.to_string()
244 } else {
245 strip_matched_quotes(raw_value).to_string()
246 };
247 if !key.is_empty() {
248 metadata.push((key, value));
249 }
250 } else if !trimmed.is_empty() {
251 unclosed_body_start = Some(line_start);
252 }
253 }
254 }
255 let body_start = unclosed_body_start.unwrap_or(content.len());
256 let body = content[body_start..].trim_start_matches(['\r', '\n']);
257 return (metadata, body);
258 }
259
260 (Vec::new(), content)
261 }
262
263 fn strip_matched_quotes(value: &str) -> &str {
264 if let Some(stripped) = value.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
265 return stripped;
266 }
267 if let Some(stripped) = value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')) {
268 return stripped;
269 }
270 value
271 }
272
273 pub(crate) fn parse_allowed_tools(value: &str) -> Vec<String> {
274 value
275 .split(',')
276 .map(|tool| {
277 strip_matched_quotes(tool.trim())
278 .trim()
279 .to_ascii_lowercase()
280 })
281 .filter(|tool| !tool.is_empty())
282 .collect()
283 }
284
285 /// Check if the input matches a user-defined command and return the
286 /// content as a `SendMessage` action.
287 ///
288 /// The `input` should be the full command string including the `/`
289 /// prefix (e.g. `/mycmd` or `/mycmd with args`). Only exact matches
290 /// on the command name are considered (no partial/alias matching).
291 /// Substitute $1, $2, $ARGUMENTS placeholders in a command template.
292 pub(crate) fn apply_template(template: &str, args: &str) -> String {
293 let positional: Vec<&str> = args.split_whitespace().collect();
294 let mut result = template.replace("$ARGUMENTS", args);
295 for (i, arg) in positional.iter().enumerate() {
296 result = result.replace(&format!("${}", i + 1), arg);
297 }
298 result
299 }
300
301 #[cfg(test)]
302 pub fn try_dispatch_user_command(app: &mut App, input: &str) -> Option<CommandResult> {
303 let parts: Vec<&str> = input.trim().splitn(2, ' ').collect();
304 let command = parts[0].to_lowercase();
305 let command = command.strip_prefix('/').unwrap_or(&command);
306 let args = parts.get(1).copied().unwrap_or("").trim();
307
308 let user_commands = load_user_commands(Some(&app.workspace));
309
310 for (name, content) in &user_commands {
311 if name == command {
312 let (metadata, body) = parse_frontmatter(content);
313 app.goal.objective = None;
314 app.goal.started_at = None;
315 app.goal.status = crate::tools::goal::GoalStatus::Active;
316 app.goal.token_budget = None;
317 app.goal.tokens_used = 0;
318 app.goal.time_used_seconds = 0;
319 app.goal.continuation_count = 0;
320 app.active_allowed_tools = None;
321 app.pausable = false;
322 app.paused = false;
323 app.paused_goal_objective = None;
324 // Clear todos and plan state from the previous command so they
325 // don't bleed into the next one. Both are behind the same locks
326 // the sidebar reads; a contended/poisoned lock is logged and
327 // skipped rather than blocking dispatch.
328 if let Ok(mut todos) = app.todos.try_lock() {
329 todos.clear();
330 } else {
331 tracing::warn!(target: "commands", "todos lock contended or poisoned — previous todos not cleared");
332 }
333 if let Ok(mut plan) = app.plan_state.try_lock() {
334 *plan = crate::tools::plan::PlanState::default();
335 } else {
336 tracing::warn!(target: "commands", "plan_state lock contended or poisoned — previous plan not cleared");
337 }
338 for (key, value) in &metadata {
339 match key.as_str() {
340 "description" => {
341 app.goal.objective = Some(value.clone());
342 app.goal.started_at = Some(std::time::Instant::now());
343 }
344 "allowed-tools" => {
345 app.active_allowed_tools = Some(parse_allowed_tools(value));
346 }
347 "pausable" => {
348 app.pausable = value.trim().eq_ignore_ascii_case("true");
349 }
350 _ => {}
351 }
352 }
353 let message = apply_template(body, args);
354 return Some(CommandResult::action(AppAction::SendMessage(message)));
355 }
356 }
357
358 None
359 }
360
361 #[cfg(test)]
362 mod tests {
363 use super::*;
364 use tempfile::TempDir;
365
366 #[test]
367 fn test_global_commands_dir_contains_codewhale_commands() {
368 let dir = global_commands_dir();
369 let parts: Vec<_> = dir
370 .components()
371 .filter_map(|component| component.as_os_str().to_str())
372 .collect();
373 assert!(
374 parts
375 .windows(2)
376 .any(|pair| pair == [".codewhale", "commands"]),
377 "expected .codewhale/commands components in path, got: {}",
378 dir.display()
379 );
380 }
381
382 #[test]
383 fn test_load_user_commands_when_no_dir_exists() {
384 let cmds = load_user_commands(None);
385 // Should not panic; returns empty vec when no directories exist.
386 assert!(cmds.is_empty() || !cmds.is_empty());
387 }
388
389 #[test]
390 fn test_try_dispatch_nonexistent_command() {
391 use crate::config::Config;
392 use crate::tui::app::TuiOptions;
393
394 let options = TuiOptions {
395 ..crate::test_support::test_tui_options(PathBuf::from("."))
396 };
397 let mut app = App::new(options, &Config::default());
398 let result = try_dispatch_user_command(&mut app, "/nonexistent-thing-12345");
399 assert!(result.is_none());
400 }
401
402 // ── Workspace-local commands tests ─────────────────────────────────
403
404 fn write_command(dir: &Path, name: &str, body: &str) {
405 std::fs::create_dir_all(dir).unwrap();
406 std::fs::write(dir.join(format!("{name}.md")), body).unwrap();
407 }
408
409 fn test_options(workspace: PathBuf) -> crate::tui::app::TuiOptions {
410 crate::tui::app::TuiOptions {
411 ..crate::test_support::test_tui_options(workspace)
412 }
413 }
414
415 #[test]
416 fn load_user_commands_scans_workspace_local_dir() {
417 let tmp = TempDir::new().unwrap();
418 let ws = tmp.path();
419 let cmds_dir = ws.join(".codewhale").join("commands");
420 write_command(&cmds_dir, "hello", "echo hi");
421
422 let cmds = load_user_commands(Some(ws));
423 let names: Vec<&str> = cmds.iter().map(|(n, _)| n.as_str()).collect();
424 assert!(
425 names.contains(&"hello"),
426 "expected 'hello' in workspace-local commands: {names:?}"
427 );
428 }
429
430 #[test]
431 fn load_user_commands_scans_claude_and_cursor_dirs() {
432 let tmp = TempDir::new().unwrap();
433 let ws = tmp.path();
434 write_command(
435 &ws.join(".claude").join("commands"),
436 "claude-cmd",
437 "claude body",
438 );
439 write_command(
440 &ws.join(".cursor").join("commands"),
441 "cursor-cmd",
442 "cursor body",
443 );
444
445 let cmds = load_user_commands(Some(ws));
446 let names: Vec<&str> = cmds.iter().map(|(n, _)| n.as_str()).collect();
447 assert!(
448 names.contains(&"claude-cmd"),
449 "expected 'claude-cmd': {names:?}"
450 );
451 assert!(
452 names.contains(&"cursor-cmd"),
453 "expected 'cursor-cmd': {names:?}"
454 );
455 }
456
457 #[test]
458 fn workspace_local_shadows_global_by_name() {
459 let tmp = TempDir::new().unwrap();
460 let ws = tmp.path();
461
462 // Workspace-local version
463 write_command(
464 &ws.join(".codewhale").join("commands"),
465 "shared",
466 "workspace version",
467 );
468 // Global version — simulate by putting it in a "global" temp dir.
469 // Paths resolve via effective_home_dir (HOME/USERPROFILE-aware). We test the
470 // first-match-wins semantics by putting the same name in both
471 // workspace-scanned dirs. The first dir in precedence order wins.
472 write_command(
473 &ws.join(".claude").join("commands"),
474 "shared",
475 "claude version",
476 );
477
478 let cmds = load_user_commands(Some(ws));
479 let shared = cmds
480 .iter()
481 .find(|(n, _)| n == "shared")
482 .expect("shared present");
483 assert_eq!(
484 shared.1, "workspace version",
485 "workspace-local (.codewhale) must shadow later dirs"
486 );
487 }
488
489 #[test]
490 fn load_user_commands_without_workspace_falls_back_to_global_only() {
491 // When no workspace is passed, only global command directories are
492 // scanned. On test machines these often don't exist, so we just
493 // verify we don't panic.
494 let cmds = load_user_commands(None);
495 // This should not panic; can be empty or have user's real commands.
496 let _ = cmds;
497 }
498
499 #[test]
500 fn try_dispatch_uses_workspace_local_command() {
501 use crate::config::Config;
502 use crate::tui::app::TuiOptions;
503
504 let tmp = TempDir::new().unwrap();
505 let ws = tmp.path().to_path_buf();
506 write_command(
507 &ws.join(".deepseek").join("commands"),
508 "hello",
509 "Hello, $ARGUMENTS!",
510 );
511
512 let options = TuiOptions {
513 ..crate::test_support::test_tui_options(ws.clone())
514 };
515 let mut app = App::new(options, &Config::default());
516 let result = try_dispatch_user_command(&mut app, "/hello world");
517 assert!(result.is_some());
518 let cmd_result = result.unwrap();
519 match cmd_result.action {
520 Some(AppAction::SendMessage(msg)) => {
521 assert!(msg.contains("Hello, world!"), "got: {msg}");
522 }
523 other => panic!("expected SendMessage action, got: {other:?}"),
524 }
525 }
526
527 #[test]
528 fn frontmatter_is_stripped_before_dispatch() {
529 use crate::config::Config;
530
531 let tmp = TempDir::new().unwrap();
532 let ws = tmp.path().to_path_buf();
533 write_command(
534 &ws.join(".deepseek").join("commands"),
535 "secure",
536 "---\ndescription: Secure scan\nallowed-tools: Bash, Read\n---\nRun $ARGUMENTS",
537 );
538
539 let mut app = App::new(test_options(ws), &Config::default());
540 let result = try_dispatch_user_command(&mut app, "/secure checks").unwrap();
541 match result.action {
542 Some(AppAction::SendMessage(msg)) => assert_eq!(msg, "Run checks"),
543 other => panic!("expected SendMessage action, got: {other:?}"),
544 }
545 }
546
547 #[test]
548 fn review_regression_unclosed_frontmatter_keeps_metadata_and_strips_header() {
549 let (metadata, body) = parse_frontmatter(
550 "---\ndescription: Broken command\nallowed-tools: Bash\nRun the safe body",
551 );
552
553 assert_eq!(
554 metadata,
555 vec![
556 ("description".to_string(), "Broken command".to_string()),
557 ("allowed-tools".to_string(), "Bash".to_string())
558 ]
559 );
560 assert_eq!(body, "Run the safe body");
561 }
562
563 #[test]
564 fn review_regression_unclosed_frontmatter_without_metadata_strips_header() {
565 let (metadata, body) =
566 parse_frontmatter("---\nRun the command body without a closing delimiter");
567
568 assert!(metadata.is_empty());
569 assert_eq!(body, "Run the command body without a closing delimiter");
570 }
571
572 #[test]
573 fn review_regression_frontmatter_strips_only_matched_quote_pairs() {
574 let (metadata, body) = parse_frontmatter("---\ndescription: 'Read\"\n---\nrun");
575
576 assert_eq!(
577 metadata,
578 vec![("description".to_string(), "'Read\"".to_string())]
579 );
580 assert_eq!(body, "run");
581 }
582
583 #[test]
584 fn allowed_tools_frontmatter_sets_app_state() {
585 use crate::config::Config;
586
587 let tmp = TempDir::new().unwrap();
588 let ws = tmp.path().to_path_buf();
589 write_command(
590 &ws.join(".deepseek").join("commands"),
591 "secure",
592 "---\nallowed-tools: Bash, Grep\n---\nrun tests",
593 );
594
595 let mut app = App::new(test_options(ws), &Config::default());
596 let _ = try_dispatch_user_command(&mut app, "/secure").unwrap();
597 assert_eq!(
598 app.active_allowed_tools,
599 Some(vec!["bash".to_string(), "grep".to_string()])
600 );
601 }
602
603 #[test]
604 fn pausable_frontmatter_sets_app_state_without_worktree_mutation() {
605 use crate::config::Config;
606
607 if std::process::Command::new("git")
608 .arg("--version")
609 .output()
610 .is_err()
611 {
612 return;
613 }
614
615 let tmp = TempDir::new().unwrap();
616 let ws = tmp.path().to_path_buf();
617 let init = std::process::Command::new("git")
618 .args(["-C", ws.to_str().unwrap(), "init"])
619 .output()
620 .expect("git init");
621 assert!(
622 init.status.success(),
623 "git init failed: {}",
624 String::from_utf8_lossy(&init.stderr)
625 );
626 std::fs::write(ws.join("user-work.txt"), "untracked user work").unwrap();
627 write_command(
628 &ws.join(".codewhale").join("commands"),
629 "pause-scan",
630 "---\ndescription: Scan repos\npausable: true\n---\nscan",
631 );
632
633 let mut app = App::new(test_options(ws.clone()), &Config::default());
634 let _ = try_dispatch_user_command(&mut app, "/pause-scan").unwrap();
635
636 assert!(app.pausable);
637 assert!(!app.paused);
638 assert!(app.paused_goal_objective.is_none());
639 assert!(ws.join("user-work.txt").exists());
640 let stash = std::process::Command::new("git")
641 .args(["-C", ws.to_str().unwrap(), "stash", "list"])
642 .output()
643 .expect("git stash list");
644 assert!(
645 stash.status.success(),
646 "git stash list failed: {}",
647 String::from_utf8_lossy(&stash.stderr)
648 );
649 assert!(
650 String::from_utf8_lossy(&stash.stdout).trim().is_empty(),
651 "pausable dispatch must not create git stash entries"
652 );
653 }
654
655 #[test]
656 fn new_user_command_clears_stale_paused_state() {
657 use crate::config::Config;
658
659 let tmp = TempDir::new().unwrap();
660 let ws = tmp.path().to_path_buf();
661 let commands_dir = ws.join(".codewhale").join("commands");
662 write_command(
663 &commands_dir,
664 "pause-scan",
665 "---\ndescription: Scan repos\npausable: true\n---\nscan",
666 );
667 write_command(&commands_dir, "plain", "plain command");
668
669 let mut app = App::new(test_options(ws), &Config::default());
670 let _ = try_dispatch_user_command(&mut app, "/pause-scan").unwrap();
671 app.paused = true;
672 app.paused_goal_objective = Some("Scan repos".to_string());
673
674 let _ = try_dispatch_user_command(&mut app, "/plain").unwrap();
675
676 assert!(!app.pausable);
677 assert!(!app.paused);
678 assert!(app.paused_goal_objective.is_none());
679 }
680
681 #[test]
682 fn new_user_command_clears_previous_todos_and_plan() {
683 use crate::config::Config;
684 use crate::tools::plan::UpdatePlanArgs;
685 use crate::tools::todo::TodoStatus;
686
687 let tmp = TempDir::new().unwrap();
688 let ws = tmp.path().to_path_buf();
689 let commands_dir = ws.join(".codewhale").join("commands");
690 write_command(&commands_dir, "first", "first command body");
691 write_command(&commands_dir, "second", "second command body");
692
693 let mut app = App::new(test_options(ws), &Config::default());
694
695 // Seed the state a previous command would leave behind: a non-empty
696 // todo list and a non-empty plan. These should NOT bleed into the
697 // next command. The shared lists are tokio async mutexes, so seed and
698 // observe through `try_lock` (the same sync path dispatch uses).
699 {
700 let mut todos = app.todos.try_lock().expect("todos lock");
701 todos.add(
702 "leftover task from first command".to_string(),
703 TodoStatus::Pending,
704 );
705 }
706 {
707 let mut plan = app.plan_state.try_lock().expect("plan_state lock");
708 plan.update(UpdatePlanArgs {
709 title: Some("leftover plan".to_string()),
710 objective: Some("old goal".to_string()),
711 ..Default::default()
712 });
713 }
714
715 // Dispatch a fresh command — dispatch must reset both.
716 let _ = try_dispatch_user_command(&mut app, "/second").unwrap();
717
718 assert!(
719 app.todos
720 .try_lock()
721 .expect("todos lock")
722 .snapshot()
723 .items
724 .is_empty(),
725 "previous command's todos must be cleared on new command dispatch"
726 );
727 assert!(
728 app.plan_state
729 .try_lock()
730 .expect("plan_state lock")
731 .snapshot()
732 .is_empty(),
733 "previous command's plan must be cleared on new command dispatch"
734 );
735 }
736
737 #[test]
738 fn review_regression_empty_allowed_tools_blocks_all_tools() {
739 use crate::config::Config;
740
741 let tmp = TempDir::new().unwrap();
742 let ws = tmp.path().to_path_buf();
743 write_command(
744 &ws.join(".deepseek").join("commands"),
745 "locked",
746 "---\nallowed-tools: \"\"\n---\nrun nothing",
747 );
748
749 let mut app = App::new(test_options(ws), &Config::default());
750 let _ = try_dispatch_user_command(&mut app, "/locked").unwrap();
751 assert_eq!(app.active_allowed_tools, Some(Vec::new()));
752 }
753
754 #[test]
755 fn review_regression_allowed_tools_accepts_per_item_quotes() {
756 use crate::config::Config;
757
758 let tmp = TempDir::new().unwrap();
759 let ws = tmp.path().to_path_buf();
760 write_command(
761 &ws.join(".deepseek").join("commands"),
762 "quoted",
763 "---\nallowed-tools: \"exec_shell\", 'read_file'\n---\nrun quoted tools",
764 );
765
766 let mut app = App::new(test_options(ws), &Config::default());
767 let _ = try_dispatch_user_command(&mut app, "/quoted").unwrap();
768 assert_eq!(
769 app.active_allowed_tools,
770 Some(vec!["exec_shell".to_string(), "read_file".to_string()])
771 );
772 }
773
774 #[test]
775 fn review_regression_dispatch_without_frontmatter_resets_previous_command_state() {
776 use crate::config::Config;
777
778 let tmp = TempDir::new().unwrap();
779 let ws = tmp.path().to_path_buf();
780 let commands_dir = ws.join(".deepseek").join("commands");
781 write_command(
782 &commands_dir,
783 "described",
784 "---\ndescription: Scan repos\nallowed-tools: Bash\n---\nscan",
785 );
786 write_command(&commands_dir, "plain", "plain command");
787
788 let mut app = App::new(test_options(ws), &Config::default());
789 let _ = try_dispatch_user_command(&mut app, "/described").unwrap();
790 assert_eq!(app.goal.objective.as_deref(), Some("Scan repos"));
791 assert!(app.goal.started_at.is_some());
792 assert_eq!(app.goal.status, crate::tools::goal::GoalStatus::Active);
793 assert_eq!(app.goal.token_budget, None);
794 assert_eq!(app.active_allowed_tools, Some(vec!["bash".to_string()]));
795
796 app.goal.status = crate::tools::goal::GoalStatus::Blocked;
797 app.goal.token_budget = Some(42);
798 app.goal.tokens_used = 100;
799 app.goal.time_used_seconds = 5;
800 app.goal.continuation_count = 1;
801 let _ = try_dispatch_user_command(&mut app, "/plain").unwrap();
802 assert_eq!(app.goal.objective, None);
803 assert_eq!(app.goal.started_at, None);
804 assert_eq!(app.goal.status, crate::tools::goal::GoalStatus::Active);
805 assert_eq!(app.goal.token_budget, None);
806 assert_eq!(app.goal.tokens_used, 0);
807 assert_eq!(app.goal.time_used_seconds, 0);
808 assert_eq!(app.goal.continuation_count, 0);
809 assert_eq!(app.active_allowed_tools, None);
810 }
811
812 #[test]
813 fn description_frontmatter_sets_work_objective_and_autocomplete_description() {
814 use crate::config::Config;
815
816 let tmp = TempDir::new().unwrap();
817 let ws = tmp.path().to_path_buf();
818 write_command(
819 &ws.join(".deepseek").join("commands"),
820 "git-scan",
821 "---\ndescription: Scan nested git repositories\nargument-hint: <root>\n---\nscan",
822 );
823
824 let mut app = App::new(test_options(ws.clone()), &Config::default());
825 let _ = try_dispatch_user_command(&mut app, "/git-scan").unwrap();
826 assert_eq!(
827 app.goal.objective.as_deref(),
828 Some("Scan nested git repositories")
829 );
830 let commands = load_user_commands(Some(&ws));
831 let (_, content) = commands
832 .iter()
833 .find(|(name, _)| name == "git-scan")
834 .expect("git-scan command should load");
835 let (metadata, _) = parse_frontmatter(content);
836 assert!(metadata.contains(&(
837 "description".to_string(),
838 "Scan nested git repositories".to_string()
839 )));
840 assert!(metadata.contains(&("argument-hint".to_string(), "<root>".to_string())));
841 }
842
843 #[test]
844 fn parser_preserves_layer_5_1_frontmatter_fields() {
845 let (metadata, body) = parse_frontmatter(
846 "---\nname: inspect\ndescription: Inspect a target\nusage: /inspect <path>\narguments: <path>\nhidden: false\nallowed-tools: Read_File, Grep_Files\n---\ninspect $ARGUMENTS",
847 );
848
849 assert!(metadata.contains(&("name".to_string(), "inspect".to_string())));
850 assert!(metadata.contains(&("description".to_string(), "Inspect a target".to_string())));
851 assert!(metadata.contains(&("usage".to_string(), "/inspect <path>".to_string())));
852 assert!(metadata.contains(&("arguments".to_string(), "<path>".to_string())));
853 assert!(metadata.contains(&("hidden".to_string(), "false".to_string())));
854 assert!(metadata.contains(&(
855 "allowed-tools".to_string(),
856 "Read_File, Grep_Files".to_string()
857 )));
858 assert_eq!(body, "inspect $ARGUMENTS");
859 }
860 }
861
861 lines RUST