Skip to main content

majordomus_cli/capability/builtin/
commands.rs

1//! The `commands` module: the command graph, projected.
2//!
3//! Three capabilities, all read-only, all answered by [`crate::command_graph`] — the same
4//! graph the terminal renders, the same graph the workflow bridge is written from, and the
5//! same graph a shell's completion is answered from. Declaring them here is what puts the
6//! command graph on MCP, on HTTP, in the OpenAPI document, in Swagger UI, in the Cockpit
7//! and in the generated reference, without any of those carrying a route, a schema or a
8//! sentence of its own.
9//!
10//! # Why three, and not one
11//!
12//! Because a client that wants to know what it may call should not have to read every
13//! argument of every command to find out. `commands.list` answers with one line per
14//! command; `commands.get` answers with one command in full; `commands.graph` answers with
15//! the whole document and its diagnostics. Summary first, detail on demand — the shape an
16//! agent's context needs, and the shape a command palette needs, are the same shape.
17
18use 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
33/// The URI under which the index of commands is read as an MCP resource.
34pub const COMMANDS_URI: &str = "majordomus://commands";
35
36// ---------------------------------------------------------------- inputs
37
38#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
39#[serde(deny_unknown_fields)]
40/// Which commands to answer with. Every filter is the graph's own vocabulary; none is a
41/// search over rendered text.
42pub struct CommandFilter {
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    /// Only the commands of one program: `executable`, `tool` or `workflow`.
45    pub origin: Option<String>,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    /// Only the commands whose effect is at most this one: `read_only`,
48    /// `local_mutation`, `repository_mutation`, `network_mutation`, `destructive`.
49    pub effect: Option<String>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    /// Only the commands matching this text in their invocation, summary, tags or identity.
52    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)]
79/// One command, by its canonical identity.
80pub struct CommandRequest {
81    /// The identity, `executable.worktree.status`.
82    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// ---------------------------------------------------------------- outputs
97
98#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
99/// One command, as an index shows it: enough to choose, never enough to have to skim.
100pub struct CommandSummary {
101    /// The canonical identity.
102    pub id: String,
103    /// The command line a person types.
104    pub invocation: String,
105    /// One line.
106    pub summary: String,
107    /// Which program runs it.
108    pub origin: String,
109    /// What running it changes.
110    pub effect: String,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    /// The group it is found under.
113    pub group: Option<String>,
114    /// Where it appears.
115    pub projections: Projections,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
119/// The commands this repository offers, filtered.
120pub struct CommandIndex {
121    /// The schema of the graph these came from.
122    pub schema: String,
123    /// The fingerprint of that graph: a client may cache against it.
124    pub fingerprint: String,
125    /// How many commands the graph holds, before the filter.
126    pub total: usize,
127    /// The commands that matched, in graph order.
128    pub commands: Vec<CommandSummary>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
132/// The whole graph, with what its build found.
133pub struct CommandGraphReport {
134    /// The graph.
135    pub graph: CommandGraph,
136    /// How many findings are errors; a projection refuses to write while this is not zero.
137    pub errors: usize,
138}
139
140// ---------------------------------------------------------------- handlers
141
142/// The graph this process answers from.
143///
144/// The fast load: the cache the last materialisation wrote, or the two declarations that
145/// can be read without a subprocess. A server answering a request must not spawn one.
146fn graph_of(ctx: &Context) -> CommandGraph {
147    // The share directory is where the shell tool's command registry is shipped. It is the
148    // one this process located: a second resolution from the repository root alone finds
149    // another distribution, or none when the executable lives outside the repository, and
150    // the conventions are the fallback only for an index built without an application.
151    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
227/// The serialised word of a unit variant: `local_mutation`, never `LocalMutation`.
228fn 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
235/// One line of the index.
236fn summary(node: &CommandNode) -> CommandSummary {
237    CommandSummary {
238        id: node.id.to_string(),
239        invocation: node.invocation.clone(),
240        summary: node.summary.clone(),
241        // The words the graph serialises and the filter accepts. `Debug` lowercased was
242        // `localmutation`, a word no filter takes, so every link built from a summary's
243        // effect answered 500.
244        origin: word(&node.origin),
245        effect: word(&node.effect),
246        group: node.group.clone(),
247        projections: node.projections.clone(),
248    }
249}
250
251// ---------------------------------------------------------------- the module
252
253/// The `commands` module: every command this repository offers, projected once.
254pub 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                    // A tool for a client that asks a question, and a resource for one that
270                    // wants the index in its context without asking: the summary is small by
271                    // construction — an id, a summary and an effect per command — which is
272                    // what makes it safe to read whole, and what the detail capability is for.
273                    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    /// A summary's effect is the word the filter takes: a Cockpit chip links with it, and
325    /// `localmutation` — what `Debug` lowercased gave — is refused, so the link answered 500.
326    #[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    /// The module's own documentation says three capabilities, all read-only, all answered
335    /// by the command graph. That sentence is the thing every surface then derives from:
336    /// the MCP tool names, the HTTP routes, the OpenAPI operations, the Cockpit views. A
337    /// refactor that dropped one, renamed a route or added a fourth would still compile,
338    /// and every suite that exercises the graph behind them would still pass. This is the
339    /// assertion that would not.
340    #[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}