garc-gws-agent-runtime/scripts/garc-sheets-helper.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

430 lines
15 KiB
Python
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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