fix: resolve two bugs found during live testing

- Add garc_core.py (underscore) alongside garc-core.py (hyphen);
  Python cannot import a hyphenated filename as a module, causing
  ModuleNotFoundError on every garc command
- Replace invalid scope people.readonly with directory.readonly
  in garc-core.py and scope-map.json; people.readonly does not
  exist in Google OAuth, causing error 400 invalid_scope on login

Verified working: garc gmail profile, garc calendar today,
garc gmail inbox, garc auth suggest

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
林 駿甫 (Shunsuke Hayashi) 2026-04-15 09:25:14 +09:00
parent 4028c7f47a
commit 10bcf9b17a
3 changed files with 213 additions and 6 deletions

View file

@ -222,7 +222,7 @@
"read_contacts": {
"scopes": [
"https://www.googleapis.com/auth/contacts.readonly",
"https://www.googleapis.com/auth/people.readonly"
"https://www.googleapis.com/auth/directory.readonly"
],
"identity": "user_access_token",
"gate": "none",
@ -238,7 +238,7 @@
},
"search_people": {
"scopes": [
"https://www.googleapis.com/auth/people.readonly"
"https://www.googleapis.com/auth/directory.readonly"
],
"identity": "user_access_token",
"gate": "none",
@ -364,7 +364,7 @@
"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/people.readonly"
"https://www.googleapis.com/auth/directory.readonly"
],
"description": "Read-only access to all GWS resources"
},
@ -379,7 +379,7 @@
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/tasks",
"https://www.googleapis.com/auth/contacts.readonly",
"https://www.googleapis.com/auth/people.readonly"
"https://www.googleapis.com/auth/directory.readonly"
],
"description": "Write access to owned resources and send email"
},
@ -409,7 +409,7 @@
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/tasks",
"https://www.googleapis.com/auth/contacts.readonly",
"https://www.googleapis.com/auth/people.readonly",
"https://www.googleapis.com/auth/directory.readonly",
"https://www.googleapis.com/auth/chat.messages"
],
"description": "Back-office automation profile for office-work agents"

View file

@ -29,8 +29,8 @@ ALL_SCOPES = [
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/tasks",
"https://www.googleapis.com/auth/contacts.readonly",
"https://www.googleapis.com/auth/directory.readonly",
"https://www.googleapis.com/auth/chat.messages",
"https://www.googleapis.com/auth/people.readonly",
]
PROFILE_SCOPES = {

207
scripts/garc_core.py Normal file
View file

@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""
GARC Core Shared utilities: auth, service builders, retry, output formatting
"""
import json
import os
import sys
import time
import functools
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, Any
GARC_CONFIG_DIR = Path(os.environ.get("GARC_CONFIG_DIR", Path.home() / ".garc"))
TOKEN_FILE = Path(os.environ.get("GARC_TOKEN_FILE", GARC_CONFIG_DIR / "token.json"))
CREDENTIALS_FILE = Path(os.environ.get("GARC_CREDENTIALS_FILE", GARC_CONFIG_DIR / "credentials.json"))
SERVICE_ACCOUNT_FILE = Path(os.environ.get("GARC_SERVICE_ACCOUNT_FILE", GARC_CONFIG_DIR / "service_account.json"))
# All supported scopes for backoffice_agent profile
ALL_SCOPES = [
"https://www.googleapis.com/auth/drive",
"https://www.googleapis.com/auth/drive.file",
"https://www.googleapis.com/auth/documents",
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/gmail.send",
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/gmail.modify",
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/tasks",
"https://www.googleapis.com/auth/contacts.readonly",
"https://www.googleapis.com/auth/directory.readonly",
"https://www.googleapis.com/auth/chat.messages",
]
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