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:
commit
a69b9d9160
44 changed files with 9790 additions and 0 deletions
257
scripts/garc-auth-helper.py
Executable file
257
scripts/garc-auth-helper.py
Executable 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()
|
||||
332
scripts/garc-calendar-helper.py
Normal file
332
scripts/garc-calendar-helper.py
Normal 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
207
scripts/garc-core.py
Normal 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
522
scripts/garc-drive-helper.py
Executable 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
316
scripts/garc-gmail-helper.py
Executable 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()
|
||||
454
scripts/garc-ingress-helper.py
Normal file
454
scripts/garc-ingress-helper.py
Normal 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 Code–ready 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()
|
||||
392
scripts/garc-people-helper.py
Normal file
392
scripts/garc-people-helper.py
Normal 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
516
scripts/garc-setup.py
Normal 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
430
scripts/garc-sheets-helper.py
Executable 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
317
scripts/garc-tasks-helper.py
Executable 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
111
scripts/setup-workspace.sh
Executable 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"'
|
||||
Loading…
Add table
Add a link
Reference in a new issue