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