New skill for Google Workspace administration via the gws CLI: - SKILL.md with 4 workflows (Gmail, Drive/Sheets, Calendar, Security Audit) - 5 stdlib-only Python scripts (doctor, auth setup, recipe runner, audit, analyzer) - 3 reference docs, 2 asset files, 43 built-in recipes, 10 persona bundles - cs-workspace-admin agent, /google-workspace slash command - Standalone marketplace plugin entry with .claude-plugin/plugin.json - Cross-platform sync (Codex CLI, Gemini CLI), MkDocs docs pages - All documentation updated (173 skills, 250 tools, 15 agents, 15 commands) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
400 lines
20 KiB
Python
400 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Google Workspace CLI Recipe Runner — Catalog, search, and execute gws recipes.
|
|
|
|
Browse 43 built-in recipes, filter by persona, search by keyword,
|
|
and run with dry-run support.
|
|
|
|
Usage:
|
|
python3 gws_recipe_runner.py --list
|
|
python3 gws_recipe_runner.py --search "email"
|
|
python3 gws_recipe_runner.py --describe standup-report
|
|
python3 gws_recipe_runner.py --run standup-report --dry-run
|
|
python3 gws_recipe_runner.py --persona pm --list
|
|
python3 gws_recipe_runner.py --list --json
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import dataclass, field, asdict
|
|
from typing import List, Dict, Optional
|
|
|
|
|
|
@dataclass
|
|
class Recipe:
|
|
name: str
|
|
description: str
|
|
category: str
|
|
services: List[str]
|
|
commands: List[str]
|
|
prerequisites: str = ""
|
|
|
|
|
|
RECIPES: Dict[str, Recipe] = {
|
|
# Email (8)
|
|
"send-email": Recipe("send-email", "Send an email with optional attachments", "email",
|
|
["gmail"], ["gws gmail users.messages send me --to {to} --subject {subject} --body {body}"]),
|
|
"reply-to-thread": Recipe("reply-to-thread", "Reply to an existing email thread", "email",
|
|
["gmail"], ["gws gmail users.messages reply me --thread-id {thread_id} --body {body}"]),
|
|
"forward-email": Recipe("forward-email", "Forward an email to another recipient", "email",
|
|
["gmail"], ["gws gmail users.messages forward me --message-id {msg_id} --to {to}"]),
|
|
"search-emails": Recipe("search-emails", "Search emails with Gmail query syntax", "email",
|
|
["gmail"], ["gws gmail users.messages list me --query {query} --json"]),
|
|
"archive-old": Recipe("archive-old", "Archive read emails older than N days", "email",
|
|
["gmail"], [
|
|
"gws gmail users.messages list me --query 'is:read older_than:{days}d' --json",
|
|
"# Pipe IDs to batch modify to remove INBOX label",
|
|
]),
|
|
"label-manager": Recipe("label-manager", "Create, list, and organize Gmail labels", "email",
|
|
["gmail"], ["gws gmail users.labels list me --json", "gws gmail users.labels create me --name {name}"]),
|
|
"filter-setup": Recipe("filter-setup", "Create email filters for auto-labeling", "email",
|
|
["gmail"], ["gws gmail users.settings.filters create me --criteria {criteria} --action {action}"]),
|
|
"unread-digest": Recipe("unread-digest", "Get digest of unread emails", "email",
|
|
["gmail"], ["gws gmail users.messages list me --query 'is:unread' --limit 20 --json"]),
|
|
|
|
# Files (7)
|
|
"upload-file": Recipe("upload-file", "Upload a file to Google Drive", "files",
|
|
["drive"], ["gws drive files create --name {name} --upload {path} --parents {folder_id}"]),
|
|
"create-sheet": Recipe("create-sheet", "Create a new Google Spreadsheet", "files",
|
|
["sheets"], ["gws sheets spreadsheets create --title {title} --json"]),
|
|
"share-file": Recipe("share-file", "Share a Drive file with a user or domain", "files",
|
|
["drive"], ["gws drive permissions create {file_id} --type user --role writer --emailAddress {email}"]),
|
|
"export-file": Recipe("export-file", "Export a Google Doc/Sheet as PDF", "files",
|
|
["drive"], ["gws drive files export {file_id} --mime application/pdf --output {output}"]),
|
|
"list-files": Recipe("list-files", "List files in a Drive folder", "files",
|
|
["drive"], ["gws drive files list --parents {folder_id} --json"]),
|
|
"find-large-files": Recipe("find-large-files", "Find largest files in Drive", "files",
|
|
["drive"], ["gws drive files list --orderBy 'quotaBytesUsed desc' --limit 20 --json"]),
|
|
"cleanup-trash": Recipe("cleanup-trash", "Empty Drive trash", "files",
|
|
["drive"], ["gws drive files emptyTrash"]),
|
|
|
|
# Calendar (6)
|
|
"create-event": Recipe("create-event", "Create a calendar event with attendees", "calendar",
|
|
["calendar"], [
|
|
"gws calendar events insert primary --summary {title} "
|
|
"--start {start} --end {end} --attendees {attendees}"
|
|
]),
|
|
"quick-event": Recipe("quick-event", "Create event from natural language", "calendar",
|
|
["calendar"], ["gws helpers quick-event {text}"]),
|
|
"find-time": Recipe("find-time", "Find available time slots for a meeting", "calendar",
|
|
["calendar"], ["gws helpers find-time --attendees {attendees} --duration {minutes} --within {date_range}"]),
|
|
"today-schedule": Recipe("today-schedule", "Show today's calendar events", "calendar",
|
|
["calendar"], ["gws calendar events list primary --timeMin {today_start} --timeMax {today_end} --json"]),
|
|
"meeting-prep": Recipe("meeting-prep", "Prepare for an upcoming meeting (agenda + attendees)", "calendar",
|
|
["calendar"], ["gws recipes meeting-prep --event-id {event_id}"]),
|
|
"reschedule": Recipe("reschedule", "Move an event to a new time", "calendar",
|
|
["calendar"], ["gws calendar events patch primary {event_id} --start {new_start} --end {new_end}"]),
|
|
|
|
# Reporting (5)
|
|
"standup-report": Recipe("standup-report", "Generate daily standup from calendar and tasks", "reporting",
|
|
["calendar", "tasks"], ["gws recipes standup-report --json"]),
|
|
"weekly-summary": Recipe("weekly-summary", "Summarize week's emails, events, and tasks", "reporting",
|
|
["gmail", "calendar", "tasks"], ["gws recipes weekly-summary --json"]),
|
|
"drive-activity": Recipe("drive-activity", "Report on Drive file activity", "reporting",
|
|
["drive"], ["gws drive activities list --json"]),
|
|
"email-stats": Recipe("email-stats", "Email volume statistics", "reporting",
|
|
["gmail"], [
|
|
"gws gmail users.messages list me --query 'newer_than:7d' --json",
|
|
"# Pipe through output_analyzer.py --count",
|
|
]),
|
|
"task-progress": Recipe("task-progress", "Report on task completion", "reporting",
|
|
["tasks"], ["gws tasks tasks list {tasklist_id} --json"]),
|
|
|
|
# Collaboration (5)
|
|
"share-folder": Recipe("share-folder", "Share a Drive folder with a team", "collaboration",
|
|
["drive"], ["gws drive permissions create {folder_id} --type group --role writer --emailAddress {group}"]),
|
|
"create-doc": Recipe("create-doc", "Create a Google Doc with initial content", "collaboration",
|
|
["docs"], ["gws docs documents create --title {title} --json"]),
|
|
"chat-message": Recipe("chat-message", "Send a message to a Google Chat space", "collaboration",
|
|
["chat"], ["gws chat spaces.messages create {space} --text {message}"]),
|
|
"list-spaces": Recipe("list-spaces", "List Google Chat spaces", "collaboration",
|
|
["chat"], ["gws chat spaces list --json"]),
|
|
"task-create": Recipe("task-create", "Create a task in Google Tasks", "collaboration",
|
|
["tasks"], ["gws tasks tasks insert {tasklist_id} --title {title} --due {due_date}"]),
|
|
|
|
# Data (4)
|
|
"sheet-read": Recipe("sheet-read", "Read data from a spreadsheet range", "data",
|
|
["sheets"], ["gws sheets spreadsheets.values get {sheet_id} --range {range} --json"]),
|
|
"sheet-write": Recipe("sheet-write", "Write data to a spreadsheet", "data",
|
|
["sheets"], ["gws sheets spreadsheets.values update {sheet_id} --range {range} --values {data}"]),
|
|
"sheet-append": Recipe("sheet-append", "Append rows to a spreadsheet", "data",
|
|
["sheets"], ["gws sheets spreadsheets.values append {sheet_id} --range {range} --values {data}"]),
|
|
"export-contacts": Recipe("export-contacts", "Export contacts list", "data",
|
|
["people"], ["gws people people.connections list me --personFields names,emailAddresses --json"]),
|
|
|
|
# Admin (4)
|
|
"list-users": Recipe("list-users", "List all users in the Workspace domain", "admin",
|
|
["admin"], ["gws admin users list --domain {domain} --json"],
|
|
"Requires Admin SDK API and admin.directory.user.readonly scope"),
|
|
"list-groups": Recipe("list-groups", "List all groups in the domain", "admin",
|
|
["admin"], ["gws admin groups list --domain {domain} --json"]),
|
|
"user-info": Recipe("user-info", "Get detailed user information", "admin",
|
|
["admin"], ["gws admin users get {email} --json"]),
|
|
"audit-logins": Recipe("audit-logins", "Audit recent login activity", "admin",
|
|
["admin"], ["gws admin activities list login --json"]),
|
|
|
|
# Cross-Service (4)
|
|
"morning-briefing": Recipe("morning-briefing", "Today's events + unread emails + pending tasks", "cross-service",
|
|
["gmail", "calendar", "tasks"], [
|
|
"gws calendar events list primary --timeMin {today} --maxResults 10 --json",
|
|
"gws gmail users.messages list me --query 'is:unread' --limit 10 --json",
|
|
"gws tasks tasks list {default_tasklist} --json",
|
|
]),
|
|
"eod-wrap": Recipe("eod-wrap", "End-of-day wrap up: summarize completed, pending, tomorrow", "cross-service",
|
|
["calendar", "tasks"], [
|
|
"gws calendar events list primary --timeMin {today_start} --timeMax {today_end} --json",
|
|
"gws tasks tasks list {default_tasklist} --json",
|
|
]),
|
|
"project-status": Recipe("project-status", "Aggregate project status from Drive, Sheets, Tasks", "cross-service",
|
|
["drive", "sheets", "tasks"], [
|
|
"gws drive files list --query 'name contains {project}' --json",
|
|
"gws tasks tasks list {tasklist_id} --json",
|
|
]),
|
|
"inbox-zero": Recipe("inbox-zero", "Process inbox to zero: label, archive, reply, task", "cross-service",
|
|
["gmail", "tasks"], [
|
|
"gws gmail users.messages list me --query 'is:inbox' --json",
|
|
"# Process each: label, archive, or create task",
|
|
]),
|
|
}
|
|
|
|
PERSONAS: Dict[str, Dict] = {
|
|
"executive-assistant": {
|
|
"description": "Executive assistant managing schedules, emails, and communications",
|
|
"recipes": ["morning-briefing", "today-schedule", "find-time", "send-email", "reply-to-thread",
|
|
"standup-report", "meeting-prep", "eod-wrap", "quick-event", "inbox-zero"],
|
|
},
|
|
"pm": {
|
|
"description": "Project manager tracking tasks, meetings, and deliverables",
|
|
"recipes": ["standup-report", "create-event", "find-time", "task-create", "task-progress",
|
|
"project-status", "weekly-summary", "share-folder", "sheet-read", "morning-briefing"],
|
|
},
|
|
"hr": {
|
|
"description": "HR managing people, onboarding, and communications",
|
|
"recipes": ["list-users", "user-info", "send-email", "create-event", "create-doc",
|
|
"share-folder", "chat-message", "list-groups", "export-contacts", "today-schedule"],
|
|
},
|
|
"sales": {
|
|
"description": "Sales rep managing client communications and proposals",
|
|
"recipes": ["send-email", "search-emails", "create-event", "find-time", "create-doc",
|
|
"share-file", "sheet-read", "sheet-write", "export-file", "morning-briefing"],
|
|
},
|
|
"it-admin": {
|
|
"description": "IT administrator managing Workspace configuration and security",
|
|
"recipes": ["list-users", "list-groups", "user-info", "audit-logins", "drive-activity",
|
|
"find-large-files", "cleanup-trash", "label-manager", "filter-setup", "share-folder"],
|
|
},
|
|
"developer": {
|
|
"description": "Developer using Workspace APIs for automation",
|
|
"recipes": ["sheet-read", "sheet-write", "sheet-append", "upload-file", "create-doc",
|
|
"chat-message", "task-create", "list-files", "export-file", "send-email"],
|
|
},
|
|
"marketing": {
|
|
"description": "Marketing team member managing campaigns and content",
|
|
"recipes": ["send-email", "create-doc", "share-file", "upload-file", "create-sheet",
|
|
"sheet-write", "chat-message", "create-event", "email-stats", "weekly-summary"],
|
|
},
|
|
"finance": {
|
|
"description": "Finance team managing spreadsheets and reports",
|
|
"recipes": ["sheet-read", "sheet-write", "sheet-append", "create-sheet", "export-file",
|
|
"share-file", "send-email", "find-large-files", "drive-activity", "weekly-summary"],
|
|
},
|
|
"legal": {
|
|
"description": "Legal team managing documents and compliance",
|
|
"recipes": ["create-doc", "share-file", "export-file", "search-emails", "send-email",
|
|
"upload-file", "list-files", "drive-activity", "audit-logins", "find-large-files"],
|
|
},
|
|
"support": {
|
|
"description": "Customer support managing tickets and communications",
|
|
"recipes": ["search-emails", "send-email", "reply-to-thread", "label-manager", "filter-setup",
|
|
"task-create", "chat-message", "unread-digest", "inbox-zero", "morning-briefing"],
|
|
},
|
|
}
|
|
|
|
|
|
def list_recipes(persona: Optional[str], output_json: bool):
|
|
"""List all recipes, optionally filtered by persona."""
|
|
if persona:
|
|
if persona not in PERSONAS:
|
|
print(f"Unknown persona: {persona}. Available: {', '.join(PERSONAS.keys())}")
|
|
sys.exit(1)
|
|
recipe_names = PERSONAS[persona]["recipes"]
|
|
recipes = {k: v for k, v in RECIPES.items() if k in recipe_names}
|
|
title = f"Recipes for {persona.upper()}: {PERSONAS[persona]['description']}"
|
|
else:
|
|
recipes = RECIPES
|
|
title = "All 43 Google Workspace CLI Recipes"
|
|
|
|
if output_json:
|
|
output = []
|
|
for name, r in recipes.items():
|
|
output.append(asdict(r))
|
|
print(json.dumps(output, indent=2))
|
|
return
|
|
|
|
print(f"\n{'='*60}")
|
|
print(f" {title}")
|
|
print(f"{'='*60}\n")
|
|
|
|
by_category: Dict[str, list] = {}
|
|
for name, r in recipes.items():
|
|
by_category.setdefault(r.category, []).append(r)
|
|
|
|
for cat, cat_recipes in sorted(by_category.items()):
|
|
print(f" {cat.upper()} ({len(cat_recipes)})")
|
|
for r in cat_recipes:
|
|
svcs = ",".join(r.services)
|
|
print(f" {r.name:<24} {r.description:<40} [{svcs}]")
|
|
print()
|
|
|
|
print(f" Total: {len(recipes)} recipes")
|
|
print(f"\n{'='*60}\n")
|
|
|
|
|
|
def search_recipes(keyword: str, output_json: bool):
|
|
"""Search recipes by keyword."""
|
|
keyword_lower = keyword.lower()
|
|
matches = {k: v for k, v in RECIPES.items()
|
|
if keyword_lower in k.lower()
|
|
or keyword_lower in v.description.lower()
|
|
or keyword_lower in v.category.lower()
|
|
or any(keyword_lower in s for s in v.services)}
|
|
|
|
if output_json:
|
|
print(json.dumps([asdict(r) for r in matches.values()], indent=2))
|
|
return
|
|
|
|
print(f"\n Search results for '{keyword}': {len(matches)} matches\n")
|
|
for name, r in matches.items():
|
|
print(f" {r.name:<24} {r.description}")
|
|
print()
|
|
|
|
|
|
def describe_recipe(name: str, output_json: bool):
|
|
"""Show full details for a recipe."""
|
|
recipe = RECIPES.get(name)
|
|
if not recipe:
|
|
print(f"Unknown recipe: {name}")
|
|
print(f"Use --list to see available recipes")
|
|
sys.exit(1)
|
|
|
|
if output_json:
|
|
print(json.dumps(asdict(recipe), indent=2))
|
|
return
|
|
|
|
print(f"\n{'='*60}")
|
|
print(f" Recipe: {recipe.name}")
|
|
print(f"{'='*60}\n")
|
|
print(f" Description: {recipe.description}")
|
|
print(f" Category: {recipe.category}")
|
|
print(f" Services: {', '.join(recipe.services)}")
|
|
if recipe.prerequisites:
|
|
print(f" Prerequisites: {recipe.prerequisites}")
|
|
print(f"\n Commands:")
|
|
for i, cmd in enumerate(recipe.commands, 1):
|
|
print(f" {i}. {cmd}")
|
|
print(f"\n{'='*60}\n")
|
|
|
|
|
|
def run_recipe(name: str, dry_run: bool):
|
|
"""Execute a recipe (or print commands in dry-run mode)."""
|
|
recipe = RECIPES.get(name)
|
|
if not recipe:
|
|
print(f"Unknown recipe: {name}")
|
|
sys.exit(1)
|
|
|
|
if dry_run:
|
|
print(f"\n [DRY RUN] Recipe: {recipe.name}\n")
|
|
for i, cmd in enumerate(recipe.commands, 1):
|
|
print(f" {i}. {cmd}")
|
|
print(f"\n (No commands executed)")
|
|
return
|
|
|
|
print(f"\n Executing recipe: {recipe.name}\n")
|
|
for cmd in recipe.commands:
|
|
if cmd.startswith("#"):
|
|
print(f" {cmd}")
|
|
continue
|
|
print(f" $ {cmd}")
|
|
try:
|
|
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30)
|
|
if result.stdout:
|
|
print(result.stdout)
|
|
if result.returncode != 0 and result.stderr:
|
|
print(f" Error: {result.stderr.strip()[:200]}")
|
|
except subprocess.TimeoutExpired:
|
|
print(f" Timeout after 30s")
|
|
except OSError as e:
|
|
print(f" Execution error: {e}")
|
|
|
|
|
|
def list_personas(output_json: bool):
|
|
"""List all available personas."""
|
|
if output_json:
|
|
print(json.dumps(PERSONAS, indent=2))
|
|
return
|
|
|
|
print(f"\n{'='*60}")
|
|
print(f" 10 PERSONA BUNDLES")
|
|
print(f"{'='*60}\n")
|
|
for name, p in PERSONAS.items():
|
|
print(f" {name:<24} {p['description']}")
|
|
print(f" {'':24} Recipes: {', '.join(p['recipes'][:5])}...")
|
|
print()
|
|
print(f"{'='*60}\n")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Catalog, search, and execute Google Workspace CLI recipes",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
%(prog)s --list # List all 43 recipes
|
|
%(prog)s --list --persona pm # Recipes for project managers
|
|
%(prog)s --search "email" # Search by keyword
|
|
%(prog)s --describe standup-report # Full recipe details
|
|
%(prog)s --run standup-report --dry-run # Preview recipe commands
|
|
%(prog)s --personas # List all 10 personas
|
|
%(prog)s --list --json # JSON output
|
|
""",
|
|
)
|
|
parser.add_argument("--list", action="store_true", help="List all recipes")
|
|
parser.add_argument("--search", help="Search recipes by keyword")
|
|
parser.add_argument("--describe", help="Show full details for a recipe")
|
|
parser.add_argument("--run", help="Execute a recipe")
|
|
parser.add_argument("--dry-run", action="store_true", help="Print commands without executing")
|
|
parser.add_argument("--persona", help="Filter recipes by persona")
|
|
parser.add_argument("--personas", action="store_true", help="List all personas")
|
|
parser.add_argument("--json", action="store_true", help="Output JSON")
|
|
args = parser.parse_args()
|
|
|
|
if not any([args.list, args.search, args.describe, args.run, args.personas]):
|
|
parser.print_help()
|
|
return
|
|
|
|
if args.personas:
|
|
list_personas(args.json)
|
|
return
|
|
|
|
if args.list:
|
|
list_recipes(args.persona, args.json)
|
|
return
|
|
|
|
if args.search:
|
|
search_recipes(args.search, args.json)
|
|
return
|
|
|
|
if args.describe:
|
|
describe_recipe(args.describe, args.json)
|
|
return
|
|
|
|
if args.run:
|
|
run_recipe(args.run, args.dry_run)
|
|
return
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|