majordomus_cli/command_graph/policy.rs
1//! The exposure policy: which surfaces carry a command, derived from what the command is.
2//!
3//! Nothing here reads a per-command configuration, because there is none. A surface is not
4//! a property a declaration sets; it is a consequence of the effect, the interactivity and
5//! the origin — and those are already on the node. The whole policy is the two functions
6//! below, so the question "why is this not an MCP tool?" has one answer in one place, and
7//! `commands explain` can quote it.
8//!
9//! The rules, in one table:
10//!
11//! | effect / interactivity | command line | workflow bridge | MCP · HTTP | Cockpit |
12//! |---|---|---|---|---|
13//! | read-only, non-interactive | yes | yes | yes, when a capability backs it | yes |
14//! | local mutation | yes | yes | yes, when a capability backs it | yes, confirmed |
15//! | repository / network mutation | yes | yes | no | no |
16//! | destructive | yes | yes | no | no |
17//! | interactive | yes | yes | no | no |
18//! | long-running | yes | yes | no | no |
19//!
20//! The command line and the workflow runner carry everything: a person at a terminal is
21//! the caller both were built for, and withholding a command from the terminal that
22//! implements it would be theatre. Every machine surface stops at local mutation, and the
23//! two that execute — MCP and HTTP — additionally require a capability behind the command,
24//! because a surface that executes must have a typed input schema to execute *from*.
25
26use super::model::{CommandNode, Effect, Interactivity, Origin, Projections, Provenance, Surface};
27
28/// Where the architecture of the command graph is documented; the page a command without
29/// one of its own points at.
30pub const DOCS_PREFIX: &str = "/docs/commands";
31
32/// The strongest effect a machine surface will carry.
33pub const MACHINE_CEILING: Effect = Effect::LocalMutation;
34
35/// The route of a command's page.
36///
37/// Not a new page tree. Each program's commands already have a reference on this site, and
38/// the rule here is only which one: the executable's command line under `/docs/cli/`, the
39/// shell tool's commands under `/commands/`, both generated from the same declarations this
40/// graph reads. A recipe has no page of its own and points at the architecture, rather than
41/// at a page that does not exist.
42pub fn docs_route(node: &CommandNode) -> String {
43 match node.origin {
44 Origin::Executable => format!("/docs/cli/{}/", node.path.join("/")),
45 Origin::Tool => format!("/commands/{}/", node.path.join("-")),
46 Origin::Workflow => format!("{DOCS_PREFIX}/"),
47 }
48}
49
50/// The workflow runner's spelling of a command path: the words joined by `-`.
51///
52/// `majordomus worktree status` becomes `worktree-status`. The rule is an algorithm and
53/// not a table, so a command added tomorrow has a recipe name today.
54pub fn workflow_name(path: &[String]) -> String {
55 path.join("-")
56}
57
58/// Words the workflow runner cannot accept as a recipe name, or that would read as one of
59/// its own options.
60///
61/// A projection that would produce one of these is qualified rather than emitted, and the
62/// graph says so. The list is a property of the runner's grammar, which is a contract, not
63/// a fact discoverable from this repository.
64pub const RESERVED_WORKFLOW_NAMES: &[&str] = &["default", "list", "help", "choose", "init"];
65
66/// Decide every projection of one node.
67///
68/// `capability` carries the MCP tool name and HTTP route the registry already declared for
69/// the capability behind this command, when there is one; the policy decides whether they
70/// may be *used*, never what they are called.
71pub fn project(
72 node: &CommandNode,
73 capability: Option<(&Option<String>, &Option<String>)>,
74) -> Projections {
75 let mut out = Projections {
76 docs: docs_route(node),
77 ..Projections::default()
78 };
79
80 if node.runnable {
81 out.cli = Some(node.invocation.clone());
82 } else {
83 // A node that only groups others still has a page and a completion, and it is
84 // still what a person types on the way to a leaf.
85 out.cli = Some(node.invocation.clone());
86 }
87
88 // The workflow runner carries the two programs' commands as a bridge. A workflow is
89 // already a recipe; projecting it back into one would be the cycle this design exists
90 // to forbid.
91 if node.runnable && node.origin != Origin::Workflow {
92 let name = workflow_name(&node.path);
93 if !RESERVED_WORKFLOW_NAMES.contains(&name.as_str()) {
94 out.workflow = Some(name);
95 }
96 }
97 if node.origin == Origin::Workflow {
98 out.workflow = Some(node.path.join("-"));
99 }
100
101 let (mut withheld, machine_ok) = machine_verdict(node);
102 if machine_ok {
103 if let Some((mcp, http)) = capability {
104 out.mcp.clone_from(mcp);
105 out.http.clone_from(http);
106 }
107 // The Cockpit renders any command a machine surface may execute; the capability
108 // page is where a generated form for it already lives.
109 if out.http.is_some() {
110 out.cockpit = Some("/cockpit/commands".into());
111 }
112 // A command the policy admits and no machine surface carries is withheld for the
113 // one remaining reason, and saying so is the point: a surface that executes needs
114 // a typed input schema to execute from, and only a capability supplies one. Left
115 // unsaid, this is the case that reads as an unexplained gap — the policy allowed
116 // it, and it is absent anyway.
117 if withheld.is_none() && out.mcp.is_none() && out.http.is_none() {
118 withheld = Some(
119 "no capability declares this command line, so no machine surface has a typed \
120 input schema to execute from"
121 .into(),
122 );
123 }
124 }
125 out.withheld = withheld;
126 out
127}
128
129/// Whether a machine surface may carry this command, and the reason when it may not.
130///
131/// The reason is the product, not the boolean: it is what `commands explain`, the Cockpit's
132/// disabled state and the completion's filtering all show, and it is written once here.
133pub fn machine_verdict(node: &CommandNode) -> (Option<String>, bool) {
134 match node.interactivity {
135 Interactivity::Interactive => (
136 Some("asks the person something; a request/response surface would hang".into()),
137 false,
138 ),
139 Interactivity::LongRunning => (
140 Some("serves until it is stopped; a request/response surface cannot carry it".into()),
141 false,
142 ),
143 Interactivity::NonInteractive => {
144 if node.effect > MACHINE_CEILING {
145 (
146 Some(format!(
147 "effect {:?} is above the machine ceiling {:?}",
148 node.effect, MACHINE_CEILING
149 )),
150 false,
151 )
152 } else if !node.runnable {
153 (
154 Some("groups other commands; nothing to execute".into()),
155 false,
156 )
157 } else {
158 (None, true)
159 }
160 }
161 }
162}
163
164/// Whether a projection on this surface should offer the command at all.
165///
166/// A completion asks this before it offers a candidate, so an unavailable command is not
167/// suggested prominently — and the reason it carries is what a shell that can show one
168/// displays.
169pub fn offered_on(node: &CommandNode, surface: Surface) -> bool {
170 match surface {
171 Surface::Cli => true,
172 Surface::Workflow => node.projections.workflow.is_some(),
173 Surface::Mcp => node.projections.mcp.is_some(),
174 Surface::Http => node.projections.http.is_some(),
175 }
176}
177
178/// The provenance of a node whose declaration is one file.
179pub fn declared_in(path: &str) -> Provenance {
180 Provenance {
181 declared_in: path.into(),
182 read_by: None,
183 capability: None,
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use crate::capability::Stability;
191 use crate::command_graph::model::{Availability, CommandId, Execution};
192
193 fn node(effect: Effect, interactivity: Interactivity, runnable: bool) -> CommandNode {
194 let path = vec!["worktree".to_string(), "status".to_string()];
195 CommandNode {
196 id: CommandId::derive(Origin::Executable, &path),
197 origin: Origin::Executable,
198 invocation: "majordomus worktree status".into(),
199 summary: "s".into(),
200 description: None,
201 runnable,
202 arguments: Vec::new(),
203 execution: Execution {
204 origin: Origin::Executable,
205 argv: path.clone(),
206 },
207 effect,
208 interactivity,
209 stability: Stability::Implemented,
210 availability: Availability::always(),
211 group: None,
212 tags: Vec::new(),
213 aliases: Vec::new(),
214 deprecation: None,
215 provenance: declared_in("apps/majordomus-cli/src/cli.rs"),
216 projections: Projections::default(),
217 entrypoint: None,
218 path,
219 }
220 }
221
222 #[test]
223 fn read_only_reaches_every_surface() {
224 let n = node(Effect::ReadOnly, Interactivity::NonInteractive, true);
225 let mcp = Some("majordomus_worktree_status".to_string());
226 let http = Some("/api/v1/worktree".to_string());
227 let p = project(&n, Some((&mcp, &http)));
228 assert_eq!(p.cli.as_deref(), Some("majordomus worktree status"));
229 assert_eq!(p.workflow.as_deref(), Some("worktree-status"));
230 assert_eq!(p.mcp, mcp);
231 assert_eq!(p.http, http);
232 assert!(p.withheld.is_none());
233 assert_eq!(p.docs, "/docs/cli/worktree/status/");
234 }
235
236 #[test]
237 fn a_repository_mutation_is_withheld_from_machines_with_a_reason() {
238 let n = node(
239 Effect::RepositoryMutation,
240 Interactivity::NonInteractive,
241 true,
242 );
243 let mcp = Some("t".to_string());
244 let http = Some("/api/v1/x".to_string());
245 let p = project(&n, Some((&mcp, &http)));
246 assert!(p.mcp.is_none() && p.http.is_none());
247 assert!(p.withheld.is_some());
248 // and the terminal still has it
249 assert!(p.cli.is_some() && p.workflow.is_some());
250 }
251
252 #[test]
253 fn a_server_is_never_a_machine_tool() {
254 let n = node(Effect::ReadOnly, Interactivity::LongRunning, true);
255 let (why, ok) = machine_verdict(&n);
256 assert!(!ok);
257 assert!(why.unwrap().contains("stopped"));
258 }
259
260 #[test]
261 fn a_workflow_is_never_bridged_back_into_a_workflow() {
262 let mut n = node(Effect::ReadOnly, Interactivity::NonInteractive, true);
263 n.origin = Origin::Workflow;
264 n.path = vec!["site-build".into()];
265 n.execution.origin = Origin::Workflow;
266 let p = project(&n, None);
267 assert_eq!(p.workflow.as_deref(), Some("site-build"));
268 }
269}