diff --git a/.changeset/add-manual-notation.md b/.changeset/add-manual-notation.md new file mode 100644 index 0000000..61506ce --- /dev/null +++ b/.changeset/add-manual-notation.md @@ -0,0 +1,10 @@ +--- +"@googleworkspace/cli": minor +--- + +feat: add [MANUAL] notation for human-interaction steps (#66) + +Added `[MANUAL]` prefix to help text and stderr hints for commands that +require human interaction (browser open, UI clicks). Also adds two new +`script` helpers: `+open` (open Apps Script editor in browser) and +`+run` (execute a function via the Apps Script Execution API). diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 852190a..1e4fbe8 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -116,12 +116,14 @@ fn token_cache_path() -> PathBuf { pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { const USAGE: &str = concat!( "Usage: gws auth \n\n", - " login Authenticate via OAuth2 (opens browser)\n", + " login [MANUAL] Authenticate via OAuth2 (opens browser)\n", " --readonly Request read-only scopes\n", " --full Request all scopes incl. pubsub + cloud-platform\n", " (may trigger restricted_client for unverified apps)\n", " --scopes Comma-separated custom scopes\n", " setup Configure GCP project + OAuth client (requires gcloud)\n", + " [MANUAL] Download OAuth client JSON from GCP Console first:\n", + " GCP Console -> APIs & Services -> Credentials -> Create OAuth client\n", " --project Use a specific GCP project\n", " status Show current authentication state\n", " export Print decrypted credentials to stdout\n", @@ -159,7 +161,7 @@ impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelega ) -> std::pin::Pin> + Send + 'a>> { Box::pin(async move { - eprintln!("Open this URL in your browser to authenticate:\n"); + eprintln!("[MANUAL] Open this URL in your browser to authenticate:\n"); eprintln!(" {url}\n"); Ok(String::new()) }) diff --git a/src/helpers/script.rs b/src/helpers/script.rs index 8685afb..c6ebeb2 100644 --- a/src/helpers/script.rs +++ b/src/helpers/script.rs @@ -23,6 +23,7 @@ use std::fs; use std::future::Future; use std::path::Path; use std::pin::Pin; +use std::process::Command as OsCommand; pub struct ScriptHelper; @@ -32,6 +33,69 @@ impl Helper for ScriptHelper { mut cmd: Command, _doc: &crate::discovery::RestDescription, ) -> Command { + cmd = cmd.subcommand( + Command::new("+open") + .about("[MANUAL] Open the Apps Script editor in your browser") + .arg( + Arg::new("script") + .long("script") + .help("Script Project ID") + .required(true) + .value_name("SCRIPT_ID"), + ) + .after_help( + "\ +EXAMPLES: + gws script +open --script SCRIPT_ID + +NOTE: + [MANUAL] This opens a browser window. AI agents should hand control + to the user for this step.", + ), + ); + cmd = cmd.subcommand( + Command::new("+run") + .about("Execute a function in an Apps Script project via the Apps Script API") + .arg( + Arg::new("script") + .long("script") + .help("Script Project ID") + .required(true) + .value_name("ID"), + ) + .arg( + Arg::new("function") + .long("function") + .help("Name of the function to run") + .required(true) + .value_name("NAME"), + ) + .arg( + Arg::new("params") + .long("params") + .help("JSON array of parameters to pass to the function") + .value_name("JSON"), + ) + .arg( + Arg::new("dev-mode") + .long("dev-mode") + .help("Run using the latest saved (not deployed) script code") + .action(clap::ArgAction::SetTrue), + ) + .after_help( + "\ +PREREQUISITES: + 1. Auth with cloud-platform scope: gws auth login --full + 2. [MANUAL] Link the script to your GCP project: + Open the script editor -> Project Settings -> Change GCP project + 3. Add to appsscript.json: \"executionApi\": {\"access\": \"MYSELF\"} + +EXAMPLES: + gws script +run --script SCRIPT_ID --function myFunction + gws script +run --script SCRIPT_ID --function myFunction --params '[\"arg1\"]' + gws script +run --script SCRIPT_ID --function myFunction --dev-mode", + ), + ); cmd = cmd.subcommand( Command::new("+push") .about("[Helper] Upload local files to an Apps Script project") @@ -70,6 +134,94 @@ TIPS: _sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig, ) -> Pin> + Send + 'a>> { Box::pin(async move { + if let Some(matches) = matches.subcommand_matches("+open") { + let script_id = matches.get_one::("script").unwrap(); + let url = format!( + "https://script.google.com/home/projects/{}/edit", + script_id + ); + eprintln!("[MANUAL] Opening Apps Script editor in your browser..."); + #[cfg(target_os = "macos")] + let _ = OsCommand::new("open").arg(&url).status(); + #[cfg(target_os = "linux")] + let _ = OsCommand::new("xdg-open").arg(&url).status(); + #[cfg(target_os = "windows")] + let _ = OsCommand::new("cmd").args(["/C", "start", &url]).status(); + println!( + "{}", + serde_json::to_string_pretty(&json!({ "status": "opened", "url": url })) + .unwrap_or_default() + ); + return Ok(true); + } + + if let Some(matches) = matches.subcommand_matches("+run") { + let script_id = matches.get_one::("script").unwrap(); + let func_name = matches.get_one::("function").unwrap(); + let dev_mode = matches.get_flag("dev-mode"); + let params_array: serde_json::Value = match matches.get_one::("params") { + Some(p) => serde_json::from_str(p).map_err(|e| { + GwsError::Validation(format!("Invalid --params JSON: {e}")) + })?, + None => json!([]), + }; + + // Find method: scripts.run + let scripts_res = doc.resources.get("scripts").ok_or_else(|| { + GwsError::Discovery("Resource 'scripts' not found".to_string()) + })?; + let run_method = scripts_res.methods.get("run").ok_or_else(|| { + GwsError::Discovery("Method 'scripts.run' not found".to_string()) + })?; + + let params = json!({ "scriptId": script_id }); + let params_str = params.to_string(); + + let body = json!({ + "function": func_name, + "parameters": params_array, + "devMode": dev_mode, + }); + let body_str = body.to_string(); + + let scopes: Vec<&str> = run_method.scopes.iter().map(|s| s.as_str()).collect(); + let (token, auth_method) = match auth::get_token(&scopes).await { + Ok(t) => (Some(t), executor::AuthMethod::OAuth), + Err(_) => (None, executor::AuthMethod::None), + }; + + let result = executor::execute_method( + doc, + run_method, + Some(¶ms_str), + Some(&body_str), + token.as_deref(), + auth_method, + None, + None, + matches.get_flag("dry-run"), + &executor::PaginationConfig::default(), + None, + &crate::helpers::modelarmor::SanitizeMode::Warn, + &crate::formatter::OutputFormat::default(), + false, + ) + .await; + + if let Err(GwsError::Api { code: 403, .. }) = &result { + eprintln!( + "[MANUAL] GCP project linking required.\n\ + \x20 Link the script to your GCP project via the script editor:\n\ + \x20 -> Project Settings -> Change GCP project\n\ + \x20 Run: gws script +open --script {script_id}\n\ + \x20 Then add to appsscript.json: \"executionApi\": {{\"access\": \"MYSELF\"}}" + ); + } + + result?; + return Ok(true); + } + if let Some(matches) = matches.subcommand_matches("+push") { let script_id = matches.get_one::("script").unwrap(); let dir_path = matches