grr_cli/cli.rs
1//! grr — Google tools from the terminal, at maximum performance.
2//!
3//! One binary, one login, every service. The command surface has two
4//! layers:
5//!
6//! * a thin, hand-written set of account-level commands — `auth`, `api`,
7//! `mcp`, `transport`, `schema`, `completions`, `ask`, `skills` — kept
8//! as clap derive types;
9//! * the ENTIRE generated service command tree, built from the committed
10//! Discovery index into `commands/generated.rs`, because the index is
11//! the single source of truth for the CLI surface and it changes daily.
12//!
13//! Wiring: clap's derive cannot express a runtime-generated tree, and
14//! `external_subcommand` would forfeit typed flags and per-leaf `--help`.
15//! So [`root_command`] composes the derive's static commands with the
16//! generated service commands onto ONE `Command`, parses once with
17//! `get_matches()`, and dispatch matches the static names first —
18//! everything else is a generated service and goes through
19//! `gen_dispatch::dispatch_with_profile`, which shares the call path with
20//! `grr api`.
21
22use crate::core::ConfigLoader;
23use anyhow::Result;
24use clap::{CommandFactory, FromArgMatches, Parser, Subcommand};
25use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
26
27use crate::commands::{
28 api, ask, auth, build_auth, completions, gen_dispatch, generated, mcp, safety, skills,
29 transport,
30};
31use crate::logo;
32use crate::schema;
33use auth::AuthCommands;
34
35// The `--version` banner lives in `logo`: the semver, then the mascot
36// rendered as well as the terminal allows, then the tagline. clap
37// prefixes the whole string with `grr `, and the semver must stay on the
38// FIRST line — see that module for the three consumers that parse it.
39
40#[derive(Parser)]
41#[command(
42 name = "grr",
43 version = logo::version_banner(),
44 about = "Google tools from the terminal, at maximum performance"
45)]
46struct Cli {
47 #[command(subcommand)]
48 command: Option<StaticCommands>,
49 // NOTE: no global --format flag on purpose. A global `format` collides
50 // by ID with per-command `format` args of *different types*, which
51 // panics clap's downcast at runtime (0xC0000409). Every subcommand
52 // declares its own -f/--format.
53}
54
55#[derive(Subcommand)]
56enum StaticCommands {
57 /// Google account authentication (PKCE browser flow; --device for headless)
58 #[command(subcommand, subcommand_required = true)]
59 Auth(auth::AuthCommands),
60
61 /// Discovery-driven access to every Google Workspace method, including
62 /// the ones with no dedicated command. `grr api list` to browse.
63 #[command(subcommand)]
64 Api(api::ApiCommands),
65
66 /// Start a Model Context Protocol server over stdio, exposing every
67 /// grr method as a typed MCP tool. --readonly for a read-only server.
68 Mcp(mcp::McpArgs),
69
70 /// Show negotiated transport protocol and runtime features
71 Transport(transport::TransportArgs),
72
73 /// Dump the full command tree as JSON (machine-readable contract)
74 Schema(schema::SchemaArgs),
75
76 /// Print a shell completion script for the full command tree
77 /// (bash, elvish, powershell, zsh)
78 Completions(completions::CompletionsArgs),
79
80 /// Natural-language entry point: a System One model (Jev by default)
81 /// picks the method from the discovery catalog and fills its
82 /// parameters. Prints the plan; --run executes it.
83 Ask(ask::AskArgs),
84
85 /// Install the packaged agent skills into the user-level skills
86 /// directory (~/.agents/skills); list shows what is installed there
87 #[command(subcommand, subcommand_required = true)]
88 Skills(skills::SkillsCommands),
89}
90
91/// The complete parse tree: the eight static commands plus every generated
92/// service command. `grr --help`, `grr schema` and `grr completions` all
93/// read this, so it is the single definition of the CLI surface.
94///
95/// The safety flags are `global(true)`: clap merges global args across
96/// levels, so `--readonly` parses whether it appears before the subcommand
97/// (`grr --readonly gmail …`) or after it
98/// (`grr gmail users messages list --readonly`), and is readable from the
99/// root matches either way.
100pub(crate) fn root_command() -> clap::Command {
101 Cli::command()
102 .subcommands(generated::tree::commands())
103 .args(safety::global_args())
104}
105
106pub async fn run() -> Result<()> {
107 tracing_subscriber::registry()
108 .with(tracing_subscriber::EnvFilter::new(
109 // Default: info, but silence quinn_udp's harmless IPv6
110 // network-unreachable warnings on v6-less networks (v4 wins).
111 std::env::var("RUST_LOG").unwrap_or_else(|_| "info,quinn_udp=error".into()),
112 ))
113 // Logs go to stderr so stdout stays pure machine-readable output
114 // (`grr gmail profile | jq` must not receive log lines).
115 .with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
116 .init();
117
118 let matches = root_command().get_matches();
119
120 // Parse dispatch: the static commands first (they are few and
121 // fixed); any other matched name is a generated service, which clap
122 // has already validated against the registered tree.
123 let Some((name, sub)) = matches.subcommand() else {
124 // Bare `grr` prints help and exits 0 (winget's portable-installer
125 // validator launches the binary with no args and flags exit code 2
126 // as Validation-Executable-Error).
127 root_command().print_help()?;
128 println!();
129 return Ok(());
130 };
131
132 // Self-migrating installs: if the skills manifest records a different grr
133 // version, run the smart pass before the requested command. This is
134 // LAZY — `skills::maybe_auto_migrate` reads the manifest and compares the
135 // version before hashing a single file, and does nothing at all when no
136 // manifest exists. Skipped for `skills` itself (it manages its own
137 // install/list) and for `completions`, which shells commonly run on
138 // every startup (`source <(grr completions zsh)`) and so must not write
139 // files or print to the terminal; unreachable for `--help`/`--version`,
140 // which clap handles and exits from inside `get_matches`. stdout stays
141 // pure: the one-line summary goes to stderr.
142 if !matches!(name, "skills" | "completions")
143 && let Some(summary) = skills::maybe_auto_migrate()
144 {
145 eprintln!("{summary}");
146 }
147
148 match name {
149 // The schema dump is pure clap introspection: it must answer with
150 // zero configuration, before any client (or OAuth) exists.
151 "schema" => {
152 let args = schema::SchemaArgs::from_arg_matches(sub)?;
153 schema::handle_schema_cmd(root_command(), args)?;
154 }
155
156 // Completion scripts are clap introspection too, so they need no
157 // configuration and answer before it is loaded.
158 "completions" => {
159 let args = completions::CompletionsArgs::from_arg_matches(sub)?;
160 completions::handle_completions_cmd(root_command(), args)?;
161 }
162
163 // The skills are compiled into the binary (`include_str!`), so
164 // install/list need zero configuration, no OAuth client, and no
165 // network — like `schema`, they answer before the config is loaded.
166 "skills" => {
167 skills::handle_skills_cmd(skills::SkillsCommands::from_arg_matches(sub)?)?;
168 }
169
170 // `auth setup` writes the OAuth client, so it too must run before
171 // the config is loaded — otherwise a user with no client could
172 // never run it.
173 "auth" => {
174 let cmd = AuthCommands::from_arg_matches(sub)?;
175 if let AuthCommands::Setup(args) = &cmd {
176 return crate::commands::setup::handle_setup(args.clone()).await;
177 }
178 let config = ConfigLoader::load().await?;
179 let auth = build_auth(&config).await?;
180 auth::handle_auth_cmd(&auth, cmd).await?;
181 }
182
183 // The transport probe needs an auth handle (it issues a real
184 // authenticated request) but no typed service client.
185 "transport" => {
186 let config = ConfigLoader::load().await?;
187 let auth = build_auth(&config).await?;
188 transport::handle_transport_cmd(auth, transport::TransportArgs::from_arg_matches(sub)?)
189 .await?;
190 }
191
192 "api" => {
193 let cmd = api::ApiCommands::from_arg_matches(sub)?;
194 // `api list` / `describe` / `refresh` are pure introspection over
195 // the embedded index: they must work with zero configuration —
196 // a fresh CI runner has no OAuth client, and these commands
197 // would otherwise die at the client gate before doing anything.
198 // `call` is credential-OPTIONAL the same way: --dry-run is a
199 // plan-only operation, and a real send with no client reports
200 // the actionable help from the call path.
201 if matches!(cmd, api::ApiCommands::Call(_)) {
202 let safety_args = safety::SafetyArgs::from_arg_matches(&matches)?;
203 let config = ConfigLoader::load().await?;
204 let auth = build_auth(&config).await.ok();
205 api::handle_api_cmd(
206 auth.as_ref(),
207 cmd,
208 &safety::SafetyProfile::from_args(&safety_args),
209 )
210 .await?;
211 } else {
212 // Permissive profile: list/describe/refresh cannot write.
213 api::handle_api_cmd(None, cmd, &safety::SafetyProfile::PERMISSIVE).await?;
214 }
215 }
216
217 // The MCP server is self-contained: it loads the config, builds its
218 // own auth handle, and serves the JSON-RPC stream itself.
219 "mcp" => {
220 let args = mcp::McpArgs::from_arg_matches(sub)?;
221 mcp::run_mcp(args).await?;
222 }
223
224 // The ask flow plans by default (no credential needed); --run
225 // builds the auth handle and executes through the shared call
226 // path. The safety gate fires as soon as the method is resolved.
227 "ask" => {
228 let args = ask::AskArgs::from_arg_matches(sub)?;
229 let config = ConfigLoader::load().await?;
230 let auth = if args.run {
231 Some(build_auth(&config).await?)
232 } else {
233 None
234 };
235 let safety_args = safety::SafetyArgs::from_arg_matches(&matches)?;
236 ask::handle_ask(
237 &config,
238 auth.as_ref(),
239 args,
240 &safety::SafetyProfile::from_args(&safety_args),
241 )
242 .await?;
243 }
244
245 // A generated service. Dispatch walks the matched chain itself
246 // (the deepest subcommand's name IS the full dotted method id),
247 // so it gets the full root matches. The safety profile comes from
248 // the global --readonly/--deny-service/--deny-verb flags.
249 //
250 // The credential is OPTIONAL: --dry-run plans never consult it
251 // (call_method requires it only when actually sending), so a fresh
252 // CI runner — no OAuth client, no config — can exercise the whole
253 // tree offline. A real send with no client gets the actionable
254 // NO_CLIENT_HELP from the call path.
255 _ => {
256 let safety_args = safety::SafetyArgs::from_arg_matches(&matches)?;
257 let config = ConfigLoader::load().await?;
258 let auth = build_auth(&config).await.ok();
259 gen_dispatch::dispatch_with_profile(
260 &matches,
261 auth.as_ref(),
262 &safety::SafetyProfile::from_args(&safety_args),
263 )
264 .await?;
265 }
266 }
267
268 Ok(())
269}