garc-gws-agent-runtime/scripts/garc-setup.py
林 駿甫 (Shunsuke Hayashi) a69b9d9160 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>
2026-04-15 08:59:12 +09:00

516 lines
16 KiB
Python

#!/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()