1use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20
21use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
22use crate::capability::handler::{CapabilityError, Context};
23use crate::capability::model::{Exposure, McpExposure, McpResource, Stability};
24use crate::capability::module::ModuleDescriptor;
25use crate::command_graph::load;
26use crate::command_graph::model::{
27 CommandGraph, CommandNode, Effect, Origin, Projections, Severity,
28};
29use crate::{capability, module};
30
31use super::{get, mcp, Empty};
32
33pub const COMMANDS_URI: &str = "majordomus://commands";
35
36#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
39#[serde(deny_unknown_fields)]
40pub struct CommandFilter {
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub origin: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub effect: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub search: Option<String>,
53}
54
55impl BenchmarkCases for CommandFilter {
56 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
57 vec![
58 NamedCase::new("all", CommandFilter::default()),
59 NamedCase::new(
60 "read-only",
61 CommandFilter {
62 effect: Some("read_only".into()),
63 ..Default::default()
64 },
65 ),
66 NamedCase::new(
67 "one-program",
68 CommandFilter {
69 origin: Some("executable".into()),
70 ..Default::default()
71 },
72 ),
73 ]
74 }
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
78#[serde(deny_unknown_fields)]
79pub struct CommandRequest {
81 pub id: String,
83}
84
85impl BenchmarkCases for CommandRequest {
86 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
87 vec![NamedCase::new(
88 "worktree-status",
89 CommandRequest {
90 id: "executable.worktree.status".into(),
91 },
92 )]
93 }
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
99pub struct CommandSummary {
101 pub id: String,
103 pub invocation: String,
105 pub summary: String,
107 pub origin: String,
109 pub effect: String,
111 #[serde(skip_serializing_if = "Option::is_none")]
112 pub group: Option<String>,
114 pub projections: Projections,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
119pub struct CommandIndex {
121 pub schema: String,
123 pub fingerprint: String,
125 pub total: usize,
127 pub commands: Vec<CommandSummary>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
132pub struct CommandGraphReport {
134 pub graph: CommandGraph,
136 pub errors: usize,
138}
139
140fn graph_of(ctx: &Context) -> CommandGraph {
147 let root = std::path::Path::new(&ctx.index.repository.root);
152 let share = crate::share::Share::locate(ctx.index.share.as_deref(), root).ok();
153 load::fast_with(root, share.as_ref().map(|s| s.dir()), Some(&ctx.registry))
154}
155
156fn commands_list(ctx: &Context, filter: CommandFilter) -> Result<CommandIndex, CapabilityError> {
157 let graph = graph_of(ctx);
158 let origin = match filter.origin.as_deref() {
159 None => None,
160 Some("executable") => Some(Origin::Executable),
161 Some("tool") => Some(Origin::Tool),
162 Some("workflow") => Some(Origin::Workflow),
163 Some(other) => {
164 return Err(CapabilityError::InvalidInput(format!(
165 "no program is called `{other}`: executable, tool or workflow"
166 )))
167 }
168 };
169 let ceiling = match filter.effect.as_deref() {
170 None => None,
171 Some("read_only") => Some(Effect::ReadOnly),
172 Some("local_mutation") => Some(Effect::LocalMutation),
173 Some("repository_mutation") => Some(Effect::RepositoryMutation),
174 Some("network_mutation") => Some(Effect::NetworkMutation),
175 Some("destructive") => Some(Effect::Destructive),
176 Some(other) => {
177 return Err(CapabilityError::InvalidInput(format!(
178 "no effect is called `{other}`"
179 )))
180 }
181 };
182 let needle = filter.search.map(|s| s.to_lowercase());
183
184 let commands = graph
185 .commands
186 .iter()
187 .filter(|c| origin.is_none_or(|o| c.origin == o))
188 .filter(|c| ceiling.is_none_or(|e| c.effect <= e))
189 .filter(|c| {
190 needle
191 .as_ref()
192 .is_none_or(|t| c.haystack().contains(t.as_str()))
193 })
194 .map(summary)
195 .collect();
196
197 Ok(CommandIndex {
198 schema: graph.schema.clone(),
199 fingerprint: graph.fingerprint.clone(),
200 total: graph.commands.len(),
201 commands,
202 })
203}
204
205fn commands_get(ctx: &Context, request: CommandRequest) -> Result<CommandNode, CapabilityError> {
206 let graph = graph_of(ctx);
207 graph
208 .commands
209 .iter()
210 .find(|c| c.id.as_str() == request.id)
211 .cloned()
212 .ok_or_else(|| {
213 CapabilityError::NotFound(format!("no command carries the identity `{}`", request.id))
214 })
215}
216
217fn commands_graph(ctx: &Context, _: Empty) -> Result<CommandGraphReport, CapabilityError> {
218 let graph = graph_of(ctx);
219 let errors = graph
220 .diagnostics
221 .iter()
222 .filter(|d| d.severity == Severity::Error)
223 .count();
224 Ok(CommandGraphReport { graph, errors })
225}
226
227fn word(value: &impl Serialize) -> String {
229 serde_json::to_value(value)
230 .ok()
231 .and_then(|v| v.as_str().map(str::to_string))
232 .unwrap_or_default()
233}
234
235fn summary(node: &CommandNode) -> CommandSummary {
237 CommandSummary {
238 id: node.id.to_string(),
239 invocation: node.invocation.clone(),
240 summary: node.summary.clone(),
241 origin: word(&node.origin),
245 effect: word(&node.effect),
246 group: node.group.clone(),
247 projections: node.projections.clone(),
248 }
249}
250
251pub fn module() -> ModuleDescriptor {
255 module! {
256 id: "commands",
257 title: "Command graph",
258 description: "Every command this repository offers, from whichever program offers it: the Rust executable, the shell tool that carries the task lifecycle, and the workflows the repository declares for a person to run. Composed from the three declarations that already exist — the clap tree, the shipped command registry and the workflow runner's own dump — never from a list. Each command carries what running it changes, what it needs, where its argument values come from, and every surface that carries it, with the reason when one does not.",
259 stability: Stability::Implemented,
260 capabilities: [
261 capability! {
262 id: "commands.list",
263 title: "Every command, one line each",
264 description: "The commands this repository offers, filtered by the program that runs them, by what running them changes, or by text. A summary rather than the whole graph: enough to choose a command, and never so much that a client has to read every argument of every command to find one.",
265 input: CommandFilter,
266 output: CommandIndex,
267 stability: Stability::Implemented,
268 exposure: Exposure {
269 mcp: Some(McpExposure {
274 tool: Some("majordomus_commands".into()),
275 resource: Some(McpResource {
276 uri: COMMANDS_URI.into(),
277 name: "commands".into(),
278 }),
279 }),
280 http: get("/api/v1/commands"),
281 cli: None,
282 },
283 tags: ["commands", "introspection"],
284 handler: commands_list,
285 },
286 capability! {
287 id: "commands.get",
288 title: "One command in full",
289 description: "One command by its canonical identity: its arguments with the source of each one's values, what running it changes, what it needs, where it came from, and every surface that carries it — with the reason a machine surface withholds it when one does.",
290 input: CommandRequest,
291 output: CommandNode,
292 stability: Stability::Implemented,
293 exposure: Exposure {
294 mcp: mcp("majordomus_command"),
295 http: get("/api/v1/command"),
296 cli: None,
297 },
298 tags: ["commands", "introspection"],
299 handler: commands_get,
300 },
301 capability! {
302 id: "commands.graph",
303 title: "The whole command graph",
304 description: "The graph as one document, with its fingerprint and every diagnostic its build found: a duplicate identity, a recipe name two commands would take, an annotation that names a command which no longer exists. Deterministic — two builds over one tree produce the same document — so a client may cache against the fingerprint.",
305 input: Empty,
306 output: CommandGraphReport,
307 stability: Stability::Implemented,
308 exposure: Exposure {
309 mcp: mcp("majordomus_command_graph"),
310 http: get("/api/v1/commands/graph"),
311 cli: None,
312 },
313 tags: ["commands", "introspection", "diagnostics"],
314 handler: commands_graph,
315 }
316 ],
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
327 fn a_summary_speaks_the_filters_words() {
328 use crate::command_graph::{Effect, Origin};
329 assert_eq!(word(&Effect::LocalMutation), "local_mutation");
330 assert_eq!(word(&Effect::ReadOnly), "read_only");
331 assert_eq!(word(&Origin::Workflow), "workflow");
332 }
333
334 #[test]
341 fn the_declaration_yields_the_projections_it_claims() {
342 let m = module();
343 assert_eq!(m.id.as_str(), "commands");
344 let expected: &[(&str, &str, &str)] = &[
345 ("commands.list", "majordomus_commands", "/api/v1/commands"),
346 ("commands.get", "majordomus_command", "/api/v1/command"),
347 (
348 "commands.graph",
349 "majordomus_command_graph",
350 "/api/v1/commands/graph",
351 ),
352 ];
353 let ids: Vec<&str> = m
354 .capabilities
355 .iter()
356 .map(|e| e.capability.id.as_str())
357 .collect();
358 let want: Vec<&str> = expected.iter().map(|(id, _, _)| *id).collect();
359 assert_eq!(
360 ids, want,
361 "the module declares a different set of capabilities"
362 );
363 for (executable, (id, tool, path)) in m.capabilities.iter().zip(expected) {
364 let exposure = &executable.capability.exposure;
365 assert_eq!(
366 exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()),
367 Some(*tool),
368 "{id} lost or renamed its MCP tool"
369 );
370 assert_eq!(
371 exposure.http.as_ref().map(|h| h.path.as_str()),
372 Some(*path),
373 "{id} lost or renamed its HTTP route"
374 );
375 assert!(
376 exposure.cli.is_none(),
377 "{id} grew a command line of its own; the graph is read through the \
378 executable's own surfaces, and a command that lists commands would be \
379 the second catalogue this module exists to avoid"
380 );
381 }
382 }
383}