- 1
//! Custom slash commands: markdown prompt templates discovered from - 2
//! `.vak/commands/*.md` (project), `<home>/commands/*.md` (user), and - 3
//! `.vak/plugins/<plugin>/commands/*.md` (plugin-contributed palette - 4
//! actions). Project wins over plugin wins over user on name collision. - 5
- 6
use std::path::Path; - 7
- 8
#[derive(Debug, Clone, PartialEq)] - 9
pub struct CustomCommand { - 10
pub name: String, - 11
pub description: String, - 12
/// Full markdown body; `$ARGUMENTS` is substituted at invocation. - 13
pub template: String, - 14
pub source: String, - 15
} - 16
- 17
pub fn discover(cwd: &Path, home: &Path) -> Vec<CustomCommand> { - 18
let mut commands = discover_with_plugins(cwd, home, &[]); - 19
// Standalone inspection preserves the historical local plugin view. Core - 20
// turn admission never uses this convenience path; it supplies only - 21
// package roots returned by the enabled-plugin store. - 22
if let Ok(entries) = std::fs::read_dir(cwd.join(".vak/plugins")) { - 23
for entry in entries.flatten().filter(|entry| entry.path().is_dir()) { - 24
let Some(name) = entry.file_name().to_str().map(str::to_string) else { - 25
continue; - 26
}; - 27
collect_dir( - 28
&entry.path().join("commands"), - 29
&format!("plugin:{name}"), - 30
&mut commands, - 31
); - 32
} - 33
} - 34
commands.sort_by(|a, b| a.name.cmp(&b.name).then(a.source.cmp(&b.source))); - 35
commands.dedup_by(|a, b| a.name == b.name); - 36
commands - 37
} - 38
- 39
pub fn discover_with_plugins( - 40
cwd: &Path, - 41
home: &Path, - 42
plugins: &[(std::path::PathBuf, String)], - 43
) -> Vec<CustomCommand> { - 44
let mut out = Vec::new(); - 45
let mut roots: Vec<(std::path::PathBuf, String)> = vec![ - 46
(cwd.join(".vak/plugins"), String::new()), - 47
(cwd.join(".vak/commands"), "project".to_string()), - 48
(home.join("commands"), "user".to_string()), - 49
]; - 50
roots.dedup(); - 51
// Plugin roots are supplied by the enabled-plugin resolver. Never scan - 52
// `.vak/plugins` directly: an unpacked or disabled package is not a - 53
// capability source. - 54
for (root, provenance) in plugins { - 55
collect_dir( - 56
&root.join("commands"), - 57
&format!("{provenance}:commands"), - 58
&mut out, - 59
); - 60
} - 61
for (root, label) in roots.iter().skip(1).rev() { - 62
collect_dir(root, label, &mut out); - 63
} - 64
out.sort_by(|a, b| { - 65
let rank = |source: &str| { - 66
if source == "project" { - 67
0 - 68
} else if source.starts_with("plugin:") { - 69
1 - 70
} else { - 71
2 - 72
} - 73
}; - 74
a.name - 75
.cmp(&b.name) - 76
.then(rank(&a.source).cmp(&rank(&b.source))) - 77
}); - 78
out.dedup_by(|a, b| a.name == b.name); - 79
out - 80
} - 81
- 82
fn collect_dir(dir: &Path, source: &str, out: &mut Vec<CustomCommand>) { - 83
let Ok(entries) = std::fs::read_dir(dir) else { - 84
return; - 85
}; - 86
for entry in entries.flatten() { - 87
let path = entry.path(); - 88
if !path.is_file() { - 89
continue; - 90
} - 91
let Some(name) = path.file_stem().map(|n| n.to_string_lossy().into_owned()) else { - 92
continue; - 93
}; - 94
if !valid_name(&name) || path.extension().is_none_or(|e| e != "md") { - 95
continue; - 96
} - 97
if let Some(cmd) = parse(&path, &name, source) { - 98
out.push(cmd); - 99
} - 100
} - 101
} - 102
- 103
fn valid_name(name: &str) -> bool { - 104
!name.is_empty() - 105
&& name - 106
.chars() - 107
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')) - 108
} - 109
- 110
fn parse(path: &Path, name: &str, source: &str) -> Option<CustomCommand> { - 111
let text = std::fs::read_to_string(path).ok()?; - 112
let mut body = text.as_str(); - 113
let mut description = String::new(); - 114
if let Some(rest) = text.strip_prefix("---") - 115
&& let Some((frontmatter, remainder)) = rest.split_once("---") - 116
{ - 117
for line in frontmatter.lines() { - 118
if let Some(v) = line.trim().strip_prefix("description:") { - 119
description = v.trim().trim_matches('"').to_string(); - 120
} - 121
} - 122
body = remainder; - 123
} - 124
if description.is_empty() { - 125
for line in body.lines() { - 126
let trimmed = line.trim().trim_start_matches('#').trim_start(); - 127
if trimmed.is_empty() { - 128
continue; - 129
} - 130
description = trimmed.trim_start_matches('>').trim().to_string(); - 131
break; - 132
} - 133
} - 134
Some(CustomCommand { - 135
name: name.to_string(), - 136
description, - 137
template: body.trim().to_string(), - 138
source: source.to_string(), - 139
}) - 140
} - 141
- 142
/// Substitutes `$ARGUMENTS` with the invocation arguments; when the - 143
/// template has no placeholder and args are present they are appended so - 144
/// the payload is never silently dropped. - 145
pub fn expand(template: &str, args: &str) -> String { - 146
let args = args.trim(); - 147
if template.contains("$ARGUMENTS") { - 148
return template.replace("$ARGUMENTS", args); - 149
} - 150
if args.is_empty() { - 151
return template.to_string(); - 152
} - 153
format!("{template}\n\nArguments: {args}") - 154
} - 155
- 156
/// Expands a leading admitted `/command` invocation from the frozen - 157
/// capability packet. Unknown slash-prefixed text is left untouched. - 158
pub fn expand_capability_invocation( - 159
capabilities: &[vak_session::CapabilityDescriptor], - 160
input: &str, - 161
) -> Option<String> { - 162
let trimmed = input.trim_start(); - 163
let rest = trimmed.strip_prefix('/')?; - 164
let mut parts = rest.splitn(2, char::is_whitespace); - 165
let name = parts.next().filter(|name| !name.is_empty())?; - 166
let command = capabilities.iter().find(|capability| { - 167
capability.kind == vak_session::CapabilityKind::Command && capability.name == name - 168
})?; - 169
let template = command - 170
.configuration - 171
.get("template") - 172
.and_then(serde_json::Value::as_str)?; - 173
Some(expand(template, parts.next().unwrap_or_default())) - 174
} - 175
- 176
#[cfg(test)] - 177
#[allow(clippy::unwrap_used, clippy::expect_used)] - 178
mod tests { - 179
use super::*; - 180
- 181
#[test] - 182
fn expand_substitutes_and_appends_arguments() { - 183
assert_eq!( - 184
expand("fix $ARGUMENTS please", "the parser"), - 185
"fix the parser please" - 186
); - 187
assert_eq!(expand("no placeholder here", ""), "no placeholder here"); - 188
assert_eq!( - 189
expand("no placeholder", "extra context"), - 190
"no placeholder\n\nArguments: extra context" - 191
); - 192
} - 193
- 194
#[test] - 195
fn discovery_precedence_project_over_user_and_valid_names_only() { - 196
let dir = tempfile::tempdir().unwrap(); - 197
let project = dir.path().join(".vak/commands"); - 198
let user = dir.path().join("home/commands"); - 199
std::fs::create_dir_all(&project).unwrap(); - 200
std::fs::create_dir_all(&user).unwrap(); - 201
std::fs::write( - 202
project.join("review.md"), - 203
"---\ndescription: project review\n---\nReview $ARGUMENTS", - 204
) - 205
.unwrap(); - 206
std::fs::write(user.join("review.md"), "# user review\nBody").unwrap(); - 207
std::fs::write(user.join("bad name.md"), "skipped").unwrap(); - 208
- 209
let cmds = discover(dir.path(), &dir.path().join("home")); - 210
assert_eq!(cmds.len(), 1, "{cmds:?}"); - 211
assert_eq!(cmds[0].name, "review"); - 212
assert_eq!(cmds[0].source, "project"); - 213
assert_eq!(cmds[0].description, "project review"); - 214
assert_eq!(cmds[0].template, "Review $ARGUMENTS"); - 215
} - 216
- 217
#[test] - 218
fn plugin_commands_are_discovered_with_namespace_label() { - 219
let dir = tempfile::tempdir().unwrap(); - 220
let plugin = dir.path().join(".vak/plugins/acme/commands"); - 221
std::fs::create_dir_all(&plugin).unwrap(); - 222
std::fs::write(plugin.join("deploy.md"), "# Ship it\nAll steps").unwrap(); - 223
- 224
let cmds = discover(dir.path(), &dir.path().join("home")); - 225
assert_eq!(cmds.len(), 1); - 226
assert_eq!(cmds[0].source, "plugin:acme"); - 227
assert_eq!(cmds[0].name, "deploy"); - 228
assert_eq!(cmds[0].description, "Ship it"); - 229
} - 230
- 231
#[test] - 232
fn invocation_expands_only_admitted_commands() { - 233
let commands = vec![vak_session::CapabilityDescriptor { - 234
name: "review".into(), - 235
kind: vak_session::CapabilityKind::Command, - 236
invocation: vak_session::CapabilityInvocation::UserCommand, - 237
description: "Review changes".into(), - 238
source: None, - 239
digest: None, - 240
provenance: Some("user".into()), - 241
configuration: serde_json::json!({"template": "Review $ARGUMENTS"}), - 242
}]; - 243
assert_eq!( - 244
expand_capability_invocation(&commands, "/review src/lib.rs"), - 245
Some("Review src/lib.rs".into()) - 246
); - 247
assert_eq!( - 248
expand_capability_invocation(&commands, "/unknown hello"), - 249
None - 250
); - 251
} - 252
- 253
#[test] - 254
fn turn_discovery_ignores_unmanaged_plugin_directory() { - 255
let dir = tempfile::tempdir().unwrap(); - 256
let plugin = dir.path().join(".vak/plugins/ghost/commands"); - 257
std::fs::create_dir_all(&plugin).unwrap(); - 258
std::fs::write(plugin.join("ghost.md"), "# Ghost\nBody").unwrap(); - 259
let commands = discover_with_plugins(dir.path(), &dir.path().join("home"), &[]); - 260
assert!(commands.is_empty()); - 261
} - 262
- 263
#[test] - 264
fn project_command_wins_over_enabled_plugin_command() { - 265
let dir = tempfile::tempdir().unwrap(); - 266
let project = dir.path().join(".vak/commands"); - 267
let package = dir.path().join("package"); - 268
std::fs::create_dir_all(&project).unwrap(); - 269
std::fs::create_dir_all(package.join("commands")).unwrap(); - 270
std::fs::write( - 271
project.join("review.md"), - 272
"---\ndescription: Project\n---\nProject", - 273
) - 274
.unwrap(); - 275
std::fs::write( - 276
package.join("commands/review.md"), - 277
"---\ndescription: Plugin\n---\nPlugin", - 278
) - 279
.unwrap(); - 280
let commands = discover_with_plugins( - 281
dir.path(), - 282
&dir.path().join("home"), - 283
&[(package, "plugin:acme:trace".into())], - 284
); - 285
assert_eq!(commands[0].source, "project"); - 286
} - 287
} - 288
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.