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