Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/add-manual-notation.md
Original file line number Diff line number Diff line change
@@ -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).
6 changes: 4 additions & 2 deletions src/auth_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <login|setup|status|export|logout>\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",
Expand Down Expand Up @@ -159,7 +161,7 @@ impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelega
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String, String>> + 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())
})
Expand Down
152 changes: 152 additions & 0 deletions src/helpers/script.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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")
Expand Down Expand Up @@ -70,6 +134,94 @@ TIPS:
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
Box::pin(async move {
if let Some(matches) = matches.subcommand_matches("+open") {
let script_id = matches.get_one::<String>("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::<String>("script").unwrap();
let func_name = matches.get_one::<String>("function").unwrap();
let dev_mode = matches.get_flag("dev-mode");
let params_array: serde_json::Value = match matches.get_one::<String>("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(&params_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::<String>("script").unwrap();
let dir_path = matches
Expand Down