feat: initial release — GARC v0.1.0

Permission-first AI agent runtime for Google Workspace.
Ports the LARC/OpenClaw governance model (disclosure chain,
execution gates, queue/ingress) to Gmail, Calendar, Drive,
Sheets, Tasks, and People APIs with Claude Code as the
execution engine.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
林 駿甫 (Shunsuke Hayashi) 2026-04-15 08:59:12 +09:00
commit a69b9d9160
44 changed files with 9790 additions and 0 deletions

257
scripts/garc-auth-helper.py Executable file
View file

@ -0,0 +1,257 @@
#!/usr/bin/env python3
"""
GARC Auth Helper OAuth scope inference and token management
Mirrors LARC's scope inference engine but for Google Workspace OAuth scopes
"""
import argparse
import json
import os
import sys
from pathlib import Path
GARC_DIR = Path(__file__).parent.parent
SCOPE_MAP_PATH = GARC_DIR / "config" / "scope-map.json"
GARC_CONFIG_DIR = Path.home() / ".garc"
TOKEN_FILE = Path(os.environ.get("GARC_TOKEN_FILE", str(GARC_CONFIG_DIR / "token.json")))
CREDENTIALS_FILE = Path(os.environ.get("GARC_CREDENTIALS_FILE", str(GARC_CONFIG_DIR / "credentials.json")))
def load_scope_map():
with open(SCOPE_MAP_PATH) as f:
return json.load(f)
def suggest_scopes(task_description: str):
"""Infer minimum OAuth scopes for a natural-language task description."""
scope_map = load_scope_map()
task_description_lower = task_description.lower()
matched_tasks = []
matched_scopes = set()
# Keyword pattern matching
for task_type, patterns in scope_map.get("keyword_patterns", {}).items():
for pattern in patterns:
if pattern.lower() in task_description_lower:
if task_type not in matched_tasks:
matched_tasks.append(task_type)
task_def = scope_map["tasks"].get(task_type, {})
for scope in task_def.get("scopes", []):
matched_scopes.add(scope)
if not matched_tasks:
print("No specific task types matched. General writer profile recommended.")
print("\nSuggested profile: writer")
profile = scope_map["profiles"]["writer"]
print(f"Description: {profile['description']}")
print("\nScopes:")
for scope in profile["scopes"]:
print(f" - {scope}")
return
print(f"Task analysis: \"{task_description}\"")
print(f"\nMatched task types: {', '.join(matched_tasks)}")
# Show gate policies
print("\nExecution gates:")
for task_type in matched_tasks:
task_def = scope_map["tasks"].get(task_type, {})
gate = task_def.get("gate", "none")
desc = task_def.get("description", "")
gate_icon = {"none": "", "preview": "⚠️", "approval": "🔒"}.get(gate, "")
print(f" {gate_icon} {task_type} ({gate}): {desc}")
print("\nRequired OAuth scopes:")
for scope in sorted(matched_scopes):
print(f" - {scope}")
# Identity type
identities = set()
for task_type in matched_tasks:
task_def = scope_map["tasks"].get(task_type, {})
identities.add(task_def.get("identity", "user_access_token"))
print(f"\nIdentity type: {', '.join(identities)}")
# Highest gate level
gate_order = {"none": 0, "preview": 1, "approval": 2}
max_gate = max(
(scope_map["tasks"].get(t, {}).get("gate", "none") for t in matched_tasks),
key=lambda g: gate_order.get(g, 0),
default="none"
)
gate_messages = {
"none": "✅ All operations are read-only. Can execute immediately.",
"preview": "⚠️ Some operations have external visibility. Use --confirm flag.",
"approval": "🔒 High-risk operations detected. Human approval required before execution."
}
print(f"\nGate requirement: {gate_messages.get(max_gate, '')}")
# Recommend profile
print("\nRecommended garc auth login command:")
if max_gate == "none":
print(" garc auth login --profile readonly")
elif max_gate == "preview":
print(" garc auth login --profile writer")
else:
print(" garc auth login --profile backoffice_agent")
def check_scopes(profile: str):
"""Check if current token has the required scopes for a profile."""
scope_map = load_scope_map()
if profile not in scope_map.get("profiles", {}):
print(f"Unknown profile: {profile}")
print(f"Available profiles: {', '.join(scope_map['profiles'].keys())}")
sys.exit(1)
required_scopes = set(scope_map["profiles"][profile]["scopes"])
if not TOKEN_FILE.exists():
print(f"No token file found at {TOKEN_FILE}")
print(f"Run: garc auth login --profile {profile}")
sys.exit(1)
try:
with open(TOKEN_FILE) as f:
token_data = json.load(f)
current_scopes = set(token_data.get("scopes", "").split() if isinstance(token_data.get("scopes"), str)
else token_data.get("scopes", []))
except (json.JSONDecodeError, KeyError):
print(f"Could not read token file: {TOKEN_FILE}")
sys.exit(1)
missing = required_scopes - current_scopes
if not missing:
print(f"✅ Current token satisfies '{profile}' profile requirements.")
print(f" Required: {len(required_scopes)} scopes — all present.")
else:
print(f"❌ Missing scopes for '{profile}' profile:")
for scope in sorted(missing):
print(f" - {scope}")
print(f"\nRun: garc auth login --profile {profile}")
def login(profile: str):
"""Launch OAuth2 authorization flow for the given profile."""
scope_map = load_scope_map()
if profile not in scope_map.get("profiles", {}):
print(f"Unknown profile: {profile}")
sys.exit(1)
scopes = scope_map["profiles"][profile]["scopes"]
description = scope_map["profiles"][profile]["description"]
print(f"OAuth2 authorization for profile: {profile}")
print(f"Description: {description}")
print(f"\nRequested scopes ({len(scopes)}):")
for scope in scopes:
print(f" - {scope}")
if not CREDENTIALS_FILE.exists():
print(f"\nError: credentials.json not found at {CREDENTIALS_FILE}")
print("Download it from Google Cloud Console → APIs & Services → Credentials")
print("OAuth 2.0 Client IDs → Download JSON → save as ~/.garc/credentials.json")
sys.exit(1)
try:
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
import google.oauth2.credentials
# Check if we have valid existing credentials
creds = None
if TOKEN_FILE.exists():
creds = google.oauth2.credentials.Credentials.from_authorized_user_file(str(TOKEN_FILE))
if creds and creds.valid:
print(f"\n✅ Already authenticated. Token file: {TOKEN_FILE}")
return
if creds and creds.expired and creds.refresh_token:
print("Refreshing expired token...")
creds.refresh(Request())
else:
flow = InstalledAppFlow.from_client_secrets_file(str(CREDENTIALS_FILE), scopes)
creds = flow.run_local_server(port=0)
GARC_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
with open(TOKEN_FILE, "w") as f:
f.write(creds.to_json())
print(f"\n✅ Token saved to {TOKEN_FILE}")
except ImportError:
print("\nNote: google-auth-oauthlib not installed.")
print("Install with: pip install google-auth-oauthlib google-api-python-client")
print("\nManual authorization URL would use scopes:")
for scope in scopes:
print(f" {scope}")
def show_status():
"""Show current token information."""
if not TOKEN_FILE.exists():
print(f"No token file found at {TOKEN_FILE}")
print("Run: garc auth login --profile writer")
return
try:
with open(TOKEN_FILE) as f:
token_data = json.load(f)
print(f"Token file: {TOKEN_FILE}")
print(f"Client ID: {token_data.get('client_id', 'N/A')[:20]}...")
scopes = token_data.get("scopes", [])
if isinstance(scopes, str):
scopes = scopes.split()
print(f"\nGranted scopes ({len(scopes)}):")
for scope in sorted(scopes):
short = scope.replace("https://www.googleapis.com/auth/", "")
print(f" - {short}")
expiry = token_data.get("expiry", "unknown")
print(f"\nExpiry: {expiry}")
except Exception as e:
print(f"Error reading token: {e}")
def main():
parser = argparse.ArgumentParser(description="GARC Auth Helper")
subparsers = parser.add_subparsers(dest="command")
# suggest
suggest_parser = subparsers.add_parser("suggest", help="Suggest scopes for a task")
suggest_parser.add_argument("task", nargs="+", help="Task description")
# check
check_parser = subparsers.add_parser("check", help="Check current token scopes")
check_parser.add_argument("--profile", default="writer", help="Profile to check against")
# login
login_parser = subparsers.add_parser("login", help="Launch OAuth2 flow")
login_parser.add_argument("--profile", default="writer", help="Profile to authorize")
# status
subparsers.add_parser("status", help="Show token status")
args = parser.parse_args()
if args.command == "suggest":
suggest_scopes(" ".join(args.task))
elif args.command == "check":
check_scopes(args.profile)
elif args.command == "login":
login(args.profile)
elif args.command == "status":
show_status()
else:
parser.print_help()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,332 @@
#!/usr/bin/env python3
"""
GARC Calendar Helper Full Google Calendar operations
list / create / update / delete / search / freebusy / quick-add
"""
import argparse
import json
import sys
from datetime import datetime, timezone, timedelta
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from garc_core import build_service, utc_now, with_retry
def get_svc():
return build_service("calendar", "v3")
def _parse_datetime(dt_str: str, tz: str = "Asia/Tokyo") -> dict:
"""Parse a datetime string into Google Calendar format."""
if "T" in dt_str or ":" in dt_str:
# Full datetime
if "T" not in dt_str:
dt_str = dt_str.replace(" ", "T")
return {"dateTime": dt_str, "timeZone": tz}
else:
# Date only
return {"date": dt_str}
def _format_event(event: dict) -> str:
"""Format an event for display."""
summary = event.get("summary", "(no title)")
start = event.get("start", {})
end = event.get("end", {})
start_str = start.get("dateTime", start.get("date", ""))[:16]
end_str = end.get("dateTime", end.get("date", ""))[:16]
location = event.get("location", "")
attendees = event.get("attendees", [])
attendee_str = f" ({len(attendees)} attendees)" if attendees else ""
loc_str = f" @ {location[:30]}" if location else ""
return f"[{event['id'][:10]}] {start_str}{end_str} {summary}{loc_str}{attendee_str}"
@with_retry()
def list_events(calendar_id: str = "primary", days_ahead: int = 7,
days_back: int = 0, max_results: int = 50, query: str = ""):
"""List calendar events."""
svc = get_svc()
now = datetime.now(timezone.utc)
time_min = (now - timedelta(days=days_back)).isoformat()
time_max = (now + timedelta(days=days_ahead)).isoformat()
kwargs = {
"calendarId": calendar_id,
"timeMin": time_min,
"timeMax": time_max,
"maxResults": max_results,
"singleEvents": True,
"orderBy": "startTime",
}
if query:
kwargs["q"] = query
result = svc.events().list(**kwargs).execute()
events = result.get("items", [])
if not events:
print(f"No events found" + (f" for: {query}" if query else ""))
return []
label = f"next {days_ahead}d" if not days_back else f"±{days_ahead}d"
print(f"Calendar events ({label}, {len(events)} results):")
print()
for event in events:
print(f" {_format_event(event)}")
return events
@with_retry()
def create_event(summary: str, start: str, end: str,
description: str = "", location: str = "",
attendees: list = None, calendar_id: str = "primary",
send_notifications: bool = True, all_day: bool = False,
recurrence: str = "", timezone: str = "Asia/Tokyo"):
"""Create a calendar event."""
svc = get_svc()
start_obj = {"date": start} if all_day else _parse_datetime(start, timezone)
end_obj = {"date": end} if all_day else _parse_datetime(end, timezone)
body = {
"summary": summary,
"start": start_obj,
"end": end_obj,
}
if description:
body["description"] = description
if location:
body["location"] = location
if attendees:
body["attendees"] = [{"email": a} for a in attendees]
if recurrence:
body["recurrence"] = [recurrence]
result = svc.events().insert(
calendarId=calendar_id,
body=body,
sendNotifications=send_notifications
).execute()
print(f"✅ Event created: {result['summary']}")
print(f" ID: {result['id']}")
print(f" Start: {result['start'].get('dateTime', result['start'].get('date', ''))}")
print(f" End: {result['end'].get('dateTime', result['end'].get('date', ''))}")
print(f" Link: {result.get('htmlLink', '')}")
return result
@with_retry()
def update_event(event_id: str, calendar_id: str = "primary", **updates):
"""Update a calendar event."""
svc = get_svc()
# Get existing event
event = svc.events().get(calendarId=calendar_id, eventId=event_id).execute()
if "summary" in updates:
event["summary"] = updates["summary"]
if "description" in updates:
event["description"] = updates["description"]
if "location" in updates:
event["location"] = updates["location"]
if "start" in updates:
event["start"] = _parse_datetime(updates["start"])
if "end" in updates:
event["end"] = _parse_datetime(updates["end"])
if "attendees_add" in updates:
existing = {a["email"] for a in event.get("attendees", [])}
for email in updates["attendees_add"]:
if email not in existing:
event.setdefault("attendees", []).append({"email": email})
result = svc.events().update(
calendarId=calendar_id, eventId=event_id, body=event
).execute()
print(f"✅ Event updated: {result['summary']}")
return result
@with_retry()
def delete_event(event_id: str, calendar_id: str = "primary"):
"""Delete a calendar event."""
svc = get_svc()
svc.events().delete(calendarId=calendar_id, eventId=event_id).execute()
print(f"✅ Event deleted: {event_id}")
@with_retry()
def get_event(event_id: str, calendar_id: str = "primary"):
"""Get event details."""
svc = get_svc()
event = svc.events().get(calendarId=calendar_id, eventId=event_id).execute()
print(f"Summary: {event.get('summary', '')}")
print(f"ID: {event['id']}")
start = event.get("start", {})
end = event.get("end", {})
print(f"Start: {start.get('dateTime', start.get('date', ''))}")
print(f"End: {end.get('dateTime', end.get('date', ''))}")
print(f"Location: {event.get('location', '')}")
print(f"Description: {event.get('description', '')}")
attendees = event.get("attendees", [])
if attendees:
print(f"Attendees ({len(attendees)}):")
for a in attendees:
status = a.get("responseStatus", "unknown")
status_icon = {"accepted": "", "declined": "", "tentative": "", "needsAction": ""}.get(status, "")
print(f" {status_icon} {a.get('email', '')} ({a.get('displayName', '')})")
print(f"Link: {event.get('htmlLink', '')}")
return event
@with_retry()
def freebusy(start: str, end: str, emails: list, timezone: str = "Asia/Tokyo"):
"""Check free/busy status for given email addresses."""
svc = get_svc()
body = {
"timeMin": start if "T" in start else f"{start}T00:00:00Z",
"timeMax": end if "T" in end else f"{end}T23:59:59Z",
"timeZone": timezone,
"items": [{"id": email} for email in emails]
}
result = svc.freebusy().query(body=body).execute()
calendars = result.get("calendars", {})
print(f"Free/Busy ({start}{end}):")
for email, data in calendars.items():
busy = data.get("busy", [])
if busy:
print(f"\n 🔴 {email} — BUSY ({len(busy)} slots):")
for slot in busy:
print(f" {slot['start'][:16]}{slot['end'][:16]}")
else:
print(f"\n{email} — FREE")
return result
@with_retry()
def quick_add(text: str, calendar_id: str = "primary"):
"""Quick add an event from natural language text."""
svc = get_svc()
result = svc.events().quickAdd(calendarId=calendar_id, text=text).execute()
print(f"✅ Quick add: {result.get('summary', '')}")
print(f" {result.get('start', {}).get('dateTime', '')[:16]}")
return result
@with_retry()
def list_calendars():
"""List all accessible calendars."""
svc = get_svc()
result = svc.calendarList().list().execute()
calendars = result.get("items", [])
print(f"Calendars ({len(calendars)}):")
for cal in calendars:
primary = " (primary)" if cal.get("primary") else ""
print(f" [{cal['id'][:30]:<30}] {cal['summary']}{primary}")
return calendars
def main():
parser = argparse.ArgumentParser(description="GARC Calendar Helper")
sub = parser.add_subparsers(dest="command")
# list
lp = sub.add_parser("list", help="List events")
lp.add_argument("--calendar", default="primary")
lp.add_argument("--days", type=int, default=7)
lp.add_argument("--back", type=int, default=0, help="Days to look back")
lp.add_argument("--max", type=int, default=50)
lp.add_argument("--query", default="")
# create
cp = sub.add_parser("create", help="Create event")
cp.add_argument("--summary", required=True)
cp.add_argument("--start", required=True)
cp.add_argument("--end", required=True)
cp.add_argument("--description", default="")
cp.add_argument("--location", default="")
cp.add_argument("--attendees", nargs="+", default=[])
cp.add_argument("--calendar", default="primary")
cp.add_argument("--no-notify", action="store_true")
cp.add_argument("--all-day", action="store_true")
cp.add_argument("--recurrence", default="")
cp.add_argument("--timezone", default="Asia/Tokyo")
# update
up = sub.add_parser("update", help="Update event")
up.add_argument("event_id")
up.add_argument("--summary")
up.add_argument("--description")
up.add_argument("--location")
up.add_argument("--start")
up.add_argument("--end")
up.add_argument("--add-attendees", nargs="+", dest="attendees_add", default=[])
up.add_argument("--calendar", default="primary")
# delete
dp = sub.add_parser("delete", help="Delete event")
dp.add_argument("event_id")
dp.add_argument("--calendar", default="primary")
# get
gp = sub.add_parser("get", help="Get event details")
gp.add_argument("event_id")
gp.add_argument("--calendar", default="primary")
# freebusy
fb = sub.add_parser("freebusy", help="Check free/busy")
fb.add_argument("--start", required=True)
fb.add_argument("--end", required=True)
fb.add_argument("--emails", nargs="+", required=True)
fb.add_argument("--timezone", default="Asia/Tokyo")
# quick-add
qa = sub.add_parser("quick-add", help="Quick add from natural language")
qa.add_argument("text")
qa.add_argument("--calendar", default="primary")
# calendars
sub.add_parser("calendars", help="List all calendars")
args = parser.parse_args()
if args.command == "list":
list_events(args.calendar, args.days, args.back, args.max, args.query)
elif args.command == "create":
create_event(args.summary, args.start, args.end, args.description,
args.location, args.attendees, args.calendar,
not args.no_notify, args.all_day, args.recurrence, args.timezone)
elif args.command == "update":
updates = {}
for k in ["summary", "description", "location", "start", "end"]:
v = getattr(args, k, None)
if v:
updates[k] = v
if args.attendees_add:
updates["attendees_add"] = args.attendees_add
update_event(args.event_id, args.calendar, **updates)
elif args.command == "delete":
delete_event(args.event_id, args.calendar)
elif args.command == "get":
get_event(args.event_id, args.calendar)
elif args.command == "freebusy":
freebusy(args.start, args.end, args.emails, args.timezone)
elif args.command == "quick-add":
quick_add(args.text, args.calendar)
elif args.command == "calendars":
list_calendars()
else:
parser.print_help()
if __name__ == "__main__":
main()

207
scripts/garc-core.py Normal file
View file

@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""
GARC Core Shared utilities: auth, service builders, retry, output formatting
"""
import json
import os
import sys
import time
import functools
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, Any
GARC_CONFIG_DIR = Path(os.environ.get("GARC_CONFIG_DIR", Path.home() / ".garc"))
TOKEN_FILE = Path(os.environ.get("GARC_TOKEN_FILE", GARC_CONFIG_DIR / "token.json"))
CREDENTIALS_FILE = Path(os.environ.get("GARC_CREDENTIALS_FILE", GARC_CONFIG_DIR / "credentials.json"))
SERVICE_ACCOUNT_FILE = Path(os.environ.get("GARC_SERVICE_ACCOUNT_FILE", GARC_CONFIG_DIR / "service_account.json"))
# All supported scopes for backoffice_agent profile
ALL_SCOPES = [
"https://www.googleapis.com/auth/drive",
"https://www.googleapis.com/auth/drive.file",
"https://www.googleapis.com/auth/documents",
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/gmail.send",
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/gmail.modify",
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/tasks",
"https://www.googleapis.com/auth/contacts.readonly",
"https://www.googleapis.com/auth/chat.messages",
"https://www.googleapis.com/auth/people.readonly",
]
PROFILE_SCOPES = {
"readonly": [
"https://www.googleapis.com/auth/drive.readonly",
"https://www.googleapis.com/auth/spreadsheets.readonly",
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/calendar.readonly",
"https://www.googleapis.com/auth/tasks.readonly",
"https://www.googleapis.com/auth/contacts.readonly",
"https://www.googleapis.com/auth/documents",
],
"writer": [
"https://www.googleapis.com/auth/drive.file",
"https://www.googleapis.com/auth/documents",
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/gmail.send",
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/tasks",
],
"backoffice_agent": ALL_SCOPES,
"admin": ALL_SCOPES + [
"https://www.googleapis.com/auth/admin.directory.user.readonly",
],
}
def get_credentials(scopes: Optional[list] = None, use_service_account: bool = False):
"""
Get valid Google credentials.
Tries: service account existing token OAuth flow
"""
if scopes is None:
scopes = ALL_SCOPES
# Service account path
if use_service_account and SERVICE_ACCOUNT_FILE.exists():
try:
from google.oauth2 import service_account
creds = service_account.Credentials.from_service_account_file(
str(SERVICE_ACCOUNT_FILE), scopes=scopes
)
return creds
except Exception as e:
print(f"⚠️ Service account error: {e}", file=sys.stderr)
# User OAuth token
try:
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
creds = None
if TOKEN_FILE.exists():
creds = Credentials.from_authorized_user_file(str(TOKEN_FILE), scopes)
if creds and creds.valid:
return creds
if creds and creds.expired and creds.refresh_token:
try:
creds.refresh(Request())
_save_token(creds)
return creds
except Exception as e:
print(f"⚠️ Token refresh failed: {e}", file=sys.stderr)
# Need fresh OAuth flow
if not CREDENTIALS_FILE.exists():
print(f"❌ credentials.json not found: {CREDENTIALS_FILE}", file=sys.stderr)
print(" Download from Google Cloud Console → APIs & Services → Credentials", file=sys.stderr)
print(" Run: garc auth login --profile backoffice_agent", file=sys.stderr)
sys.exit(1)
from google_auth_oauthlib.flow import InstalledAppFlow
flow = InstalledAppFlow.from_client_secrets_file(str(CREDENTIALS_FILE), scopes)
creds = flow.run_local_server(port=0, open_browser=True)
_save_token(creds)
return creds
except ImportError as e:
print(f"❌ Missing dependency: {e}", file=sys.stderr)
print(" Run: pip install -r requirements.txt", file=sys.stderr)
sys.exit(1)
def _save_token(creds):
"""Save credentials to token file."""
GARC_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
with open(TOKEN_FILE, "w") as f:
f.write(creds.to_json())
TOKEN_FILE.chmod(0o600)
def build_service(service_name: str, version: str, scopes: Optional[list] = None):
"""Build a Google API service with proper credentials."""
try:
from googleapiclient.discovery import build
creds = get_credentials(scopes)
return build(service_name, version, credentials=creds, cache_discovery=False)
except ImportError:
print("❌ google-api-python-client not installed", file=sys.stderr)
print(" Run: pip install -r requirements.txt", file=sys.stderr)
sys.exit(1)
def with_retry(max_retries: int = 3, backoff: float = 1.5):
"""Decorator: retry on transient Google API errors with exponential backoff."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_error = None
for attempt in range(max_retries):
try:
return func(*args, **kwargs)
except Exception as e:
error_str = str(e).lower()
# Retry on rate limit, server error
if any(code in error_str for code in ["429", "500", "503", "quota", "rate"]):
wait = backoff ** attempt
if attempt < max_retries - 1:
print(f" ⏳ Rate limit hit, waiting {wait:.1f}s...", file=sys.stderr)
time.sleep(wait)
last_error = e
continue
raise
raise last_error
return wrapper
return decorator
def utc_now() -> str:
"""Return current UTC time as ISO 8601 string."""
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def format_table(rows: list[dict], columns: list[str], max_width: int = 120) -> str:
"""Format a list of dicts as a simple table."""
if not rows:
return "(no results)"
# Calculate column widths
widths = {col: len(col) for col in columns}
for row in rows:
for col in columns:
val = str(row.get(col, ""))
widths[col] = min(max(widths[col], len(val)), 40)
header = " ".join(col.ljust(widths[col]) for col in columns)
sep = " ".join("" * widths[col] for col in columns)
lines = [header, sep]
for row in rows:
line = " ".join(str(row.get(col, "")).ljust(widths[col])[:widths[col]] for col in columns)
lines.append(line)
return "\n".join(lines)
def load_config() -> dict:
"""Load GARC config from environment / config.env file."""
config_file = GARC_CONFIG_DIR / "config.env"
config = {}
if config_file.exists():
with open(config_file) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, _, val = line.partition("=")
config[key.strip()] = val.strip()
# Override with env vars
for key in ["GARC_DRIVE_FOLDER_ID", "GARC_SHEETS_ID", "GARC_GMAIL_DEFAULT_TO",
"GARC_CALENDAR_ID", "GARC_CHAT_SPACE_ID", "GARC_DEFAULT_AGENT"]:
if os.environ.get(key):
config[key] = os.environ[key]
return config

522
scripts/garc-drive-helper.py Executable file
View file

@ -0,0 +1,522 @@
#!/usr/bin/env python3
"""
GARC Drive Helper Full Google Drive operations
list / search / download / upload / create-doc / create-folder / share / move / delete / kg-build
"""
import argparse
import io
import json
import mimetypes
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from garc_core import build_service, utc_now, with_retry
def get_svc():
return build_service("drive", "v3")
MIME_FOLDER = "application/vnd.google-apps.folder"
MIME_DOC = "application/vnd.google-apps.document"
MIME_SHEET = "application/vnd.google-apps.spreadsheet"
MIME_SLIDE = "application/vnd.google-apps.presentation"
def _format_file(f: dict) -> str:
kind = f.get("mimeType", "")
if MIME_FOLDER in kind:
icon = "📁"
elif "document" in kind:
icon = "📄"
elif "spreadsheet" in kind:
icon = "📊"
elif "presentation" in kind:
icon = "📺"
elif "image" in kind:
icon = "🖼️"
else:
icon = "📎"
size = f.get("size", "")
size_str = f" ({int(size):,}B)" if size else ""
mod = f.get("modifiedTime", "")[:10]
return f"{icon} [{f['id'][:12]}] {f['name']}{size_str} {mod}"
@with_retry()
def list_files(folder_id: str = "root", max_results: int = 50,
query: str = "", order_by: str = "modifiedTime desc"):
"""List files in a Drive folder."""
svc = get_svc()
q_parts = [f"'{folder_id}' in parents", "trashed = false"]
if query:
q_parts.append(f"name contains '{query}'")
result = svc.files().list(
q=" and ".join(q_parts),
pageSize=max_results,
orderBy=order_by,
fields="files(id,name,mimeType,size,modifiedTime,webViewLink,parents)"
).execute()
files = result.get("files", [])
if not files:
print(f"No files found in folder: {folder_id}")
return []
print(f"Files in {folder_id} ({len(files)}):")
for f in files:
print(f" {_format_file(f)}")
return files
@with_retry()
def search_files(query: str, max_results: int = 30, file_type: str = ""):
"""Search Drive files by name or content."""
svc = get_svc()
q_parts = ["trashed = false"]
q_parts.append(f"(name contains '{query}' or fullText contains '{query}')")
mime_map = {
"doc": MIME_DOC,
"sheet": MIME_SHEET,
"slide": MIME_SLIDE,
"folder": MIME_FOLDER,
"pdf": "application/pdf",
}
if file_type and file_type in mime_map:
q_parts.append(f"mimeType = '{mime_map[file_type]}'")
result = svc.files().list(
q=" and ".join(q_parts),
pageSize=max_results,
orderBy="modifiedTime desc",
fields="files(id,name,mimeType,size,modifiedTime,webViewLink)"
).execute()
files = result.get("files", [])
if not files:
print(f"No files found for: {query}")
return []
print(f"Search results for '{query}' ({len(files)}):")
for f in files:
print(f" {_format_file(f)}")
print(f" 🔗 {f.get('webViewLink', '')}")
return files
@with_retry()
def get_file_info(file_id: str):
"""Get detailed file information."""
svc = get_svc()
f = svc.files().get(
fileId=file_id,
fields="id,name,mimeType,size,createdTime,modifiedTime,webViewLink,parents,owners,shared,sharingUser"
).execute()
print(f"Name: {f['name']}")
print(f"ID: {f['id']}")
print(f"Type: {f['mimeType']}")
print(f"Size: {f.get('size', 'N/A')}")
print(f"Created: {f.get('createdTime', '')[:19]}")
print(f"Modified: {f.get('modifiedTime', '')[:19]}")
print(f"Shared: {f.get('shared', False)}")
print(f"Link: {f.get('webViewLink', '')}")
owners = f.get("owners", [])
if owners:
print(f"Owner: {owners[0].get('emailAddress', '')}")
return f
@with_retry()
def download_file(file_id: str = "", folder_id: str = "", filename: str = "",
output: str = ""):
"""Download a file from Google Drive."""
svc = get_svc()
# Find file by name in folder if file_id not given
if not file_id and folder_id and filename:
parts = filename.split("/")
current_folder = folder_id
for i, part in enumerate(parts):
q = f"'{current_folder}' in parents and name='{part}' and trashed=false"
results = svc.files().list(q=q, fields="files(id,mimeType)").execute()
files = results.get("files", [])
if not files:
print(f"Not found: {filename}", file=sys.stderr)
sys.exit(1)
if i == len(parts) - 1:
file_id = files[0]["id"]
mime_type = files[0]["mimeType"]
else:
current_folder = files[0]["id"]
if not file_id:
print("Error: provide --file-id or --folder-id + --filename", file=sys.stderr)
sys.exit(1)
# Get file info
f = svc.files().get(fileId=file_id, fields="name,mimeType").execute()
mime_type = f.get("mimeType", "")
out_path = Path(output) if output else Path(f["name"])
out_path.parent.mkdir(parents=True, exist_ok=True)
if "google-apps.document" in mime_type:
request = svc.files().export_media(fileId=file_id, mimeType="text/plain")
elif "google-apps.spreadsheet" in mime_type:
request = svc.files().export_media(fileId=file_id,
mimeType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
if not str(out_path).endswith(".xlsx"):
out_path = Path(str(out_path) + ".xlsx")
elif "google-apps" in mime_type:
request = svc.files().export_media(fileId=file_id, mimeType="application/pdf")
if not str(out_path).endswith(".pdf"):
out_path = Path(str(out_path) + ".pdf")
else:
request = svc.files().get_media(fileId=file_id)
from googleapiclient.http import MediaIoBaseDownload
fh = io.BytesIO()
downloader = MediaIoBaseDownload(fh, request)
done = False
while not done:
_, done = downloader.next_chunk()
with open(out_path, "wb") as out:
out.write(fh.getvalue())
print(f"✅ Downloaded: {out_path} ({len(fh.getvalue()):,} bytes)")
return str(out_path)
@with_retry()
def upload_file(local_path: str, folder_id: str = "root",
name: str = "", convert: bool = False):
"""Upload a local file to Google Drive."""
svc = get_svc()
local = Path(local_path)
if not local.exists():
print(f"File not found: {local_path}", file=sys.stderr)
sys.exit(1)
file_name = name or local.name
mime_type, _ = mimetypes.guess_type(str(local))
mime_type = mime_type or "application/octet-stream"
# Convert to Google format if requested
convert_mime = None
if convert:
if local.suffix in (".docx", ".doc", ".txt", ".md"):
convert_mime = MIME_DOC
elif local.suffix in (".xlsx", ".xls", ".csv"):
convert_mime = MIME_SHEET
from googleapiclient.http import MediaFileUpload
media = MediaFileUpload(str(local), mimetype=mime_type, resumable=True)
body = {"name": file_name, "parents": [folder_id]}
if convert_mime:
body["mimeType"] = convert_mime
result = svc.files().create(
body=body, media_body=media, fields="id,name,webViewLink"
).execute()
print(f"✅ Uploaded: {result['name']}")
print(f" ID: {result['id']}")
print(f" Link: {result.get('webViewLink', '')}")
return result
@with_retry()
def create_folder(name: str, parent_id: str = "root"):
"""Create a folder in Google Drive."""
svc = get_svc()
result = svc.files().create(body={
"name": name,
"mimeType": MIME_FOLDER,
"parents": [parent_id]
}, fields="id,name,webViewLink").execute()
print(f"✅ Folder created: {result['name']}")
print(f" ID: {result['id']}")
print(f" Link: {result.get('webViewLink', '')}")
return result
@with_retry()
def create_doc(name: str, folder_id: str = "root", content: str = ""):
"""Create a Google Doc (optionally with initial content)."""
svc_drive = get_svc()
svc_docs = build_service("docs", "v1")
# Create empty Doc via Drive
result = svc_drive.files().create(body={
"name": name,
"mimeType": MIME_DOC,
"parents": [folder_id]
}, fields="id,name,webViewLink").execute()
doc_id = result["id"]
# Add initial content if provided
if content:
svc_docs.documents().batchUpdate(
documentId=doc_id,
body={"requests": [{"insertText": {"location": {"index": 1}, "text": content}}]}
).execute()
print(f"✅ Doc created: {name}")
print(f" ID: {doc_id}")
print(f" Link: {result.get('webViewLink', '')}")
return result
@with_retry()
def share_file(file_id: str, email: str, role: str = "reader",
send_notification: bool = True):
"""Share a file with a user."""
svc = get_svc()
valid_roles = ["reader", "writer", "commenter", "owner"]
if role not in valid_roles:
print(f"Invalid role. Choose: {', '.join(valid_roles)}", file=sys.stderr)
sys.exit(1)
result = svc.permissions().create(
fileId=file_id,
body={"type": "user", "role": role, "emailAddress": email},
sendNotificationEmail=send_notification,
fields="id,emailAddress,role"
).execute()
print(f"✅ Shared with {email} as {role}")
return result
@with_retry()
def move_file(file_id: str, new_folder_id: str):
"""Move a file to a different folder."""
svc = get_svc()
# Get current parents
f = svc.files().get(fileId=file_id, fields="parents").execute()
current_parents = ",".join(f.get("parents", []))
result = svc.files().update(
fileId=file_id,
addParents=new_folder_id,
removeParents=current_parents,
fields="id,name,parents"
).execute()
print(f"✅ Moved: {result.get('name', file_id)}{new_folder_id}")
return result
@with_retry()
def delete_file(file_id: str, permanent: bool = False):
"""Delete or trash a file."""
svc = get_svc()
if permanent:
svc.files().delete(fileId=file_id).execute()
print(f"✅ Permanently deleted: {file_id}")
else:
svc.files().update(fileId=file_id, body={"trashed": True}).execute()
print(f"✅ Moved to trash: {file_id}")
@with_retry()
def kg_build(folder_id: str, output: str, depth: int = 3):
"""Build knowledge graph from Drive folder."""
svc = get_svc()
svc_docs = build_service("docs", "v1")
print(f"Building knowledge graph from: {folder_id} (depth={depth})")
nodes = []
visited = set()
import re
def crawl(fid: str, level: int = 0):
if level > depth or fid in visited:
return
visited.add(fid)
try:
q = f"'{fid}' in parents and trashed = false"
results = svc.files().list(
q=q, pageSize=50,
fields="files(id,name,mimeType,modifiedTime,webViewLink)"
).execute()
files = results.get("files", [])
except Exception as e:
return
for f in files:
file_id = f["id"]
if MIME_FOLDER in f["mimeType"]:
crawl(file_id, level + 1)
continue
if MIME_DOC not in f["mimeType"]:
continue
content_preview = ""
links = []
try:
req = svc.files().export_media(fileId=file_id, mimeType="text/plain")
fh = io.BytesIO()
from googleapiclient.http import MediaIoBaseDownload
dl = MediaIoBaseDownload(fh, req)
done = False
while not done:
_, done = dl.next_chunk()
content = fh.getvalue().decode("utf-8", errors="replace")
content_preview = content[:800]
links = list(set(re.findall(
r'docs\.google\.com/document/d/([a-zA-Z0-9_-]{10,})', content
)))
except Exception:
pass
nodes.append({
"doc_id": file_id,
"title": f["name"],
"mime_type": f["mimeType"],
"modified_time": f.get("modifiedTime", ""),
"web_link": f.get("webViewLink", ""),
"content_preview": content_preview,
"links": links,
"depth": level,
})
indent = " " * level
print(f" {indent}{f['name']} ({len(links)} links)")
crawl(folder_id)
import datetime
kg = {
"built_at": utc_now(),
"folder_id": folder_id,
"node_count": len(nodes),
"nodes": nodes,
}
out = Path(output)
out.parent.mkdir(parents=True, exist_ok=True)
with open(out, "w") as fp:
json.dump(kg, fp, ensure_ascii=False, indent=2)
print(f"\n✅ Knowledge graph: {len(nodes)} docs indexed → {output}")
return kg
def main():
parser = argparse.ArgumentParser(description="GARC Drive Helper")
sub = parser.add_subparsers(dest="command")
# list
lp = sub.add_parser("list", help="List files in folder")
lp.add_argument("--folder-id", default="root")
lp.add_argument("--max", type=int, default=50)
lp.add_argument("--query", default="")
# search
sp = sub.add_parser("search", help="Search files")
sp.add_argument("query")
sp.add_argument("--max", type=int, default=30)
sp.add_argument("--type", choices=["doc", "sheet", "slide", "folder", "pdf"], default="")
# info
ip = sub.add_parser("info", help="Get file info")
ip.add_argument("file_id")
# download
dp = sub.add_parser("download", help="Download file")
dp.add_argument("--file-id", default="")
dp.add_argument("--folder-id", default="")
dp.add_argument("--filename", default="")
dp.add_argument("--output", default="")
# upload
up = sub.add_parser("upload", help="Upload file")
up.add_argument("local_path")
up.add_argument("--folder-id", default="root")
up.add_argument("--name", default="")
up.add_argument("--convert", action="store_true", help="Convert to Google format")
# create-folder
cf = sub.add_parser("create-folder", help="Create folder")
cf.add_argument("name")
cf.add_argument("--parent-id", default="root")
# create-doc
cd = sub.add_parser("create-doc", help="Create Google Doc")
cd.add_argument("name")
cd.add_argument("--folder-id", default="root")
cd.add_argument("--content", default="")
# share
sh = sub.add_parser("share", help="Share file")
sh.add_argument("file_id")
sh.add_argument("--email", required=True)
sh.add_argument("--role", default="reader", choices=["reader", "writer", "commenter", "owner"])
sh.add_argument("--no-notify", action="store_true")
# move
mv = sub.add_parser("move", help="Move file to folder")
mv.add_argument("file_id")
mv.add_argument("--to", required=True, dest="new_folder_id")
# delete
delp = sub.add_parser("delete", help="Delete/trash file")
delp.add_argument("file_id")
delp.add_argument("--permanent", action="store_true")
# kg-build
kb = sub.add_parser("kg-build", help="Build knowledge graph")
kb.add_argument("--folder-id", required=True)
kb.add_argument("--output", required=True)
kb.add_argument("--depth", type=int, default=3)
args = parser.parse_args()
if args.command == "list":
list_files(args.folder_id, args.max, args.query)
elif args.command == "search":
search_files(args.query, args.max, args.type)
elif args.command == "info":
get_file_info(args.file_id)
elif args.command == "download":
download_file(args.file_id, args.folder_id, args.filename, args.output)
elif args.command == "upload":
upload_file(args.local_path, args.folder_id, args.name, args.convert)
elif args.command == "create-folder":
create_folder(args.name, args.parent_id)
elif args.command == "create-doc":
create_doc(args.name, args.folder_id, args.content)
elif args.command == "share":
share_file(args.file_id, args.email, args.role, not args.no_notify)
elif args.command == "move":
move_file(args.file_id, args.new_folder_id)
elif args.command == "delete":
delete_file(args.file_id, args.permanent)
elif args.command == "kg-build":
kg_build(args.folder_id, args.output, args.depth)
else:
parser.print_help()
if __name__ == "__main__":
main()

316
scripts/garc-gmail-helper.py Executable file
View file

@ -0,0 +1,316 @@
#!/usr/bin/env python3
"""
GARC Gmail Helper Full Gmail operations
send / search / read / list / draft / thread / label / reply / forward
"""
import argparse
import base64
import json
import sys
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from garc_core import build_service, utc_now, with_retry
def get_svc():
return build_service("gmail", "v1")
@with_retry()
def send_email(to: str, subject: str, body: str, cc: str = "",
bcc: str = "", html: bool = False, reply_to: str = ""):
"""Send an email via Gmail."""
svc = get_svc()
if html:
msg = MIMEMultipart("alternative")
msg.attach(MIMEText(body, "html"))
else:
msg = MIMEText(body, "plain")
msg["to"] = to
msg["subject"] = subject
if cc:
msg["cc"] = cc
if bcc:
msg["bcc"] = bcc
if reply_to:
msg["reply-to"] = reply_to
raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
result = svc.users().messages().send(userId="me", body={"raw": raw}).execute()
print(f"✅ Email sent")
print(f" To: {to}")
print(f" Subject: {subject}")
print(f" ID: {result['id']}")
return result
@with_retry()
def reply_to_thread(thread_id: str, message_id: str, to: str, subject: str, body: str):
"""Reply to an existing Gmail thread."""
svc = get_svc()
msg = MIMEText(body, "plain")
msg["to"] = to
msg["subject"] = subject if subject.startswith("Re:") else f"Re: {subject}"
msg["in-reply-to"] = message_id
msg["references"] = message_id
raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
result = svc.users().messages().send(userId="me", body={
"raw": raw, "threadId": thread_id
}).execute()
print(f"✅ Reply sent (thread: {thread_id[:12]})")
return result
@with_retry()
def search_emails(query: str, max_results: int = 20, include_body: bool = False):
"""Search Gmail messages."""
svc = get_svc()
result = svc.users().messages().list(
userId="me", q=query, maxResults=max_results
).execute()
messages = result.get("messages", [])
if not messages:
print(f"No results for: {query}")
return []
print(f"Found {len(messages)} messages for: {query}")
print()
detailed = []
for m in messages:
msg = svc.users().messages().get(
userId="me", id=m["id"],
format="full" if include_body else "metadata",
metadataHeaders=["From", "To", "Subject", "Date"]
).execute()
headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])}
entry = {
"id": msg["id"],
"thread_id": msg["threadId"],
"subject": headers.get("Subject", "(no subject)"),
"from": headers.get("From", ""),
"to": headers.get("To", ""),
"date": headers.get("Date", ""),
"labels": msg.get("labelIds", []),
"snippet": msg.get("snippet", ""),
}
if include_body:
entry["body"] = _extract_body(msg.get("payload", {}))
detailed.append(entry)
print(f" [{entry['id'][:10]}] {entry['subject'][:50]}")
print(f" From: {entry['from'][:50]} Date: {entry['date'][:24]}")
if entry["snippet"]:
print(f" {entry['snippet'][:100]}...")
print()
return detailed
@with_retry()
def read_email(message_id: str):
"""Read a specific email message."""
svc = get_svc()
msg = svc.users().messages().get(
userId="me", id=message_id, format="full"
).execute()
headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])}
body = _extract_body(msg.get("payload", {}))
print(f"Subject: {headers.get('Subject', '(no subject)')}")
print(f"From: {headers.get('From', '')}")
print(f"To: {headers.get('To', '')}")
print(f"Date: {headers.get('Date', '')}")
print(f"Labels: {', '.join(msg.get('labelIds', []))}")
print()
print("" * 60)
print(body)
return {"headers": headers, "body": body, "id": message_id}
@with_retry()
def list_inbox(max_results: int = 20, label: str = "INBOX", unread_only: bool = False, format_: str = "table"):
"""List inbox messages."""
import json as _json
svc = get_svc()
q = "is:unread" if unread_only else f"label:{label}"
result = svc.users().messages().list(userId="me", q=q, maxResults=max_results).execute()
message_ids = [m["id"] for m in result.get("messages", [])]
messages = []
for msg_id in message_ids:
msg = svc.users().messages().get(userId="me", id=msg_id, format="metadata",
metadataHeaders=["From", "Subject", "Date"]).execute()
headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])}
messages.append({
"id": msg["id"],
"from": headers.get("From", ""),
"subject": headers.get("Subject", "(no subject)"),
"date": headers.get("Date", ""),
"snippet": msg.get("snippet", "")[:100],
})
if format_ == "json":
print(_json.dumps(messages, ensure_ascii=False, indent=2))
return messages
print(f"Inbox {'(unread) ' if unread_only else ''}({len(messages)}):")
for m in messages:
sender = m["from"][:30]
subj = m["subject"][:45]
print(f" [{m['id'][:12]}] {sender:<30} {subj}")
return messages
@with_retry()
def create_draft(to: str, subject: str, body: str, cc: str = ""):
"""Create a Gmail draft."""
svc = get_svc()
msg = MIMEText(body, "plain")
msg["to"] = to
msg["subject"] = subject
if cc:
msg["cc"] = cc
raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
result = svc.users().drafts().create(
userId="me", body={"message": {"raw": raw}}
).execute()
print(f"✅ Draft created: {result['id']}")
return result
@with_retry()
def list_labels():
"""List all Gmail labels."""
svc = get_svc()
result = svc.users().labels().list(userId="me").execute()
labels = result.get("labels", [])
print(f"Gmail labels ({len(labels)}):")
for label in sorted(labels, key=lambda x: x["name"]):
print(f" [{label['id'][:15]:<15}] {label['name']}")
@with_retry()
def get_profile():
"""Get Gmail account profile."""
svc = get_svc()
profile = svc.users().getProfile(userId="me").execute()
print(f"Gmail: {profile['emailAddress']}")
print(f"Messages: {profile.get('messagesTotal', 'N/A'):,}")
print(f"Threads: {profile.get('threadsTotal', 'N/A'):,}")
return profile
def _extract_body(payload: dict, prefer_plain: bool = True) -> str:
"""Recursively extract email body text."""
mime_type = payload.get("mimeType", "")
body_data = payload.get("body", {}).get("data", "")
if body_data:
text = base64.urlsafe_b64decode(body_data + "==").decode("utf-8", errors="replace")
if prefer_plain and mime_type == "text/plain":
return text
if not prefer_plain and mime_type == "text/html":
return text
if mime_type in ("text/plain", "text/html"):
return text
for part in payload.get("parts", []):
result = _extract_body(part, prefer_plain)
if result:
return result
return ""
def main():
parser = argparse.ArgumentParser(description="GARC Gmail Helper")
sub = parser.add_subparsers(dest="command")
# send
sp = sub.add_parser("send", help="Send email")
sp.add_argument("--to", required=True)
sp.add_argument("--subject", required=True)
sp.add_argument("--body", required=True)
sp.add_argument("--cc", default="")
sp.add_argument("--bcc", default="")
sp.add_argument("--html", action="store_true")
sp.add_argument("--reply-to", default="")
# reply
rp = sub.add_parser("reply", help="Reply to thread")
rp.add_argument("--thread-id", required=True)
rp.add_argument("--message-id", required=True)
rp.add_argument("--to", required=True)
rp.add_argument("--subject", required=True)
rp.add_argument("--body", required=True)
# search
sp2 = sub.add_parser("search", help="Search emails")
sp2.add_argument("query")
sp2.add_argument("--max", type=int, default=20)
sp2.add_argument("--body", action="store_true", help="Include body")
# read
rp2 = sub.add_parser("read", help="Read email")
rp2.add_argument("message_id")
# inbox
ip = sub.add_parser("inbox", help="List inbox")
ip.add_argument("--max", type=int, default=20)
ip.add_argument("--unread", action="store_true")
ip.add_argument("--format", dest="format_", default="table", choices=["table", "json"])
# draft
dp = sub.add_parser("draft", help="Create draft")
dp.add_argument("--to", required=True)
dp.add_argument("--subject", required=True)
dp.add_argument("--body", required=True)
dp.add_argument("--cc", default="")
# labels
sub.add_parser("labels", help="List labels")
# profile
sub.add_parser("profile", help="Show account profile")
args = parser.parse_args()
if args.command == "send":
send_email(args.to, args.subject, args.body, args.cc, args.bcc, args.html, args.reply_to)
elif args.command == "reply":
reply_to_thread(args.thread_id, args.message_id, args.to, args.subject, args.body)
elif args.command == "search":
search_emails(args.query, args.max, args.body)
elif args.command == "read":
read_email(args.message_id)
elif args.command == "inbox":
list_inbox(args.max, unread_only=args.unread, format_=args.format_)
elif args.command == "draft":
create_draft(args.to, args.subject, args.body, args.cc)
elif args.command == "labels":
list_labels()
elif args.command == "profile":
get_profile()
else:
parser.print_help()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,454 @@
#!/usr/bin/env python3
"""
GARC Ingress Helper Queue payload builder + Claude Code execution bridge
Commands:
build-payload --text <msg> [--source <src>] [--sender <email>] [--agent <id>]
execute-stub --queue-file <path>
build-prompt --queue-file <path> [--agent-context <path>]
stats --queue-dir <path>
"""
import argparse
import json
import os
import sys
import hashlib
import time
from pathlib import Path
from datetime import datetime, timezone
GARC_DIR = Path(__file__).parent.parent
SCOPE_MAP_PATH = GARC_DIR / "config" / "scope-map.json"
GATE_POLICY_PATH = GARC_DIR / "config" / "gate-policy.json"
# ─────────────────────────────────────────────────────────────────
# Task type → GARC CLI tool mapping
# This is the GWS equivalent of LARC's TASK_OPENCLAW_TOOLS
# ─────────────────────────────────────────────────────────────────
TASK_GARC_TOOLS: dict[str, list[str]] = {
"send_email": ["garc gmail send --to <recipient> --subject <subject> --body <body>"],
"reply_email": ["garc gmail search <query>", "garc gmail read <message_id>", "garc gmail send --to <sender> --subject <Re: subject> --body <body>"],
"read_email": ["garc gmail inbox --unread", "garc gmail search <query>", "garc gmail read <message_id>"],
"search_email": ["garc gmail search <query> --max 10"],
"draft_email": ["garc gmail draft --to <recipient> --subject <subject> --body <body>"],
"read_document": ["garc drive search <query>", "garc drive download --file-id <id> --output /tmp/doc.txt"],
"create_document": ["garc drive create-doc <title>", "garc drive upload <path>"],
"update_document": ["garc drive download --file-id <id>", "# edit locally", "garc drive upload <path>"],
"search_document": ["garc drive search <query> --type doc"],
"read_spreadsheet": ["garc sheets read --range <Sheet!A:Z>", "garc sheets search --sheet <name> --query <text>"],
"write_spreadsheet": ["garc sheets append --sheet <name> --values '[\"v1\",\"v2\"]'", "garc sheets write --range <A1> --values '[[...]]'"],
"read_calendar": ["garc calendar today", "garc calendar list --days <N>", "garc calendar list --query <keyword>"],
"create_event": ["garc calendar freebusy --start <date> --end <date> --emails <attendees>", "garc calendar create --summary <title> --start <dt> --end <dt> --attendees <emails>"],
"update_event": ["garc calendar list --query <keyword>", "garc calendar update <event_id> --summary <new_title>"],
"delete_event": ["garc calendar list --query <keyword>", "garc calendar delete <event_id>"],
"check_availability": ["garc calendar freebusy --start <date> --end <date> --emails <emails>"],
"create_task": ["garc task create \"<title>\" --due <YYYY-MM-DD> --notes <notes>"],
"update_task": ["garc task list", "garc task update <task_id> --due <date>"],
"complete_task": ["garc task list", "garc task done <task_id>"],
"read_tasks": ["garc task list", "garc task list --completed"],
"upload_file": ["garc drive upload <local_path> --folder-id <id>"],
"download_file": ["garc drive search <query>", "garc drive download --file-id <id> --output <path>"],
"share_file": ["garc drive share <file_id> --email <email> --role writer"],
"create_folder": ["garc drive create-folder <name>"],
"search_contact": ["garc people search <name>", "garc people lookup <name>"],
"read_contact": ["garc people show <contact_id>"],
"create_contact": ["garc people create --name <name> --email <email> --company <company>"],
"write_memory": ["garc memory push \"<entry>\""],
"read_memory": ["garc memory search <query>", "garc memory pull"],
"create_expense": ["garc sheets append --sheet approval --values '[\"expense\",\"<amount>\",\"<desc>\",\"pending\"]'", "garc approve create \"expense: <description>\"", "garc gmail send --to <approver> --subject \"[GARC] Expense Approval Required\""],
"submit_approval": ["garc approve create \"<task description>\"", "garc approve list"],
"read_approval": ["garc approve list"],
"register_agent": ["garc agent register", "garc agent list"],
"read_agent": ["garc agent list", "garc agent show <agent_id>"],
}
# Task description templates for execute-stub output
TASK_PLANS: dict[str, str] = {
"send_email": "Compose and send an email to the target recipient(s).",
"reply_email": "Find the original email thread and compose a reply.",
"read_email": "Search and read relevant emails from Gmail.",
"search_email": "Search Gmail for emails matching the criteria.",
"draft_email": "Prepare an email draft without sending.",
"read_document": "Search for and read the target document from Google Drive.",
"create_document": "Create a new document or file in Google Drive.",
"update_document": "Download, modify, and re-upload the target document.",
"search_document": "Search Google Drive for documents matching the criteria.",
"read_spreadsheet": "Read data from the target Google Sheet.",
"write_spreadsheet": "Write or append data to the target Google Sheet.",
"read_calendar": "Retrieve calendar events for the specified time range.",
"create_event": "Check availability and create a calendar event.",
"update_event": "Find and update the target calendar event.",
"delete_event": "Find and delete the target calendar event.",
"check_availability": "Query free/busy status for the specified attendees.",
"create_task": "Create a new task in Google Tasks.",
"update_task": "Find and update the target task.",
"complete_task": "Find and mark the target task as completed.",
"read_tasks": "List current Google Tasks.",
"upload_file": "Upload a local file to Google Drive.",
"download_file": "Search for and download a file from Google Drive.",
"share_file": "Share a Google Drive file with the specified user.",
"create_folder": "Create a new folder in Google Drive.",
"search_contact": "Search Google Contacts for the specified person.",
"read_contact": "Get full contact details.",
"create_contact": "Create a new contact in Google People.",
"write_memory": "Save an important context entry to agent memory (Google Sheets).",
"read_memory": "Search or sync agent memory from Google Sheets.",
"create_expense": "Prepare expense record, create approval request, and notify approver.",
"submit_approval": "Create an approval request and notify the approver via Gmail.",
"read_approval": "List pending approval requests.",
"register_agent": "Register a new agent in the GARC agent registry.",
"read_agent": "List or show agent details from the registry.",
}
# ─────────────────────────────────────────────────────────────────
# Payload builder
# ─────────────────────────────────────────────────────────────────
def load_scope_map() -> dict:
if not SCOPE_MAP_PATH.exists():
return {}
with open(SCOPE_MAP_PATH) as f:
return json.load(f)
def load_gate_policy() -> dict:
if not GATE_POLICY_PATH.exists():
return {}
with open(GATE_POLICY_PATH) as f:
return json.load(f)
def infer_task_types(text: str, scope_map: dict) -> list[str]:
"""Match text against scope-map keyword patterns."""
text_lower = text.lower()
matched = []
# scope-map.json uses "keyword_patterns" key
patterns = scope_map.get("keyword_patterns", scope_map.get("patterns", {}))
for task_type, keywords in patterns.items():
for kw in keywords:
if kw.lower() in text_lower:
if task_type not in matched:
matched.append(task_type)
break
return matched if matched else [] # empty = unknown, caller decides fallback
def infer_gate(task_types: list[str], gate_policy: dict) -> str:
"""Return the highest-risk gate for the given task types."""
gates = gate_policy.get("gates", {})
highest = "none"
order = ["none", "preview", "approval"]
for task in task_types:
for gate_name, gate_data in gates.items():
if task in gate_data.get("tasks", []):
if order.index(gate_name) > order.index(highest):
highest = gate_name
return highest
def infer_scopes(task_types: list[str], scope_map: dict) -> list[str]:
"""Collect all OAuth scopes needed for the task types."""
tasks = scope_map.get("tasks", {})
scopes: set[str] = set()
for task in task_types:
if task in tasks:
scopes.update(tasks[task].get("scopes", []))
return sorted(scopes)
def build_queue_id(text: str) -> str:
digest = hashlib.sha256(f"{text}{time.time()}".encode()).hexdigest()
return digest[:8]
def build_payload(text: str, source: str = "manual", sender: str = "", agent: str = "main") -> dict:
scope_map = load_scope_map()
gate_policy = load_gate_policy()
task_types = infer_task_types(text, scope_map)
gate = infer_gate(task_types, gate_policy)
scopes = infer_scopes(task_types, scope_map)
# authority: who is sending this request
authority = "human_operator" if source == "manual" else "gmail_trigger"
return {
"queue_id": build_queue_id(text),
"message_text": text,
"source": source,
"sender": sender,
"agent_id": agent,
"task_types": task_types,
"scopes": scopes,
"gate": gate,
"authority": authority,
"status": "pending",
"created_at": datetime.now(timezone.utc).isoformat(),
"updated_at": None,
"approval_id": None,
"session_id": None,
"note": "",
}
def cmd_build_payload(args):
payload = build_payload(args.text, args.source, args.sender, args.agent)
print()
print(" Queue item preview")
print(f" queue_id: {payload['queue_id']}")
print(f" agent_id: {payload['agent_id']}")
print(f" source: {payload['source']}")
print(f" sender: {payload['sender'] or '-'}")
print(f" task_types: {', '.join(payload['task_types']) if payload['task_types'] else '(none matched)'}")
print(f" scopes: {len(payload['scopes'])} scope(s)")
print(f" gate: {payload['gate']}")
print(f" status: {payload['status']}")
print()
print(f"Queued: {payload['queue_id']}")
print(f" status: {payload['status']}")
print(f" gate: {payload['gate']}")
print(f" tasks: {', '.join(payload['task_types']) if payload['task_types'] else '(none matched)'}")
return payload
# ─────────────────────────────────────────────────────────────────
# Execute stub — maps queue item to execution plan
# ─────────────────────────────────────────────────────────────────
def cmd_execute_stub(args):
"""Generate an execution plan from a queue item."""
queue_file = Path(args.queue_file)
if not queue_file.exists():
print(f"Error: queue file not found: {queue_file}", file=sys.stderr)
sys.exit(1)
with open(queue_file) as f:
q = json.loads(f.readline().strip())
task_types = q.get("task_types", [])
message = q.get("message_text", q.get("message", ""))
queue_id = q.get("queue_id", "")
gate = q.get("gate", "preview")
agent_id = q.get("agent_id", q.get("agent", "main"))
print()
print("=" * 60)
print("GARC Execution Stub")
print("=" * 60)
print(f"Queue ID: {queue_id}")
print(f"Agent: {agent_id}")
print(f"Gate: {gate}")
print(f"Task types: {', '.join(task_types) if task_types else '(generic)'}")
print()
print("Task:")
print(f" {message}")
print()
# Step-by-step plan
print("Execution plan:")
print("-" * 40)
step = 1
seen_tools: set[str] = set()
for task in task_types:
plan = TASK_PLANS.get(task, f"Execute {task} operation.")
tools = TASK_GARC_TOOLS.get(task, [])
print(f"[Step {step}] {plan}")
for tool in tools:
if tool not in seen_tools:
print(f"{tool}")
seen_tools.add(tool)
step += 1
print()
if not task_types:
print("[Step 1] Execute the requested task using available GARC tools.")
print(" → garc gmail send / garc drive search / garc sheets read / ...")
print()
print("-" * 40)
print("When complete, run:")
print(f" garc ingress done --queue-id {queue_id} --note \"<what was done>\"")
print(f" (or: garc ingress fail --queue-id {queue_id} --note \"<reason>\")")
# ─────────────────────────────────────────────────────────────────
# Build prompt — Claude Code readable output
# ─────────────────────────────────────────────────────────────────
def cmd_build_prompt(args):
"""Build a Claude Codeready prompt from a queue item."""
queue_file = Path(args.queue_file)
if not queue_file.exists():
print(f"Error: queue file not found: {queue_file}", file=sys.stderr)
sys.exit(1)
with open(queue_file) as f:
q = json.loads(f.readline().strip())
task_types = q.get("task_types", [])
message = q.get("message_text", q.get("message", ""))
queue_id = q.get("queue_id", "")
gate = q.get("gate", "preview")
agent_id = q.get("agent_id", q.get("agent", "main"))
source = q.get("source", "manual")
sender = q.get("sender", "")
# Collect suggested commands
suggested_cmds: list[str] = []
for task in task_types:
for cmd in TASK_GARC_TOOLS.get(task, []):
if cmd not in suggested_cmds:
suggested_cmds.append(cmd)
# Build prompt
lines = [
"## GARC Task",
"",
f"**Queue ID**: `{queue_id}` ",
f"**Gate**: `{gate}` ",
f"**Source**: {source}" + (f" (from: {sender})" if sender else ""),
"",
"### Task description",
"",
message,
"",
]
if task_types:
lines += [
"### Inferred task types",
"",
" " + ", ".join(f"`{t}`" for t in task_types),
"",
]
if suggested_cmds:
lines += [
"### Suggested GARC commands",
"",
]
for cmd in suggested_cmds[:10]:
lines.append(f" ```bash\n {cmd}\n ```")
lines.append("")
# Gate guidance
if gate == "approval":
lines += [
"### ⚠️ Approval required",
"",
"This task requires human approval before execution.",
f" ```bash\n garc approve create \"{message[:60]}\"\n ```",
"",
]
elif gate == "preview":
lines += [
"### ⚠️ Preview gate",
"",
"Confirm the plan with the user before executing write operations.",
"",
]
# Agent context excerpt
context_path = args.agent_context if hasattr(args, "agent_context") and args.agent_context else None
if not context_path:
garc_cache = Path.home() / ".garc" / "cache"
context_path = str(garc_cache / "workspace" / agent_id / "AGENT_CONTEXT.md")
if context_path and Path(context_path).exists():
with open(context_path) as f:
context_lines = f.readlines()[:30]
lines += [
"### Agent context (excerpt)",
"```",
] + [l.rstrip() for l in context_lines] + [
"```",
"",
]
lines += [
"### After execution",
"",
f"```bash",
f"garc ingress done --queue-id {queue_id} --note \"<what was done>\"",
f"# or on failure:",
f"garc ingress fail --queue-id {queue_id} --note \"<reason>\"",
f"```",
]
print("\n".join(lines))
# ─────────────────────────────────────────────────────────────────
# Stats
# ─────────────────────────────────────────────────────────────────
def cmd_stats(args):
queue_dir = Path(args.queue_dir)
if not queue_dir.exists():
print("Queue directory not found.")
return
counts: dict[str, int] = {}
total = 0
for f in queue_dir.glob("*.jsonl"):
try:
q = json.loads(f.read_text().splitlines()[0])
status = q.get("status", "unknown")
counts[status] = counts.get(status, 0) + 1
total += 1
except Exception:
continue
print(f"Queue stats (total: {total}):")
for status in ["pending", "in_progress", "blocked", "done", "failed"]:
icon = {"pending": "", "in_progress": "🔄", "blocked": "🔒", "done": "", "failed": ""}.get(status, "")
print(f" {icon} {status:<14} {counts.get(status, 0)}")
# ─────────────────────────────────────────────────────────────────
# CLI entry point
# ─────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="GARC Ingress Helper")
subparsers = parser.add_subparsers(dest="command", required=True)
# build-payload
bp = subparsers.add_parser("build-payload")
bp.add_argument("--text", required=True)
bp.add_argument("--source", default="manual")
bp.add_argument("--sender", default="")
bp.add_argument("--agent", default="main")
# execute-stub
es = subparsers.add_parser("execute-stub")
es.add_argument("--queue-file", required=True)
# build-prompt
pr = subparsers.add_parser("build-prompt")
pr.add_argument("--queue-file", required=True)
pr.add_argument("--agent-context", default="")
# stats
st = subparsers.add_parser("stats")
st.add_argument("--queue-dir", required=True)
args = parser.parse_args()
if args.command == "build-payload":
cmd_build_payload(args)
elif args.command == "execute-stub":
cmd_execute_stub(args)
elif args.command == "build-prompt":
cmd_build_prompt(args)
elif args.command == "stats":
cmd_stats(args)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,392 @@
#!/usr/bin/env python3
"""
GARC People Helper Google People API (Contacts & Directory)
search / list / show / create / update / delete
"""
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from garc_core import build_service, with_retry
PERSON_FIELDS = "names,emailAddresses,phoneNumbers,organizations,addresses,biographies"
CONTACT_SCOPES = [
"https://www.googleapis.com/auth/contacts",
"https://www.googleapis.com/auth/directory.readonly",
]
def get_service():
return build_service("people", "v1", scopes=CONTACT_SCOPES)
def _fmt_person(p: dict, short: bool = False) -> str:
"""Format a person resource as a readable string."""
name = p.get("names", [{}])[0].get("displayName", "(no name)")
emails = [e.get("value", "") for e in p.get("emailAddresses", [])]
phones = [ph.get("value", "") for ph in p.get("phoneNumbers", [])]
orgs = [o.get("name", "") for o in p.get("organizations", [])]
resource = p.get("resourceName", "")
short_id = resource.split("/")[-1] if "/" in resource else resource
if short:
email_str = emails[0] if emails else ""
org_str = f" ({orgs[0]})" if orgs else ""
return f"[{short_id[:10]}] {name}{org_str}{email_str}"
lines = [f"Name: {name}", f"ID: {short_id}"]
for e in emails:
lines.append(f"Email: {e}")
for ph in phones:
lines.append(f"Phone: {ph}")
for o in p.get("organizations", []):
org_parts = [x for x in [o.get("name"), o.get("title"), o.get("department")] if x]
lines.append(f"Org: {' / '.join(org_parts)}")
for bio in p.get("biographies", []):
lines.append(f"Bio: {bio.get('value', '')[:80]}")
return "\n".join(lines)
# ─────────────────────────────────────────────
# Search (Directory + personal contacts)
# ─────────────────────────────────────────────
@with_retry()
def search_contacts(query: str, max_results: int = 20, format_: str = "table"):
"""Search contacts from the user's personal contacts."""
service = get_service()
result = service.people().searchContacts(
query=query,
readMask=PERSON_FIELDS,
pageSize=min(max_results, 30),
).execute()
results = result.get("results", [])
if not results:
print(f"No contacts found for: {query}")
return
if format_ == "json":
print(json.dumps([r.get("person", {}) for r in results], ensure_ascii=False, indent=2))
return
print(f"Contacts matching '{query}' ({len(results)}):")
for r in results:
print(f" {_fmt_person(r.get('person', {}), short=True)}")
@with_retry()
def search_directory(query: str, max_results: int = 20, format_: str = "table"):
"""Search the Google Workspace directory (org-wide)."""
service = get_service()
try:
result = service.people().searchDirectoryPeople(
query=query,
readMask=PERSON_FIELDS,
sources=["DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE", "DIRECTORY_SOURCE_TYPE_DOMAIN_CONTACT"],
pageSize=min(max_results, 30),
).execute()
except Exception as e:
if "403" in str(e):
print("Directory search requires Google Workspace (not personal Gmail).", file=sys.stderr)
else:
raise
return
people = result.get("people", [])
if not people:
print(f"No directory results for: {query}")
return
if format_ == "json":
print(json.dumps(people, ensure_ascii=False, indent=2))
return
print(f"Directory results for '{query}' ({len(people)}):")
for p in people:
print(f" {_fmt_person(p, short=True)}")
# ─────────────────────────────────────────────
# Contacts CRUD
# ─────────────────────────────────────────────
@with_retry()
def list_contacts(max_results: int = 50, format_: str = "table"):
"""List all personal contacts."""
service = get_service()
result = service.people().connections().list(
resourceName="people/me",
personFields=PERSON_FIELDS,
pageSize=min(max_results, 1000),
sortOrder="LAST_MODIFIED_DESCENDING",
).execute()
people = result.get("connections", [])
if not people:
print("No contacts found.")
return
if format_ == "json":
print(json.dumps(people, ensure_ascii=False, indent=2))
return
print(f"Contacts ({len(people)}):")
for p in people:
print(f" {_fmt_person(p, short=True)}")
@with_retry()
def show_contact(contact_id: str):
"""Show full details of a contact."""
service = get_service()
# Accept short ID or full resource name
if not contact_id.startswith("people/"):
contact_id = f"people/{contact_id}"
person = service.people().get(
resourceName=contact_id,
personFields=PERSON_FIELDS,
).execute()
print(_fmt_person(person))
@with_retry()
def create_contact(
name: str,
email: str = None,
phone: str = None,
company: str = None,
title: str = None,
notes: str = None,
):
"""Create a new contact."""
service = get_service()
body: dict = {}
# Name
parts = name.split(" ", 1)
body["names"] = [{
"givenName": parts[0],
"familyName": parts[1] if len(parts) > 1 else "",
}]
if email:
body["emailAddresses"] = [{"value": email, "type": "work"}]
if phone:
body["phoneNumbers"] = [{"value": phone, "type": "work"}]
if company or title:
body["organizations"] = [{
"name": company or "",
"title": title or "",
"type": "work",
}]
if notes:
body["biographies"] = [{"value": notes, "contentType": "TEXT_PLAIN"}]
result = service.people().createContact(body=body).execute()
resource_id = result.get("resourceName", "").split("/")[-1]
print(f"✅ Contact created: [{resource_id}] {name}")
if email:
print(f" Email: {email}")
@with_retry()
def update_contact(
contact_id: str,
name: str = None,
email: str = None,
phone: str = None,
company: str = None,
title: str = None,
):
"""Update fields of an existing contact."""
service = get_service()
if not contact_id.startswith("people/"):
contact_id = f"people/{contact_id}"
# Fetch current
person = service.people().get(
resourceName=contact_id,
personFields=PERSON_FIELDS,
).execute()
etag = person.get("etag")
update_fields = []
if name:
parts = name.split(" ", 1)
person["names"] = [{
"givenName": parts[0],
"familyName": parts[1] if len(parts) > 1 else "",
}]
update_fields.append("names")
if email:
person["emailAddresses"] = [{"value": email, "type": "work"}]
update_fields.append("emailAddresses")
if phone:
person["phoneNumbers"] = [{"value": phone, "type": "work"}]
update_fields.append("phoneNumbers")
if company or title:
existing_org = (person.get("organizations") or [{}])[0]
person["organizations"] = [{
"name": company or existing_org.get("name", ""),
"title": title or existing_org.get("title", ""),
"type": "work",
}]
update_fields.append("organizations")
if not update_fields:
print("No updates specified.")
return
person["etag"] = etag
service.people().updateContact(
resourceName=contact_id,
updatePersonFields=",".join(update_fields),
body=person,
).execute()
short_id = contact_id.split("/")[-1]
print(f"✅ Contact updated: [{short_id}]")
@with_retry()
def delete_contact(contact_id: str):
"""Delete a contact."""
service = get_service()
if not contact_id.startswith("people/"):
contact_id = f"people/{contact_id}"
service.people().deleteContact(resourceName=contact_id).execute()
short_id = contact_id.split("/")[-1]
print(f"🗑️ Contact deleted: [{short_id}]")
# ─────────────────────────────────────────────
# Email Lookup helper (used by gmail.sh)
# ─────────────────────────────────────────────
@with_retry()
def lookup_email(name_or_email: str):
"""Quick lookup: find email for a name. Tries contacts then directory."""
service = get_service()
# Try personal contacts first
try:
result = service.people().searchContacts(
query=name_or_email,
readMask="names,emailAddresses",
pageSize=5,
).execute()
for r in result.get("results", []):
p = r.get("person", {})
emails = p.get("emailAddresses", [])
if emails:
name = p.get("names", [{}])[0].get("displayName", "")
email = emails[0].get("value", "")
print(f"{name} <{email}>")
return
except Exception:
pass
# Try directory
try:
result = service.people().searchDirectoryPeople(
query=name_or_email,
readMask="names,emailAddresses",
sources=["DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE"],
pageSize=5,
).execute()
for p in result.get("people", []):
emails = p.get("emailAddresses", [])
if emails:
name = p.get("names", [{}])[0].get("displayName", "")
email = emails[0].get("value", "")
print(f"{name} <{email}>")
return
except Exception:
pass
print(f"Not found: {name_or_email}")
# ─────────────────────────────────────────────
# CLI
# ─────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="GARC People Helper — Google Contacts & Directory")
parser.add_argument("--format", "-f", dest="format_", default="table", choices=["table", "json"])
subparsers = parser.add_subparsers(dest="command", required=True)
# search
sp = subparsers.add_parser("search", help="Search personal contacts")
sp.add_argument("query", nargs="+")
sp.add_argument("--max", type=int, default=20)
# directory
dp = subparsers.add_parser("directory", help="Search GWS directory (org-wide)")
dp.add_argument("query", nargs="+")
dp.add_argument("--max", type=int, default=20)
# list
lp = subparsers.add_parser("list", help="List all personal contacts")
lp.add_argument("--max", type=int, default=50)
# show
shp = subparsers.add_parser("show", help="Show a contact by ID")
shp.add_argument("contact_id")
# create
cp = subparsers.add_parser("create", help="Create a new contact")
cp.add_argument("--name", required=True)
cp.add_argument("--email")
cp.add_argument("--phone")
cp.add_argument("--company")
cp.add_argument("--title")
cp.add_argument("--notes")
# update
up = subparsers.add_parser("update", help="Update a contact")
up.add_argument("contact_id")
up.add_argument("--name")
up.add_argument("--email")
up.add_argument("--phone")
up.add_argument("--company")
up.add_argument("--title")
# delete
delp = subparsers.add_parser("delete", help="Delete a contact")
delp.add_argument("contact_id")
# lookup
look = subparsers.add_parser("lookup", help="Quick email lookup by name")
look.add_argument("query", nargs="+")
args = parser.parse_args()
try:
if args.command == "search":
search_contacts(" ".join(args.query), args.max, args.format_)
elif args.command == "directory":
search_directory(" ".join(args.query), args.max, args.format_)
elif args.command == "list":
list_contacts(args.max, args.format_)
elif args.command == "show":
show_contact(args.contact_id)
elif args.command == "create":
create_contact(args.name, args.email, args.phone, args.company, args.title, args.notes)
elif args.command == "update":
update_contact(args.contact_id, args.name, args.email, args.phone, args.company, args.title)
elif args.command == "delete":
delete_contact(args.contact_id)
elif args.command == "lookup":
lookup_email(" ".join(args.query))
except KeyboardInterrupt:
sys.exit(0)
if __name__ == "__main__":
main()

516
scripts/garc-setup.py Normal file
View file

@ -0,0 +1,516 @@
#!/usr/bin/env python3
"""
GARC Setup Interactive workspace provisioner
- Creates Google Drive folder structure
- Provisions Google Sheets with all required tabs and headers
- Uploads initial disclosure chain templates to Drive
- Validates all APIs are accessible
"""
import argparse
import json
import os
import sys
from pathlib import Path
# Add scripts dir to path for garc-core
sys.path.insert(0, str(Path(__file__).parent))
from garc_core import build_service, load_config, utc_now, GARC_CONFIG_DIR
# Sheet definitions: tab name → headers
SHEET_TABS = {
"memory": ["agent_id", "timestamp", "entry", "source", "tags"],
"agents": ["id", "model", "scopes", "description", "profile", "status", "drive_folder", "registered_at"],
"queue": ["queue_id", "agent_id", "message", "status", "gate", "source", "created_at", "updated_at", "assigned_to"],
"heartbeat": ["agent_id", "timestamp", "status", "notes", "platform", "context_file"],
"approval": ["approval_id", "agent_id", "task", "status", "created_at", "resolved_at", "resolver", "notes"],
"tasks_log": ["task_id", "agent_id", "title", "google_task_id", "status", "created_at", "completed_at"],
"email_log": ["msg_id", "agent_id", "to", "subject", "sent_at", "thread_id"],
"calendar_log": ["event_id", "agent_id", "title", "start", "end", "created_at"],
}
# Disclosure chain templates
DISCLOSURE_TEMPLATES = {
"SOUL.md": """# SOUL — Agent Identity
agent_id: main
platform: Google Workspace
runtime: GARC v0.1.0
created: {timestamp}
## Core Principles
1. **Permission-first**: Always run `garc auth suggest` before a new task category.
2. **Minimum viable scopes**: Request only what is needed.
3. **Gate compliance**: Respect none / preview / approval execution gates.
4. **Transparency**: Explain actions before executing them.
5. **Reversibility preference**: Prefer reversible operations; flag irreversible ones.
## Identity
This agent operates within Google Workspace on behalf of the registered user.
It has access to Drive, Sheets, Gmail, Calendar, and Tasks as configured.
## Persona
Helpful, precise, audit-minded. Acts as a trusted digital colleague.
""",
"USER.md": """# USER — User Profile
## Identity
name: (your name)
email: (your Gmail)
timezone: Asia/Tokyo
language: Japanese / English
## Work Context
role: (your role)
organization: (your organization)
primary_tools: [Gmail, Google Drive, Google Sheets, Google Calendar]
## Preferences
- Communication style: direct, structured
- Approval threshold: always confirm before sending external emails
- Memory: persist important decisions and context
- Calendar: treat work hours as 09:00-18:00 JST
## Created
{timestamp} edit this file in Google Drive to customize.
""",
"MEMORY.md": """# MEMORY — Long-term Memory Index
Last sync: {timestamp}
Backend: Google Sheets (configured in ~/.garc/config.env)
## How to use
- Pull latest: `garc memory pull`
- Add entry: `garc memory push "key decision: ..."`
- Search: `garc memory search "keyword"`
- View raw: Google Sheets memory tab
## Recent context
(populated by `garc memory pull`)
""",
"RULES.md": """# RULES — Operating Rules
## Execution Rules
1. Always check execution gate before any write operation
- `garc approve gate <task_type>`
2. For `preview` gate: show preview, ask for confirmation
3. For `approval` gate: create approval request, wait for human
4. Never send email without explicit confirmation unless gate is `none`
5. Never delete files or calendar events without `approval` gate clearance
## Memory Rules
1. After any significant decision, push to memory
- `garc memory push "decided: ..."`
2. Pull memory at session start for context
- `garc memory pull`
3. Heartbeat at session end
- `garc heartbeat`
## Communication Rules
1. Default reply-to: use GARC_GMAIL_DEFAULT_TO for notifications
2. Subject prefix for agent emails: `[GARC]`
3. CC user on all outbound approvals
## Safety Rules
1. Max 50 emails/hour limit (self-imposed)
2. Max 100 Drive file operations/hour
3. Never modify shared Drives without explicit instruction
4. Always confirm before recurring calendar events
""",
"HEARTBEAT.md": """# HEARTBEAT — System State
agent_id: main
last_bootstrap: {timestamp}
status: initialized
platform: Google Workspace
## Latest State
(updated by `garc heartbeat`)
""",
}
def check_api_access(config: dict) -> dict:
"""Verify all required APIs are accessible."""
results = {}
print("\n🔍 Checking API access...")
# Drive
try:
svc = build_service("drive", "v3")
svc.about().get(fields="user").execute()
results["Drive API"] = ""
except Exception as e:
results["Drive API"] = f"{str(e)[:60]}"
# Sheets
try:
svc = build_service("sheets", "v4")
results["Sheets API"] = ""
except Exception as e:
results["Sheets API"] = f"{str(e)[:60]}"
# Gmail
try:
svc = build_service("gmail", "v1")
svc.users().getProfile(userId="me").execute()
results["Gmail API"] = ""
except Exception as e:
results["Gmail API"] = f"{str(e)[:60]}"
# Calendar
try:
svc = build_service("calendar", "v3")
svc.calendarList().list(maxResults=1).execute()
results["Calendar API"] = ""
except Exception as e:
results["Calendar API"] = f"{str(e)[:60]}"
# Tasks
try:
svc = build_service("tasks", "v1")
svc.tasklists().list(maxResults=1).execute()
results["Tasks API"] = ""
except Exception as e:
results["Tasks API"] = f"{str(e)[:60]}"
# Docs
try:
svc = build_service("docs", "v1")
results["Docs API"] = ""
except Exception as e:
results["Docs API"] = f"{str(e)[:60]}"
# People
try:
svc = build_service("people", "v1", scopes=["https://www.googleapis.com/auth/contacts.readonly"])
svc.people().connections().list(
resourceName="people/me",
personFields="names",
pageSize=1,
).execute()
results["People API"] = ""
except Exception as e:
results["People API"] = f"{str(e)[:60]}"
for api, status in results.items():
print(f" {status} {api}")
return results
def provision_sheets(config: dict) -> str:
"""Create or update Google Sheets with all required tabs."""
sheets_id = config.get("GARC_SHEETS_ID", "")
svc = build_service("sheets", "v4")
if sheets_id:
print(f"\n📊 Using existing Sheets: {sheets_id}")
# Get existing sheet info
try:
meta = svc.spreadsheets().get(spreadsheetId=sheets_id).execute()
existing_tabs = {s["properties"]["title"] for s in meta.get("sheets", [])}
print(f" Existing tabs: {', '.join(existing_tabs)}")
except Exception as e:
print(f"❌ Cannot access Sheets {sheets_id}: {e}")
sheets_id = ""
if not sheets_id:
print("\n📊 Creating new Google Sheets for GARC...")
result = svc.spreadsheets().create(body={
"properties": {"title": "GARC Workspace Data"},
"sheets": [{"properties": {"title": tab}} for tab in SHEET_TABS.keys()]
}).execute()
sheets_id = result["spreadsheetId"]
existing_tabs = set(SHEET_TABS.keys())
print(f" ✅ Created: https://docs.google.com/spreadsheets/d/{sheets_id}")
else:
existing_tabs = existing_tabs # from above
# Add missing tabs
meta = svc.spreadsheets().get(spreadsheetId=sheets_id).execute()
existing_tab_names = {s["properties"]["title"] for s in meta.get("sheets", [])}
add_requests = []
for tab_name in SHEET_TABS:
if tab_name not in existing_tab_names:
add_requests.append({
"addSheet": {"properties": {"title": tab_name}}
})
if add_requests:
svc.spreadsheets().batchUpdate(
spreadsheetId=sheets_id,
body={"requests": add_requests}
).execute()
print(f" ✅ Added tabs: {[r['addSheet']['properties']['title'] for r in add_requests]}")
# Write headers to each tab
batch_data = []
for tab_name, headers in SHEET_TABS.items():
batch_data.append({
"range": f"{tab_name}!A1:{chr(65 + len(headers) - 1)}1",
"values": [headers]
})
svc.spreadsheets().values().batchUpdate(
spreadsheetId=sheets_id,
body={
"valueInputOption": "RAW",
"data": batch_data
}
).execute()
print(f" ✅ Headers written to all {len(SHEET_TABS)} tabs")
# Bold the header row in each sheet (formatting)
meta = svc.spreadsheets().get(spreadsheetId=sheets_id).execute()
sheet_id_map = {s["properties"]["title"]: s["properties"]["sheetId"] for s in meta.get("sheets", [])}
format_requests = []
for tab_name in SHEET_TABS:
sid = sheet_id_map.get(tab_name)
if sid is not None:
format_requests.append({
"repeatCell": {
"range": {"sheetId": sid, "startRowIndex": 0, "endRowIndex": 1},
"cell": {"userEnteredFormat": {"textFormat": {"bold": True}, "backgroundColor": {"red": 0.9, "green": 0.9, "blue": 0.9}}},
"fields": "userEnteredFormat(textFormat,backgroundColor)"
}
})
# Freeze header row
format_requests.append({
"updateSheetProperties": {
"properties": {"sheetId": sid, "gridProperties": {"frozenRowCount": 1}},
"fields": "gridProperties.frozenRowCount"
}
})
if format_requests:
svc.spreadsheets().batchUpdate(
spreadsheetId=sheets_id,
body={"requests": format_requests}
).execute()
print(" ✅ Headers formatted (bold + freeze)")
return sheets_id
def provision_drive(config: dict) -> str:
"""Create Drive folder structure for agent workspace."""
folder_id = config.get("GARC_DRIVE_FOLDER_ID", "")
svc = build_service("drive", "v3")
if folder_id:
print(f"\n📁 Using existing Drive folder: {folder_id}")
try:
meta = svc.files().get(fileId=folder_id, fields="id,name").execute()
print(f" Folder: {meta['name']}")
except Exception:
print(f"⚠️ Folder not accessible, creating new one...")
folder_id = ""
if not folder_id:
print("\n📁 Creating GARC Drive folder...")
result = svc.files().create(body={
"name": "GARC Workspace",
"mimeType": "application/vnd.google-apps.folder"
}, fields="id,name").execute()
folder_id = result["id"]
print(f" ✅ Created folder: {result['name']} ({folder_id})")
# Create memory subfolder
memory_query = f"'{folder_id}' in parents and name='memory' and mimeType='application/vnd.google-apps.folder'"
existing = svc.files().list(q=memory_query, fields="files(id,name)").execute()
if not existing.get("files"):
svc.files().create(body={
"name": "memory",
"mimeType": "application/vnd.google-apps.folder",
"parents": [folder_id]
}).execute()
print(" ✅ Created memory/ subfolder")
return folder_id
def upload_disclosure_chain(folder_id: str):
"""Upload disclosure chain template files to Google Drive."""
svc = build_service("drive", "v3")
ts = utc_now()
print("\n📝 Uploading disclosure chain templates...")
for filename, template in DISCLOSURE_TEMPLATES.items():
content = template.replace("{timestamp}", ts).encode("utf-8")
# Check if file exists
query = f"'{folder_id}' in parents and name='{filename}' and trashed=false"
existing = svc.files().list(q=query, fields="files(id,name)").execute()
if existing.get("files"):
# Skip if already exists (don't overwrite user's customized files)
print(f" ⏭️ {filename} (already exists, skipping)")
continue
from googleapiclient.http import MediaIoBaseUpload
import io
media = MediaIoBaseUpload(io.BytesIO(content), mimetype="text/plain")
svc.files().create(
body={"name": filename, "parents": [folder_id]},
media_body=media,
fields="id,name"
).execute()
print(f"{filename}")
def save_config(config_updates: dict):
"""Save updated config values to ~/.garc/config.env."""
config_file = GARC_CONFIG_DIR / "config.env"
GARC_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
# Read existing
existing = {}
if config_file.exists():
with open(config_file) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, _, v = line.partition("=")
existing[k.strip()] = v.strip()
existing.update(config_updates)
# Write back
template_file = Path(__file__).parent.parent / "config" / "config.env.example"
if template_file.exists():
with open(template_file) as f:
template_lines = f.readlines()
out_lines = []
written_keys = set()
for line in template_lines:
stripped = line.strip()
if stripped and not stripped.startswith("#") and "=" in stripped:
key = stripped.split("=")[0].strip()
if key in existing:
out_lines.append(f"{key}={existing[key]}\n")
written_keys.add(key)
continue
out_lines.append(line)
# Add any extra keys not in template
for key, val in existing.items():
if key not in written_keys:
out_lines.append(f"{key}={val}\n")
with open(config_file, "w") as f:
f.writelines(out_lines)
else:
with open(config_file, "w") as f:
for key, val in existing.items():
f.write(f"{key}={val}\n")
config_file.chmod(0o600)
print(f"\n✅ Config saved: {config_file}")
def main():
parser = argparse.ArgumentParser(description="GARC Setup Wizard")
subparsers = parser.add_subparsers(dest="command")
# Full setup
setup_p = subparsers.add_parser("all", help="Run full setup wizard")
setup_p.add_argument("--skip-upload", action="store_true", help="Skip disclosure chain upload")
# Check only
subparsers.add_parser("check", help="Check API access only")
# Provision sheets only
subparsers.add_parser("sheets", help="Provision Sheets tabs only")
# Provision drive only
subparsers.add_parser("drive", help="Provision Drive folder only")
args = parser.parse_args()
config = load_config()
if args.command == "check":
check_api_access(config)
return
if args.command == "sheets":
sheets_id = provision_sheets(config)
save_config({"GARC_SHEETS_ID": sheets_id})
return
if args.command == "drive":
folder_id = provision_drive(config)
save_config({"GARC_DRIVE_FOLDER_ID": folder_id})
return
# Full setup
print("=" * 60)
print("GARC Workspace Setup")
print("=" * 60)
# 1. Check APIs
api_results = check_api_access(config)
failed_apis = [k for k, v in api_results.items() if v.startswith("")]
if failed_apis:
print(f"\n⚠️ {len(failed_apis)} APIs not accessible: {', '.join(failed_apis)}")
print(" See docs/google-cloud-setup.md for setup instructions")
if len(failed_apis) > 3:
print(" Too many failures. Please enable APIs first.")
return
# 2. Provision Drive
folder_id = provision_drive(config)
# 3. Provision Sheets
sheets_id = provision_sheets(config)
# 4. Upload disclosure chain
if not getattr(args, "skip_upload", False):
upload_disclosure_chain(folder_id)
# 5. Save config
save_config({
"GARC_DRIVE_FOLDER_ID": folder_id,
"GARC_SHEETS_ID": sheets_id,
})
# 6. Summary
print("\n" + "=" * 60)
print("✅ Setup complete!")
print("=" * 60)
print(f" Drive folder: https://drive.google.com/drive/folders/{folder_id}")
print(f" Sheets: https://docs.google.com/spreadsheets/d/{sheets_id}")
print()
print("Next steps:")
print(" garc bootstrap --agent main")
print(" garc status")
print(" garc memory pull")
if __name__ == "__main__":
main()

430
scripts/garc-sheets-helper.py Executable file
View file

@ -0,0 +1,430 @@
#!/usr/bin/env python3
"""
GARC Sheets Helper Full Google Sheets operations
read / write / append / search / clear / format
+ memory/agent/queue/heartbeat/approval operations
"""
import argparse
import json
import sys
from pathlib import Path
from datetime import datetime, timezone
sys.path.insert(0, str(Path(__file__).parent))
from garc_core import build_service, utc_now, with_retry
SHEET_MEMORY = "memory"
SHEET_AGENTS = "agents"
SHEET_QUEUE = "queue"
SHEET_HEARTBEAT = "heartbeat"
SHEET_APPROVAL = "approval"
SHEET_TASKS_LOG = "tasks_log"
SHEET_EMAIL_LOG = "email_log"
SHEET_CALENDAR_LOG = "calendar_log"
def get_svc():
return build_service("sheets", "v4")
# ─── Generic operations ───────────────────────────────────────────────────────
@with_retry()
def read_range(sheets_id: str, range_: str, output_format: str = "table"):
"""Read data from a Sheets range."""
svc = get_svc()
result = svc.spreadsheets().values().get(
spreadsheetId=sheets_id, range=range_,
valueRenderOption="FORMATTED_VALUE"
).execute()
rows = result.get("values", [])
if not rows:
print(f"(empty: {range_})")
return []
if output_format == "json":
if len(rows) > 1:
headers = rows[0]
data = [dict(zip(headers, row + [""] * (len(headers) - len(row)))) for row in rows[1:]]
print(json.dumps(data, ensure_ascii=False, indent=2))
else:
print(json.dumps(rows, ensure_ascii=False, indent=2))
else:
# Table format
widths = [max(len(str(rows[r][c])) if c < len(rows[r]) else 0
for r in range(len(rows))) for c in range(len(rows[0]))]
widths = [min(w, 40) for w in widths]
for i, row in enumerate(rows):
line = " ".join(str(row[c] if c < len(row) else "").ljust(widths[c])[:widths[c]]
for c in range(len(rows[0])))
print(line)
if i == 0:
print(" ".join("" * widths[c] for c in range(len(rows[0]))))
return rows
@with_retry()
def write_range(sheets_id: str, range_: str, values: list):
"""Write data to a Sheets range (overwrites)."""
svc = get_svc()
result = svc.spreadsheets().values().update(
spreadsheetId=sheets_id,
range=range_,
valueInputOption="USER_ENTERED",
body={"values": values}
).execute()
print(f"✅ Written {result.get('updatedCells', 0)} cells to {range_}")
return result
@with_retry()
def append_row(sheets_id: str, sheet_name: str, values: list):
"""Append a row to a sheet."""
svc = get_svc()
result = svc.spreadsheets().values().append(
spreadsheetId=sheets_id,
range=f"{sheet_name}!A:Z",
valueInputOption="USER_ENTERED",
insertDataOption="INSERT_ROWS",
body={"values": [values]}
).execute()
print(f"✅ Row appended to {sheet_name}")
return result
@with_retry()
def search_sheet(sheets_id: str, sheet_name: str, query: str,
column: int = -1, output_format: str = "table"):
"""Search rows in a sheet by keyword."""
svc = get_svc()
result = svc.spreadsheets().values().get(
spreadsheetId=sheets_id, range=f"{sheet_name}!A:Z"
).execute()
rows = result.get("values", [])
if not rows:
print(f"(empty: {sheet_name})")
return []
query_lower = query.lower()
matches = []
headers = rows[0] if rows else []
for row in rows[1:]:
if column >= 0:
check = str(row[column] if column < len(row) else "").lower()
else:
check = " ".join(str(cell) for cell in row).lower()
if query_lower in check:
matches.append(row)
if not matches:
print(f"No results for '{query}' in {sheet_name}")
return []
print(f"Found {len(matches)} rows in {sheet_name}:")
if headers and output_format == "table":
widths = [min(max(len(str(h)), max((len(str(r[i] if i < len(r) else "")) for r in matches), default=0)), 30)
for i, h in enumerate(headers)]
print(" ".join(h.ljust(widths[i]) for i, h in enumerate(headers)))
print(" ".join("" * widths[i] for i in range(len(headers))))
for row in matches:
print(" ".join(str(row[i] if i < len(row) else "").ljust(widths[i])[:widths[i]]
for i in range(len(headers))))
elif output_format == "json":
if headers:
data = [dict(zip(headers, r + [""] * (len(headers) - len(r)))) for r in matches]
print(json.dumps(data, ensure_ascii=False, indent=2))
return matches
@with_retry()
def get_sheet_info(sheets_id: str):
"""Get spreadsheet metadata."""
svc = get_svc()
meta = svc.spreadsheets().get(spreadsheetId=sheets_id).execute()
print(f"Title: {meta['properties']['title']}")
print(f"ID: {sheets_id}")
print(f"URL: https://docs.google.com/spreadsheets/d/{sheets_id}")
print()
print(f"Sheets ({len(meta.get('sheets', []))}):")
for s in meta.get("sheets", []):
props = s["properties"]
gp = props.get("gridProperties", {})
print(f" [{props['sheetId']:<6}] {props['title']:<20} {gp.get('rowCount', 0):>6} rows × {gp.get('columnCount', 0):>3} cols")
return meta
@with_retry()
def clear_range(sheets_id: str, range_: str):
"""Clear a range (but keep headers)."""
svc = get_svc()
svc.spreadsheets().values().clear(spreadsheetId=sheets_id, range=range_).execute()
print(f"✅ Cleared: {range_}")
# ─── GARC-specific operations ─────────────────────────────────────────────────
@with_retry()
def memory_pull(sheets_id: str, agent_id: str, output: str):
svc = get_svc()
result = svc.spreadsheets().values().get(
spreadsheetId=sheets_id, range=f"{SHEET_MEMORY}!A:E"
).execute()
rows = result.get("values", [])
headers = rows[0] if rows else []
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
out_path = Path(output)
out_path.parent.mkdir(parents=True, exist_ok=True)
with open(out_path, "w") as f:
f.write(f"# Memory — {agent_id}{today}\n\n")
for row in rows[1:]:
if not row:
continue
row_agent = row[0] if len(row) > 0 else ""
if row_agent and row_agent != agent_id:
continue
ts = row[1] if len(row) > 1 else ""
entry = row[2] if len(row) > 2 else ""
tags = row[4] if len(row) > 4 else ""
tag_str = f" `{tags}`" if tags else ""
f.write(f"## {ts[:10] if ts else 'N/A'}{tag_str}\n{entry}\n\n")
count = sum(1 for r in rows[1:] if r and (not r[0] or r[0] == agent_id))
print(f"✅ Memory pulled: {count} entries → {output}")
@with_retry()
def memory_push(sheets_id: str, agent_id: str, entry: str, timestamp: str, tags: str = ""):
append_row(sheets_id, SHEET_MEMORY, [agent_id, timestamp, entry, "manual", tags])
@with_retry()
def memory_search(sheets_id: str, query: str):
search_sheet(sheets_id, SHEET_MEMORY, query)
@with_retry()
def agent_list(sheets_id: str):
read_range(sheets_id, f"{SHEET_AGENTS}!A:H")
@with_retry()
def agent_register(sheets_id: str, yaml_file: str):
try:
import yaml
with open(yaml_file) as f:
config = yaml.safe_load(f)
except ImportError:
print("⚠️ PyYAML not installed. Install: pip install pyyaml")
return
agents = config.get("agents", [])
ts = utc_now()
for agent in agents:
scopes = ",".join(agent.get("scopes", []))
append_row(sheets_id, SHEET_AGENTS, [
agent.get("id", ""),
agent.get("model", ""),
scopes,
agent.get("description", ""),
agent.get("profile", ""),
"active",
agent.get("drive_folder", ""),
ts
])
print(f"✅ Registered {len(agents)} agents")
@with_retry()
def agent_show(sheets_id: str, agent_id: str):
search_sheet(sheets_id, SHEET_AGENTS, agent_id, column=0)
@with_retry()
def heartbeat(sheets_id: str, agent_id: str, status: str, notes: str, timestamp: str, context_file: str = ""):
append_row(sheets_id, SHEET_HEARTBEAT, [agent_id, timestamp, status, notes, "google-workspace", context_file])
@with_retry()
def approval_list(sheets_id: str):
svc = get_svc()
result = svc.spreadsheets().values().get(
spreadsheetId=sheets_id, range=f"{SHEET_APPROVAL}!A:H"
).execute()
rows = result.get("values", [])
headers = rows[0] if rows else []
pending = [r for r in rows[1:] if len(r) > 3 and r[3] == "pending"]
if not pending:
print("No pending approvals.")
return
print(f"Pending approvals ({len(pending)}):")
for row in pending:
print(f" 🔒 [{row[0][:12]}] {row[2] if len(row) > 2 else ''}")
print(f" Agent: {row[1] if len(row) > 1 else ''} Created: {(row[4] if len(row) > 4 else '')[:16]}")
@with_retry()
def approval_create(sheets_id: str, approval_id: str, task: str, agent_id: str, timestamp: str):
append_row(sheets_id, SHEET_APPROVAL, [approval_id, agent_id, task, "pending", timestamp, "", "", ""])
print(f"✅ Approval created: {approval_id}")
@with_retry()
def approval_act(sheets_id: str, approval_id: str, action: str, timestamp: str):
svc = get_svc()
result = svc.spreadsheets().values().get(
spreadsheetId=sheets_id, range=f"{SHEET_APPROVAL}!A:H"
).execute()
rows = result.get("values", [])
for i, row in enumerate(rows):
if row and row[0] == approval_id:
row_num = i + 1
svc.spreadsheets().values().update(
spreadsheetId=sheets_id,
range=f"{SHEET_APPROVAL}!D{row_num}:F{row_num}",
valueInputOption="RAW",
body={"values": [[action, timestamp, ""]]}
).execute()
print(f"✅ Approval {approval_id[:12]}{action}")
return
print(f"Approval not found: {approval_id}")
def main():
parser = argparse.ArgumentParser(description="GARC Sheets Helper")
sub = parser.add_subparsers(dest="command")
# Generic
rp = sub.add_parser("read", help="Read range")
rp.add_argument("--sheets-id", required=True)
rp.add_argument("--range", required=True, dest="range_")
rp.add_argument("--format", default="table", choices=["table", "json"])
wp = sub.add_parser("write", help="Write range")
wp.add_argument("--sheets-id", required=True)
wp.add_argument("--range", required=True, dest="range_")
wp.add_argument("--values", required=True, help="JSON array of arrays")
ap = sub.add_parser("append", help="Append row")
ap.add_argument("--sheets-id", required=True)
ap.add_argument("--sheet", required=True)
ap.add_argument("--values", required=True, help="JSON array")
sep = sub.add_parser("search", help="Search rows")
sep.add_argument("--sheets-id", required=True)
sep.add_argument("--sheet", required=True)
sep.add_argument("--query", required=True)
sep.add_argument("--column", type=int, default=-1)
sep.add_argument("--format", default="table", choices=["table", "json"])
infop = sub.add_parser("info", help="Get spreadsheet info")
infop.add_argument("--sheets-id", required=True)
clp = sub.add_parser("clear", help="Clear range")
clp.add_argument("--sheets-id", required=True)
clp.add_argument("--range", required=True, dest="range_")
# GARC-specific
mpl = sub.add_parser("memory-pull")
mpl.add_argument("--sheets-id", required=True)
mpl.add_argument("--agent-id", required=True)
mpl.add_argument("--output", required=True)
mpu = sub.add_parser("memory-push")
mpu.add_argument("--sheets-id", required=True)
mpu.add_argument("--agent-id", required=True)
mpu.add_argument("--entry", required=True)
mpu.add_argument("--timestamp", required=True)
mpu.add_argument("--tags", default="")
ms = sub.add_parser("memory-search")
ms.add_argument("--sheets-id", required=True)
ms.add_argument("--query", required=True)
al = sub.add_parser("agent-list")
al.add_argument("--sheets-id", required=True)
ar = sub.add_parser("agent-register")
ar.add_argument("--sheets-id", required=True)
ar.add_argument("--yaml-file", required=True)
ash = sub.add_parser("agent-show")
ash.add_argument("--sheets-id", required=True)
ash.add_argument("--agent-id", required=True)
hb = sub.add_parser("heartbeat")
hb.add_argument("--sheets-id", required=True)
hb.add_argument("--agent-id", required=True)
hb.add_argument("--status", required=True)
hb.add_argument("--notes", default="")
hb.add_argument("--timestamp", required=True)
hb.add_argument("--context-file", default="")
apl = sub.add_parser("approval-list")
apl.add_argument("--sheets-id", required=True)
apc = sub.add_parser("approval-create")
apc.add_argument("--sheets-id", required=True)
apc.add_argument("--approval-id", required=True)
apc.add_argument("--task", required=True)
apc.add_argument("--agent-id", required=True)
apc.add_argument("--timestamp", required=True)
apa = sub.add_parser("approval-act")
apa.add_argument("--sheets-id", required=True)
apa.add_argument("--approval-id", required=True)
apa.add_argument("--action", required=True)
apa.add_argument("--timestamp", required=True)
args = parser.parse_args()
if args.command == "read":
read_range(args.sheets_id, args.range_, args.format)
elif args.command == "write":
write_range(args.sheets_id, args.range_, json.loads(args.values))
elif args.command == "append":
append_row(args.sheets_id, args.sheet, json.loads(args.values))
elif args.command == "search":
search_sheet(args.sheets_id, args.sheet, args.query, args.column, args.format)
elif args.command == "info":
get_sheet_info(args.sheets_id)
elif args.command == "clear":
clear_range(args.sheets_id, args.range_)
elif args.command == "memory-pull":
memory_pull(args.sheets_id, args.agent_id, args.output)
elif args.command == "memory-push":
memory_push(args.sheets_id, args.agent_id, args.entry, args.timestamp, args.tags)
elif args.command == "memory-search":
memory_search(args.sheets_id, args.query)
elif args.command == "agent-list":
agent_list(args.sheets_id)
elif args.command == "agent-register":
agent_register(args.sheets_id, args.yaml_file)
elif args.command == "agent-show":
agent_show(args.sheets_id, args.agent_id)
elif args.command == "heartbeat":
heartbeat(args.sheets_id, args.agent_id, args.status, args.notes,
args.timestamp, args.context_file)
elif args.command == "approval-list":
approval_list(args.sheets_id)
elif args.command == "approval-create":
approval_create(args.sheets_id, args.approval_id, args.task, args.agent_id, args.timestamp)
elif args.command == "approval-act":
approval_act(args.sheets_id, args.approval_id, args.action, args.timestamp)
else:
parser.print_help()
if __name__ == "__main__":
main()

317
scripts/garc-tasks-helper.py Executable file
View file

@ -0,0 +1,317 @@
#!/usr/bin/env python3
"""
GARC Tasks Helper Google Tasks operations
Supports multiple task lists, create/read/update/complete/delete
"""
import argparse
import json
import sys
from pathlib import Path
from datetime import datetime, timezone
sys.path.insert(0, str(Path(__file__).parent))
from garc_core import build_service, utc_now, with_retry
def get_service():
scopes = ["https://www.googleapis.com/auth/tasks"]
return build_service("tasks", "v1", scopes=scopes)
# ─────────────────────────────────────────────
# Task Lists
# ─────────────────────────────────────────────
@with_retry()
def list_tasklists():
"""List all task lists."""
service = get_service()
result = service.tasklists().list(maxResults=100).execute()
lists = result.get("items", [])
if not lists:
print("No task lists found.")
return
print(f"Task Lists ({len(lists)}):")
for tl in lists:
print(f" [{tl['id']}] {tl['title']}")
def _resolve_tasklist(service, tasklist_ref: str) -> str:
"""Resolve a task list name or partial ID to a full ID."""
if tasklist_ref == "@default":
return "@default"
result = service.tasklists().list(maxResults=100).execute()
for tl in result.get("items", []):
if tl["id"] == tasklist_ref or tl["title"].lower() == tasklist_ref.lower():
return tl["id"]
if tl["id"].startswith(tasklist_ref):
return tl["id"]
return tasklist_ref # fallback: use as-is
# ─────────────────────────────────────────────
# Task CRUD
# ─────────────────────────────────────────────
@with_retry()
def list_tasks(tasklist: str = "@default", show_completed: bool = False, format_: str = "table"):
"""List tasks in a task list."""
service = get_service()
tasklist = _resolve_tasklist(service, tasklist)
result = service.tasks().list(
tasklist=tasklist,
showCompleted=show_completed,
showHidden=show_completed,
maxResults=100,
).execute()
tasks = result.get("items", [])
if not tasks:
print("No tasks found.")
return
if format_ == "json":
output = []
for t in tasks:
output.append({
"id": t["id"],
"title": t.get("title", ""),
"status": t.get("status", ""),
"due": t.get("due", "")[:10] if t.get("due") else "",
"notes": t.get("notes", ""),
"updated": t.get("updated", "")[:10] if t.get("updated") else "",
})
print(json.dumps(output, ensure_ascii=False, indent=2))
return
print(f"Tasks ({len(tasks)}):")
for t in tasks:
status_icon = "" if t.get("status") == "completed" else ""
due = t.get("due", "")[:10] if t.get("due") else ""
due_str = f" [due: {due}]" if due else ""
short_id = t["id"][:12]
print(f" {status_icon} [{short_id}] {t.get('title', '')}{due_str}")
if t.get("notes"):
for line in t["notes"].splitlines():
print(f" {line}")
@with_retry()
def show_task(task_id: str, tasklist: str = "@default"):
"""Show full details of a single task."""
service = get_service()
tasklist = _resolve_tasklist(service, tasklist)
# Find full ID via list if partial
full_id = _find_task_id(service, task_id, tasklist)
if not full_id:
print(f"Task not found: {task_id}", file=sys.stderr)
sys.exit(1)
task = service.tasks().get(tasklist=tasklist, task=full_id).execute()
print(f"ID: {task['id']}")
print(f"Title: {task.get('title', '')}")
print(f"Status: {task.get('status', '')}")
if task.get("due"):
print(f"Due: {task['due'][:10]}")
if task.get("notes"):
print(f"Notes: {task['notes']}")
print(f"Updated: {task.get('updated', '')[:19]}")
@with_retry()
def create_task(
title: str,
tasklist: str = "@default",
due: str = None,
notes: str = None,
parent: str = None,
):
"""Create a new Google Task."""
service = get_service()
tasklist = _resolve_tasklist(service, tasklist)
body: dict = {"title": title, "status": "needsAction"}
if due:
# Normalize due date to RFC3339
if "T" not in due:
due = f"{due}T00:00:00.000Z"
body["due"] = due
if notes:
body["notes"] = notes
kwargs: dict = {"tasklist": tasklist, "body": body}
if parent:
kwargs["parent"] = parent
result = service.tasks().insert(**kwargs).execute()
print(f"✅ Task created: [{result['id'][:12]}] {title}")
if due:
print(f" Due: {due[:10]}")
@with_retry()
def update_task(
task_id: str,
tasklist: str = "@default",
title: str = None,
due: str = None,
notes: str = None,
):
"""Update an existing task."""
service = get_service()
tasklist = _resolve_tasklist(service, tasklist)
full_id = _find_task_id(service, task_id, tasklist)
if not full_id:
print(f"Task not found: {task_id}", file=sys.stderr)
sys.exit(1)
# Fetch current
task = service.tasks().get(tasklist=tasklist, task=full_id).execute()
if title:
task["title"] = title
if due:
if "T" not in due:
due = f"{due}T00:00:00.000Z"
task["due"] = due
if notes is not None:
task["notes"] = notes
result = service.tasks().update(tasklist=tasklist, task=full_id, body=task).execute()
print(f"✅ Task updated: [{result['id'][:12]}] {result.get('title', '')}")
@with_retry()
def complete_task(task_id: str, tasklist: str = "@default"):
"""Mark a task as completed."""
service = get_service()
tasklist = _resolve_tasklist(service, tasklist)
full_id = _find_task_id(service, task_id, tasklist)
if not full_id:
print(f"Task not found: {task_id}", file=sys.stderr)
sys.exit(1)
service.tasks().patch(
tasklist=tasklist,
task=full_id,
body={"status": "completed"},
).execute()
print(f"✅ Task {task_id[:12]} marked as completed")
@with_retry()
def delete_task(task_id: str, tasklist: str = "@default"):
"""Delete a task."""
service = get_service()
tasklist = _resolve_tasklist(service, tasklist)
full_id = _find_task_id(service, task_id, tasklist)
if not full_id:
print(f"Task not found: {task_id}", file=sys.stderr)
sys.exit(1)
service.tasks().delete(tasklist=tasklist, task=full_id).execute()
print(f"🗑️ Task {task_id[:12]} deleted")
@with_retry()
def clear_completed(tasklist: str = "@default"):
"""Clear all completed tasks from a task list."""
service = get_service()
tasklist = _resolve_tasklist(service, tasklist)
service.tasks().clear(tasklist=tasklist).execute()
print("✅ Cleared all completed tasks")
# ─────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────
def _find_task_id(service, task_id_or_partial: str, tasklist: str) -> str | None:
"""Find full task ID from a partial ID or exact match."""
result = service.tasks().list(
tasklist=tasklist, showCompleted=True, showHidden=True, maxResults=200
).execute()
for t in result.get("items", []):
if t["id"] == task_id_or_partial:
return t["id"]
if t["id"].startswith(task_id_or_partial):
return t["id"]
return None
# ─────────────────────────────────────────────
# CLI
# ─────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="GARC Tasks Helper — Google Tasks operations")
parser.add_argument("--tasklist", "-l", default="@default", help="Task list ID or name (default: @default)")
parser.add_argument("--format", "-f", dest="format_", default="table", choices=["table", "json"])
subparsers = parser.add_subparsers(dest="command", required=True)
# list-tasklists
subparsers.add_parser("list-tasklists", help="Show all task lists")
# list
lp = subparsers.add_parser("list", help="List tasks")
lp.add_argument("--completed", action="store_true", help="Include completed tasks")
# show
sp = subparsers.add_parser("show", help="Show a single task")
sp.add_argument("--task-id", required=True)
# create
cp = subparsers.add_parser("create", help="Create a task")
cp.add_argument("--title", required=True)
cp.add_argument("--due", help="Due date (YYYY-MM-DD)")
cp.add_argument("--notes", help="Task notes")
cp.add_argument("--parent", help="Parent task ID (for subtasks)")
# update
up = subparsers.add_parser("update", help="Update a task")
up.add_argument("--task-id", required=True)
up.add_argument("--title")
up.add_argument("--due")
up.add_argument("--notes")
# complete
comp = subparsers.add_parser("complete", help="Mark task as completed")
comp.add_argument("--task-id", required=True)
# delete
dp = subparsers.add_parser("delete", help="Delete a task")
dp.add_argument("--task-id", required=True)
# clear-completed
subparsers.add_parser("clear-completed", help="Remove all completed tasks")
args = parser.parse_args()
try:
if args.command == "list-tasklists":
list_tasklists()
elif args.command == "list":
list_tasks(args.tasklist, args.completed, args.format_)
elif args.command == "show":
show_task(args.task_id, args.tasklist)
elif args.command == "create":
create_task(args.title, args.tasklist, args.due, args.notes, args.parent)
elif args.command == "update":
update_task(args.task_id, args.tasklist, args.title, args.due, args.notes)
elif args.command == "complete":
complete_task(args.task_id, args.tasklist)
elif args.command == "delete":
delete_task(args.task_id, args.tasklist)
elif args.command == "clear-completed":
clear_completed(args.tasklist)
except KeyboardInterrupt:
sys.exit(0)
if __name__ == "__main__":
main()

111
scripts/setup-workspace.sh Executable file
View file

@ -0,0 +1,111 @@
#!/usr/bin/env bash
# GARC setup-workspace.sh — One-shot workspace provisioning
# Sets up ~/.garc directory and Google Workspace resources
set -euo pipefail
GARC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONFIG_DIR="${HOME}/.garc"
echo "GARC Workspace Setup"
echo "===================="
# Step 1: Create local directories
echo ""
echo "Step 1: Creating local directories..."
mkdir -p "${CONFIG_DIR}/cache/workspace/main/memory"
mkdir -p "${CONFIG_DIR}/cache/queue"
echo "✅ Directories created: ${CONFIG_DIR}"
# Step 2: Copy config template
echo ""
echo "Step 2: Setting up config..."
if [[ -f "${CONFIG_DIR}/config.env" ]]; then
echo " Config already exists: ${CONFIG_DIR}/config.env"
else
cp "${GARC_DIR}/config/config.env.example" "${CONFIG_DIR}/config.env"
echo "✅ Config template created: ${CONFIG_DIR}/config.env"
fi
# Step 3: Install Python dependencies
echo ""
echo "Step 3: Python dependencies..."
if command -v pip3 &>/dev/null; then
pip3 install -q google-api-python-client google-auth-oauthlib google-auth-httplib2 pyyaml 2>/dev/null || true
echo "✅ Python packages installed"
else
echo "⚠️ pip3 not found. Install manually:"
echo " pip3 install google-api-python-client google-auth-oauthlib google-auth-httplib2 pyyaml"
fi
# Step 4: Add garc to PATH
echo ""
echo "Step 4: PATH configuration..."
GARC_BIN="${GARC_DIR}/bin"
chmod +x "${GARC_BIN}/garc"
if echo "${PATH}" | grep -q "${GARC_BIN}"; then
echo " ${GARC_BIN} already in PATH"
else
echo " Add this to your ~/.zshrc or ~/.bashrc:"
echo ""
echo " export PATH=\"${GARC_BIN}:\${PATH}\""
echo ""
echo " Or create a symlink:"
echo " ln -s ${GARC_BIN}/garc /usr/local/bin/garc"
fi
# Step 5: Google Cloud setup instructions
echo ""
echo "Step 5: Google Cloud Console setup"
echo "────────────────────────────────────"
echo ""
echo "1. Go to: https://console.cloud.google.com/"
echo "2. Create or select a project"
echo "3. Enable these APIs:"
echo " - Google Drive API"
echo " - Google Sheets API"
echo " - Gmail API"
echo " - Google Calendar API"
echo " - Google Tasks API"
echo " - Google Chat API (optional)"
echo ""
echo "4. Create OAuth 2.0 credentials:"
echo " APIs & Services → Credentials → Create Credentials → OAuth 2.0 Client IDs"
echo " Application type: Desktop app"
echo " Download JSON → save as ~/.garc/credentials.json"
echo ""
echo "5. Create a Google Drive folder for agent workspace"
echo " Note the folder ID from the URL"
echo " Example: https://drive.google.com/drive/folders/1xxxxxxxxx"
echo " ^^^^^^^^^^^^ this is the folder ID"
echo ""
echo "6. Create a Google Sheets for data storage:"
echo " Create a new spreadsheet with these tabs:"
echo " - memory"
echo " - agents"
echo " - queue"
echo " - heartbeat"
echo " - approval"
echo " Note the spreadsheet ID from the URL"
echo ""
echo "7. Edit ~/.garc/config.env with your IDs:"
echo " GARC_DRIVE_FOLDER_ID=<your folder ID>"
echo " GARC_SHEETS_ID=<your spreadsheet ID>"
echo " GARC_GMAIL_DEFAULT_TO=<your email>"
# Step 6: Complete
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Setup complete!"
echo ""
echo "Next steps:"
echo " 1. Complete Google Cloud Console setup above"
echo " 2. Edit ~/.garc/config.env"
echo " 3. Run: garc auth login --profile backoffice_agent"
echo " 4. Run: garc init"
echo " 5. Run: garc bootstrap --agent main"
echo " 6. Run: garc status"
echo ""
echo "Try it:"
echo ' garc auth suggest "send weekly report to manager"'