commit a69b9d9160e479f1ef61b827e572a6ccb194a59f Author: 林 駿甫 (Shunsuke Hayashi) Date: Wed Apr 15 08:59:12 2026 +0900 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 diff --git a/.claude/skills/garc-runtime/SKILL.md b/.claude/skills/garc-runtime/SKILL.md new file mode 100644 index 0000000..cc73621 --- /dev/null +++ b/.claude/skills/garc-runtime/SKILL.md @@ -0,0 +1,282 @@ +--- +name: garc-runtime +version: 0.1.0 +description: GARC — Google Workspace Agent Runtime. Use when performing office-work tasks on Gmail, Google Drive, Sheets, Calendar, or Tasks through the GARC CLI. +--- + +# GARC Runtime Skill + +## When to use this skill + +Use GARC when the user asks you to: +- Send emails, search Gmail, read emails +- Create or update Google Calendar events, check availability +- Read/write Google Drive files, upload/download, create docs +- Read/write Google Sheets data +- Manage Google Tasks +- Check OAuth scope requirements for a GWS task +- Manage the agent memory/context +- Register or manage agents + +## Pre-flight checklist + +Before any GARC operation: + +1. **Check config** + ```bash + garc status + ``` + All items must be ✅. If not, guide through `garc setup all`. + +2. **Infer scopes** (for new task types) + ```bash + garc auth suggest "" + ``` + Note the gate level. Act accordingly. + +3. **Check execution gate** + - `none` → proceed immediately + - `preview` → show user what will happen, confirm before executing + - `approval` → create approval request, wait for human approval + +--- + +## Gmail Operations + +### Send email +```bash +garc gmail send \ + --to recipient@example.com \ + --subject "Subject here" \ + --body "Body text here" \ + [--cc cc@example.com] \ + [--html] # for HTML body +``` + +Gate: **preview** — Always confirm recipient and content with user before sending. + +### Search emails +```bash +garc gmail search "from:alice@co.com subject:invoice" --max 10 +garc gmail search "after:2026/04/01 has:attachment" --body +``` + +Gate: **none** + +### Read email +```bash +garc gmail read +``` + +### Inbox +```bash +garc gmail inbox --unread --max 20 +``` + +### Draft +```bash +garc gmail draft --to someone@co.com --subject "Draft title" --body "..." +``` + +--- + +## Google Calendar Operations + +### List events +```bash +garc calendar today # Today's events +garc calendar week # This week +garc calendar list --days 14 # Next 14 days +garc calendar list --query "Standup" # Filter by keyword +``` + +Gate: **none** + +### Create event +```bash +garc calendar create \ + --summary "Team Meeting" \ + --start "2026-04-16T14:00:00" \ + --end "2026-04-16T15:00:00" \ + --description "Weekly sync" \ + --location "Google Meet" \ + --attendees alice@co.com bob@co.com \ + --timezone "Asia/Tokyo" +``` + +Gate: **preview** — Show user the event details before creating. + +### Check free/busy +```bash +garc calendar freebusy \ + --start 2026-04-16 \ + --end 2026-04-17 \ + --emails alice@co.com bob@co.com +``` + +Gate: **none** + +### Quick add (natural language) +```bash +garc calendar quick-add "Lunch with Alice tomorrow at 12:30pm" +``` + +Gate: **preview** + +--- + +## Google Drive Operations + +### List/search +```bash +garc drive list --folder-id 1xxxxx +garc drive search "Q1 report" --type doc +garc drive info +``` + +Gate: **none** + +### Download +```bash +garc drive download --file-id 1xxxxx --output ./local_file.txt +garc drive download --folder-id 1xxxxx --filename "SOUL.md" --output ~/.garc/SOUL.md +``` + +Gate: **none** + +### Upload +```bash +garc drive upload ./report.pdf --folder-id 1xxxxx +garc drive upload ./data.csv --folder-id 1xxxxx --convert # Convert to Google Sheet +``` + +Gate: **preview** + +### Create document / folder +```bash +garc drive create-doc "Meeting Notes 2026-04-15" --folder-id 1xxxxx +garc drive create-folder "Project Alpha" --parent-id 1xxxxx +``` + +Gate: **preview** + +### Share +```bash +garc drive share 1xxxxx --email colleague@co.com --role writer +``` + +Gate: **approval** (sharing = external visibility) + +--- + +## Google Sheets Operations + +### Read data +```bash +garc sheets info # Show all tabs and dimensions +garc sheets read --range "memory!A:E" +garc sheets read --range "agents!A:H" --format json +garc sheets search --sheet memory --query "expense" +``` + +Gate: **none** + +### Write data +```bash +garc sheets append --sheet memory --values '["main","2026-04-15T10:00:00Z","key decision: use GARC","manual",""]' +garc sheets write --range "agents!A2:F2" --values '[["crm-agent","claude-sonnet-4-6","...","CRM agent","writer","active"]]' +``` + +Gate: **preview** + +--- + +## Memory Operations + +```bash +garc memory pull # Sync Sheets → local +garc memory push "important context: ..." +garc memory search "client A" +``` + +--- + +## Task Operations + +```bash +garc task list +garc task list --completed --format json +garc task create "Write Q1 report" --due 2026-04-30 --notes "Include section on revenue" +garc task update --due 2026-05-01 +garc task done +garc task delete +garc task clear-completed +garc task tasklists # List all task lists +``` + +Gate: **preview** for create/update/delete — confirm with user before modifying tasks. + +--- + +## People & Contacts + +```bash +garc people search "Alice" # Search personal contacts +garc people directory "engineering" # Search org directory (GWS) +garc people lookup "Bob Smith" # Quick name → email lookup +garc people list --max 50 # List all contacts +garc people show +garc people create --name "Jane Doe" --email jane@co.com --company "Acme" +garc people update --title "Senior Manager" +``` + +Gate: **none** for search/read, **preview** for create/update, **approval** for delete. + +--- + +## Agent Management + +```bash +garc agent list # Show registered agents +garc agent register # Register from agents.yaml +garc agent show main # Show specific agent details +``` + +--- + +## Scope & Gate Reference + +| Operation | Gate | Scopes needed | +|-----------|------|--------------| +| Read email | none | gmail.readonly | +| Send email | preview | gmail.send | +| Delete email | approval | gmail.modify | +| Read calendar | none | calendar.readonly | +| Create event | preview | calendar | +| Delete event | approval | calendar | +| Read Drive | none | drive.readonly | +| Upload to Drive | preview | drive.file | +| Delete/share Drive | approval | drive | +| Read Sheets | none | spreadsheets.readonly | +| Write Sheets | preview | spreadsheets | +| Read Tasks | none | tasks.readonly | +| Create/update Tasks | preview | tasks | +| Delete Tasks | approval | tasks | +| Read Contacts | none | contacts.readonly | +| Create/update Contacts | preview | contacts | +| Delete Contacts | approval | contacts | +| Search Directory | none | directory.readonly | +| Create expense | approval | spreadsheets + drive.file + gmail.send | + +--- + +## Error patterns + +| Error | Action | +|-------|--------| +| `credentials.json not found` | Guide user to Google Cloud Console | +| `Token refresh failed` | Run `garc auth login --profile backoffice_agent` | +| `403 insufficientPermissions` | Run `garc auth suggest ""` to identify missing scopes, then re-login | +| `API not enabled` | Tell user to enable the specific API in Google Cloud Console | +| `Sheets tab missing` | Run `garc setup sheets` | +| Rate limit (429) | Wait and retry (garc has built-in retry with backoff) | diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4dd3732 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Credentials — never commit +.garc/ +~/.garc/ +credentials.json +token.json +service_account.json +*.key +*.pem + +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +*.egg-info/ +dist/ +build/ +.venv/ +venv/ +env/ + +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Editor +.idea/ +.vscode/ +*.swp +*.swo + +# Local cache and runtime state +.cache/ +*.log +*.pid + +# Test artifacts +.pytest_cache/ +.coverage +htmlcov/ + +# Env files (use config.env.example as template) +config.env +.env diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c5daf79 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,30 @@ +# GARC Changelog + +## [0.1.0] — 2026-04-15 + +### Added + +- `bin/garc` — Main CLI entrypoint with full command dispatch +- `lib/bootstrap.sh` — Disclosure chain loading from Google Drive (SOUL/USER/MEMORY/RULES/HEARTBEAT) +- `lib/auth.sh` — OAuth scope inference and authorization flow +- `lib/memory.sh` — Google Sheets memory sync (pull/push/search) +- `lib/send.sh` — Gmail and Google Chat message sending +- `lib/agent.sh` — Agent registry via Google Sheets +- `lib/task.sh` — Google Tasks operations (list/create/done) +- `lib/approve.sh` — Execution gate and approval flow +- `lib/heartbeat.sh` — System state logging to Google Sheets +- `lib/kg.sh` — Knowledge graph via Google Drive Docs +- `lib/ingress.sh` — Queue/ingress system with JSONL local cache +- `scripts/garc-auth-helper.py` — OAuth2 scope inference + token management +- `scripts/garc-sheets-helper.py` — Google Sheets CRUD operations +- `scripts/garc-drive-helper.py` — Google Drive file operations + KG builder +- `scripts/garc-gmail-helper.py` — Gmail send operations +- `scripts/garc-tasks-helper.py` — Google Tasks operations +- `scripts/setup-workspace.sh` — One-shot workspace provisioning +- `config/scope-map.json` — 25 task types × Google OAuth scopes × 4 profiles +- `config/gate-policy.json` — Execution gate policies (none/preview/approval) +- `config/config.env.example` — Configuration template +- `agents.yaml` — Default agent declarations (main/crm-agent/doc-agent/expense-processor) +- `docs/garc-architecture.md` — Full architecture reference +- `docs/garc-vs-larc.md` — GARC vs LARC comparison +- `docs/gws-api-alignment.md` — GWS API command mappings diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..da97998 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,189 @@ +# GARC — Google Workspace Agent Runtime CLI + +> Claude Code context file for this project. + +## Project Summary + +**GARC** bridges OpenClaw-style coding agents with Google Workspace — enabling AI agents to operate on back-office and white-collar tasks using Google Drive, Sheets, Gmail, Calendar, and Tasks as the native data surface. + +GARC is the Google Workspace counterpart to LARC (Lark Agent Runtime CLI). It reproduces the same architecture: + +- **Core pattern**: Disclosure chain (`SOUL.md → USER.md → MEMORY.md → HEARTBEAT.md`) backed by Google Drive +- **Permission-first**: `garc auth suggest ""` → keyword matching against `config/scope-map.json` → required OAuth scopes + identity type +- **Target market**: Organizations using Google Workspace (Gmail, Drive, Sheets, Calendar, Chat) + +## Repository Structure + +``` +bin/garc # Main CLI entrypoint (bash) +lib/ + bootstrap.sh # Disclosure chain loading from Google Drive + memory.sh # Daily memory sync ↔ Google Sheets + send.sh # Gmail / Google Chat message sending + agent.sh # Agent registration & management (Sheets) + task.sh # Google Tasks operations + approve.sh # Approval gate logic + heartbeat.sh # System state logging to Sheets + auth.sh # OAuth scope inference & authorization + drive.sh # Google Drive file operations +config/ + scope-map.json # GWS OAuth scopes × task types × profiles + gate-policy.json # Execution gate policies (none/preview/approval) +scripts/ + setup-workspace.sh # One-shot workspace provisioning + garc-auth-helper.py # OAuth2 token management helper +docs/ + garc-architecture.md # Full architecture reference + garc-vs-larc.md # GARC vs LARC comparison + gws-api-alignment.md # GWS API command mappings +.claude/skills/ # Claude Code skills for GWS operations +``` + +## GWS → LARC Mapping + +| LARC (Lark) | GARC (Google Workspace) | +|-------------|------------------------| +| Lark Drive folder | Google Drive folder | +| Lark Base | Google Sheets | +| Lark IM chat | Gmail / Google Chat | +| Lark Wiki | Google Docs (knowledge) | +| Lark Approval | Google Forms + Sheets approval flow | +| Lark Calendar | Google Calendar | +| Lark Task/Project | Google Tasks | +| `lark-cli` | `gcloud` + Google APIs | +| MCP (Lark) | MCP (Gmail/Drive/Calendar) | + +## Key Architecture Decisions + +### Google API Access Layer + +GARC uses two access methods: + +1. **Claude Code MCP tools** (preferred for interactive use): + - `mcp__claude_ai_Gmail__*` — email operations + - `mcp__claude_ai_Google_Drive__*` — Drive file ops + - `mcp__claude_ai_Google_Calendar__*` — calendar ops + +2. **Python googleapis client** (for CLI/automation): + - `google-api-python-client` + `google-auth-oauthlib` + - Service account for bot operations + - OAuth2 user tokens for user-identity operations + +### Disclosure Chain Storage + +``` +Google Drive folder (GARC_DRIVE_FOLDER_ID) + └── SOUL.md → agent identity & principles + └── USER.md → user profile + └── MEMORY.md → long-term memory + └── RULES.md → operating rules + └── HEARTBEAT.md → system state + └── memory/ + └── YYYY-MM-DD.md → daily context + +Downloaded to: ~/.garc/cache/workspace// +Consolidated: ~/.garc/cache/workspace//AGENT_CONTEXT.md +``` + +### Memory Backend (Google Sheets) + +Google Sheets replaces Lark Base for structured data: + +| Sheet Tab | Purpose | LARC Equivalent | +|-----------|---------|-----------------| +| `memory` | Long-term memory entries | Base memory table | +| `agents` | Agent registry (id, model, scopes) | Base agent ledger | +| `queue` | Task queue lifecycle | Base queue ledger | +| `heartbeat` | System state log | Base heartbeat table | +| `approval` | Approval instances | Lark Approval | + +### Scope Map (`config/scope-map.json`) + +Structure mirrors LARC's scope-map.json but uses Google OAuth scopes: + +```json +{ + "tasks": { + "read_document": { + "scopes": ["https://www.googleapis.com/auth/drive.readonly"], + "identity": "user_access_token", + "description": "Read Google Drive files" + } + }, + "profiles": { + "readonly": { "scopes": [...], "description": "..." }, + "writer": { "scopes": [...], "description": "..." }, + "admin": { "scopes": [...], "description": "..." }, + "backoffice_agent": { "scopes": [...], "description": "..." } + } +} +``` + +### Execution Gates + +Same 3-tier gate policy as LARC: + +``` +none → read-only ops (immediate execution) +preview → medium risk (external visibility, writes) → --confirm flag required +approval → high risk (money, permissions, irreversible) → approval gate +``` + +## Config (`~/.garc/config.env`) + +```bash +GARC_DRIVE_FOLDER_ID=1xxxxx # Google Drive folder for agent workspace +GARC_SHEETS_ID=1xxxxx # Google Sheets for memory/registry/queue +GARC_GMAIL_DEFAULT_TO=xxx@gmail.com # Default email recipient (agent notifications) +GARC_CHAT_SPACE_ID=spaces/xxx # Google Chat space ID +GARC_CALENDAR_ID=primary # Google Calendar ID +GARC_CACHE_TTL=300 # Cache TTL in seconds +GARC_CREDENTIALS_FILE=~/.garc/credentials.json # OAuth2 client credentials +GARC_TOKEN_FILE=~/.garc/token.json # OAuth2 user tokens +GARC_SERVICE_ACCOUNT_FILE=~/.garc/service_account.json # Service account (bot) +``` + +## Common Commands + +```bash +# Setup +garc init +garc bootstrap --agent main + +# Daily use +garc memory pull +garc send "Draft an expense report for last month" +garc task list + +# Permission management +garc auth suggest "create expense report and send for approval" +garc auth check --profile writer +garc auth login --profile backoffice_agent + +# Agent management +garc agent list +garc agent register + +# Knowledge graph +garc kg build +garc kg query "expense approval process" +``` + +## Development Phases + +- [ ] Phase 1A: Core CLI dispatch (`garc init/bootstrap/memory/send/task/approve/agent/status`) +- [ ] Phase 1B: Drive workspace setup + Sheets provisioning +- [ ] Phase 1C: OAuth scope map + `garc auth suggest/check/login` +- [ ] Phase 2A: Claude Code skills for GWS operations +- [ ] Phase 2B: Multi-agent YAML batch registration +- [ ] Phase 3: Queue/ingress system (Gmail-triggered) +- [ ] Phase 4: Knowledge graph via Google Docs links + +## Relation to LARC + +GARC is intentionally parallel to LARC, not a replacement. Organizations using: +- **Feishu/Lark** → use LARC +- **Google Workspace** → use GARC +- **Both** → deploy both runtimes; agents registered in one can be mirrored to the other + +The runtime governance model (permission intelligence, execution gates, disclosure chain) is identical. Only the backend APIs differ. diff --git a/README.ja.md b/README.ja.md new file mode 100644 index 0000000..e788b40 --- /dev/null +++ b/README.ja.md @@ -0,0 +1,102 @@ +# GARC — Google Workspace エージェントランタイム CLI + +Google Workspace ネイティブなオフィスワークエージェントのためのパーミッションファーストランタイム。 + +GARC は [LARC](../larc-openclaw-coding-agent/) の Google Workspace 版です。同じガバナンスアーキテクチャ(開示チェーン・実行ゲート・エージェントレジストリ・スコープ推定)を、Google Drive・Sheets・Gmail・Calendar・Tasks を使う組織向けに実装します。 + +## GARCとは + +``` +上位エージェント(Claude Code / OpenClaw) + -> GARC CLI/ランタイム + -> Google Workspace APIs(Drive, Sheets, Gmail, Calendar, Tasks, Chat) +``` + +GARCが追加するもの: + +1. **パーミッションインテリジェンス** — `garc auth suggest "<タスク>"` で自然言語タスクに必要な最小OAuthスコープを推定 +2. **実行ゲート** — エージェントアクション実行前に `none / preview / approval` の3段階ゲート +3. **エージェントレジストリ** — エージェント定義をGoogle Sheetsに保存(id, model, scopes, folder) +4. **開示チェーン** — `SOUL.md / USER.md / MEMORY.md / HEARTBEAT.md` をGoogle Driveにバックアップ +5. **メモリ同期** — Google Sheetsとの日次メモリ双方向同期 +6. **キュー/イングレス** — Gmail起動のタスクライフサイクル管理 +7. **ナレッジグラフ** — Google Docsをリンクされた概念ノードとして活用 + +## GWS ↔ LARC バックエンド対応表 + +| 機能 | LARC(Lark) | GARC(Google Workspace) | +|------|-------------|------------------------| +| ファイルストレージ | Lark Drive | Google Drive | +| 構造化データ | Lark Base | Google Sheets | +| メッセージング | Lark IM | Gmail / Google Chat | +| ナレッジ | Lark Wiki | Google Docs | +| 承認フロー | Lark Approval | Sheets連携承認フロー | +| カレンダー | Lark Calendar | Google Calendar | +| タスク | Lark Project | Google Tasks | +| 認証CLI | `lark-cli` | `gcloud` + googleapis | +| MCPツール | `openclaw-lark` | Gmail/Drive/Calendar MCP | + +## クイックスタート + +```bash +# インストール +git clone +cd garc-gws-agent-runtime +./scripts/setup-workspace.sh + +# 設定 +cp config/config.env.example ~/.garc/config.env +# ~/.garc/config.env をGWS認証情報で編集 + +# 初期化 +garc init +garc bootstrap --agent main + +# 日常使用 +garc memory pull +garc auth suggest "経費サマリーをマネージャーに送信" +garc task list +``` + +## 設定(`~/.garc/config.env`) + +```bash +GARC_DRIVE_FOLDER_ID=1xxxxx # エージェントワークスペース用Driveフォルダ +GARC_SHEETS_ID=1xxxxx # メモリ/レジストリ/キュー用Sheets +GARC_GMAIL_DEFAULT_TO=you@gmail.com # デフォルト通知先メール +GARC_CALENDAR_ID=primary # Google Calendar ID +GARC_CREDENTIALS_FILE=~/.garc/credentials.json +GARC_TOKEN_FILE=~/.garc/token.json +``` + +## コマンド一覧 + +```bash +garc init # ワークスペース初期化 +garc bootstrap [--agent ] # Driveから開示チェーン読み込み +garc memory pull/push/search # Sheetsとメモリ同期 +garc send "" [--to ] # Gmail/Google Chat経由で送信 +garc task list/create/done # Google Tasks操作 +garc approve gate # 実行ゲートポリシー確認 +garc approve list/create # 承認管理 +garc agent list/register # エージェントレジストリ操作 +garc auth suggest "<タスク>" # OAuthスコープ推定 +garc auth check [--profile

] # 現在のトークンスコープ確認 +garc auth login [--profile

] # OAuthフロー起動 +garc heartbeat # システム状態をSheetsに記録 +garc status # 設定とヘルスチェック表示 +garc kg build/query/show # ナレッジグラフ操作 +garc ingress enqueue/list/next # キュー管理 +``` + +## LARCとの関係 + +GARCとLARCは同じランタイムガバナンスモデルを共有します。バックエンドに応じて選択してください: + +- Feishu / Lark → **LARC** +- Google Workspace → **GARC** +- 両方のプラットフォーム → 両方デプロイ(エージェントYAML形式は互換性あり) + +## ライセンス + +MIT diff --git a/README.md b/README.md new file mode 100644 index 0000000..6d29361 --- /dev/null +++ b/README.md @@ -0,0 +1,234 @@ +# GARC — Google Workspace Agent Runtime CLI + +A permission-first runtime for AI agents operating on Google Workspace. + +GARC lets Claude Code (or any LLM agent) send emails, manage calendars, read Drive files, write Sheets, and manage tasks — with built-in **execution gates** that prevent accidental or unauthorized actions. + +``` +You / Claude Code + ↓ + GARC CLI ← permission check, queue, context + ↓ +Google Workspace APIs (Gmail · Calendar · Drive · Sheets · Tasks · People) +``` + +## Core concepts + +| Concept | What it does | +|---------|-------------| +| **`auth suggest`** | Infers minimum OAuth scopes from a natural-language task description | +| **Execution gates** | `none` (read-only) / `preview` (writes, confirm first) / `approval` (financial/irreversible, requires human sign-off) | +| **Disclosure chain** | `SOUL.md → USER.md → MEMORY.md → RULES.md → HEARTBEAT.md` stored in Google Drive, loaded on bootstrap | +| **Queue / ingress** | Task lifecycle (`pending → in_progress → done/failed`), Gmail polling daemon auto-enqueues new emails | +| **Agent registry** | Agent declarations (id, model, scopes) stored in Google Sheets | +| **Memory sync** | Long-term memory round-trip with a dedicated Google Sheets tab | + +## Quickstart + +### 1. Install + +```bash +git clone https://github.com//garc-gws-agent-runtime ~/study/garc-gws-agent-runtime +cd ~/study/garc-gws-agent-runtime +pip3 install -r requirements.txt +echo 'export PATH="$HOME/study/garc-gws-agent-runtime/bin:$PATH"' >> ~/.zshrc +source ~/.zshrc +garc --version # → garc 0.1.0 +``` + +### 2. Enable Google Cloud APIs + +In [Google Cloud Console](https://console.cloud.google.com/), enable: + +| API | Service name | +|-----|-------------| +| Google Drive API | `drive.googleapis.com` | +| Google Sheets API | `sheets.googleapis.com` | +| Gmail API | `gmail.googleapis.com` | +| Google Calendar API | `calendar-json.googleapis.com` | +| Google Tasks API | `tasks.googleapis.com` | +| Google Docs API | `docs.googleapis.com` | +| Google People API | `people.googleapis.com` | + +Create an **OAuth 2.0 Client ID** (Desktop app) → download JSON → save as `~/.garc/credentials.json`. + +See [`docs/google-cloud-setup.md`](docs/google-cloud-setup.md) for step-by-step instructions. + +### 3. Authenticate + +```bash +garc auth login --profile backoffice_agent +# Opens browser → Google login → authorize all scopes +garc auth status +``` + +### 4. Provision workspace + +```bash +garc setup all +# Creates GARC Workspace folder in Google Drive +# Creates Google Sheets with all tabs (memory/agents/queue/heartbeat/approval/…) +# Uploads disclosure chain templates to Drive +# Writes IDs to ~/.garc/config.env +``` + +### 5. Verify + +```bash +garc status +garc bootstrap --agent main +``` + +## Usage + +### Gmail + +```bash +garc gmail inbox --unread +garc gmail search "from:alice@co.com subject:invoice" --max 10 +garc gmail send --to boss@co.com --subject "Weekly report" --body "..." +garc gmail read +``` + +### Google Calendar + +```bash +garc calendar today +garc calendar week +garc calendar create --summary "Team meeting" \ + --start "2026-04-20T14:00:00" --end "2026-04-20T15:00:00" \ + --attendees alice@co.com bob@co.com --timezone "Asia/Tokyo" +garc calendar freebusy --start 2026-04-20 --end 2026-04-21 --emails alice@co.com +``` + +### Google Drive + +```bash +garc drive list +garc drive search "Q1 report" --type doc +garc drive upload ./report.pdf +garc drive create-doc "Meeting Notes 2026-04-20" +garc drive download --file-id 1xxxxx --output ./local.txt +``` + +### Google Sheets + +```bash +garc sheets info +garc sheets read --range "memory!A:E" --format json +garc sheets append --sheet memory --values '["main","2026-04-20","key decision","manual",""]' +``` + +### Tasks & Contacts + +```bash +garc task list +garc task create "Write Q1 report" --due 2026-04-30 +garc people lookup "Alice Smith" +garc people directory "engineering" +``` + +### Queue / Ingress (Claude Code bridge) + +```bash +# Enqueue a task +garc ingress enqueue --text "Send weekly report to manager@co.com" + +# Show queue +garc ingress list + +# Output a Claude-readable execution prompt → Claude Code acts on this +garc ingress run-once + +# Mark complete +garc ingress done --queue-id abc12345 --note "Report sent" +``` + +### Daemon — auto-enqueue from Gmail + +```bash +garc daemon start --interval 60 # poll every 60s +garc daemon status +garc daemon stop +garc daemon install # install as macOS launchd service +``` + +### Scope & gate inference + +```bash +garc auth suggest "create expense report and send to manager for approval" +# → gate: approval scopes: spreadsheets + drive.file + gmail.send + +garc approve gate create_expense +garc approve list +garc approve act --action approve +``` + +## Architecture + +``` +~/.garc/ + credentials.json # OAuth client credentials (Google Cloud Console) + token.json # OAuth user token (garc auth login) + config.env # GARC_DRIVE_FOLDER_ID, GARC_SHEETS_ID, … + cache/ + workspace// + SOUL.md / USER.md / MEMORY.md / RULES.md / HEARTBEAT.md + AGENT_CONTEXT.md # consolidated bootstrap context + queue/ # JSONL queue files + daemon/ # PID files + logs/ # daemon logs +``` + +### Execution gates + +| Gate | Risk | Behaviour | +|------|------|-----------| +| `none` | Low — reads | Execute immediately | +| `preview` | Medium — external writes | Show plan, confirm first | +| `approval` | High — financial / irreversible | Create approval request, block until approved | + +## Repository layout + +``` +bin/garc CLI entrypoint +lib/ + bootstrap.sh disclosure chain + gmail.sh / calendar.sh / drive.sh / sheets.sh / task.sh / people.sh + memory.sh / ingress.sh / daemon.sh / agent.sh / approve.sh + auth.sh / heartbeat.sh / kg.sh +scripts/ + garc-core.py shared auth, retry, utilities + garc-ingress-helper.py task inference + Claude prompt builder + garc-gmail-helper.py / garc-calendar-helper.py / garc-drive-helper.py + garc-sheets-helper.py / garc-tasks-helper.py / garc-people-helper.py + garc-auth-helper.py OAuth scope inference engine + garc-setup.py workspace provisioner +config/ + scope-map.json 42 task types × OAuth scopes × keyword patterns + gate-policy.json gate assignments + config.env.example +agents.yaml agent declarations +docs/ + quickstart.md / google-cloud-setup.md / garc-architecture.md / garc-vs-larc.md +.claude/skills/garc-runtime/SKILL.md Claude Code skill +``` + +## Relation to LARC + +GARC mirrors [LARC](https://github.com/miyabi-lab/larc-openclaw-coding-agent) — the same governance model running on Google Workspace instead of Lark/Feishu. + +| LARC (Lark) | GARC (Google Workspace) | +|-------------|------------------------| +| Lark Drive | Google Drive | +| Lark Base | Google Sheets | +| Lark IM / Mail | Gmail | +| Lark Approval | Sheets-based approval flow | +| Lark Calendar | Google Calendar | +| Lark Task | Google Tasks | +| `lark-cli` | Google APIs (Python) | +| OpenClaw agent | Claude Code | + +## License + +MIT diff --git a/agents.yaml b/agents.yaml new file mode 100644 index 0000000..47b3e6b --- /dev/null +++ b/agents.yaml @@ -0,0 +1,54 @@ +# GARC Agent Registry +# Run: garc agent register --file agents.yaml + +agents: + - id: main + model: claude-sonnet-4-6 + description: General-purpose office-work agent + profile: backoffice_agent + scopes: + - 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 + drive_folder: main + workspace: ~/garc-workspace/main + + - id: crm-agent + model: claude-sonnet-4-6 + description: CRM and customer follow-up specialist + profile: writer + scopes: + - 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/contacts.readonly + drive_folder: crm-agent + workspace: ~/garc-workspace/crm-agent + + - id: doc-agent + model: claude-sonnet-4-6 + description: Document creation and editing specialist + profile: writer + scopes: + - https://www.googleapis.com/auth/drive.file + - https://www.googleapis.com/auth/documents + - https://www.googleapis.com/auth/spreadsheets.readonly + drive_folder: doc-agent + workspace: ~/garc-workspace/doc-agent + + - id: expense-processor + model: claude-sonnet-4-6 + description: Expense report processing specialist + profile: backoffice_agent + scopes: + - https://www.googleapis.com/auth/spreadsheets + - https://www.googleapis.com/auth/drive.file + - https://www.googleapis.com/auth/gmail.send + - https://www.googleapis.com/auth/gmail.readonly + drive_folder: expense-processor + workspace: ~/garc-workspace/expense-processor diff --git a/bin/garc b/bin/garc new file mode 100755 index 0000000..3393386 --- /dev/null +++ b/bin/garc @@ -0,0 +1,265 @@ +#!/usr/bin/env bash +# GARC — Google Workspace Agent Runtime CLI +# Main entrypoint + +set -euo pipefail + +GARC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +GARC_LIB="${GARC_DIR}/lib" +GARC_CONFIG="${HOME}/.garc" +GARC_CONFIG_ENV="${GARC_CONFIG}/config.env" + +# Load config if present +if [[ -f "${GARC_CONFIG_ENV}" ]]; then + # shellcheck source=/dev/null + source "${GARC_CONFIG_ENV}" +fi + +# Defaults +GARC_CACHE_DIR="${GARC_CACHE_DIR:-${GARC_CONFIG}/cache}" +GARC_CACHE_TTL="${GARC_CACHE_TTL:-300}" +GARC_DEFAULT_AGENT="${GARC_DEFAULT_AGENT:-main}" + +VERSION="0.1.0" + +usage() { + cat < [subcommand] [options] + +━━━ Core ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + init Initialize GARC workspace (config + dirs) + setup [all|check|sheets|drive] Provision GWS resources automatically + bootstrap [--agent ] Load disclosure chain from Google Drive + status Show config and connection health + +━━━ Gmail ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + gmail send --to --subject --body [--cc] [--html] + gmail reply --thread-id --to --body + gmail search [--max N] [--body] + gmail read + gmail inbox [--max N] [--unread] + gmail draft --to --subject --body + gmail labels + gmail profile + send "" --to Shorthand for gmail send + +━━━ Google Calendar ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + calendar today Events for today + calendar week Events for this week + calendar list [--days N] [--query ] + calendar create --summary --start

--end
[--attendees ...] + calendar update [--summary ...] [--start ...] [--end ...] + calendar delete + calendar get + calendar freebusy --start --end --emails email1 [...] + calendar quick-add "" + calendar calendars List all accessible calendars + +━━━ Google Drive ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + drive list [--folder-id ] [--query ] + drive search [--type doc|sheet|slide|folder|pdf] + drive info + drive download --file-id | --folder-id + --filename [--output ] + drive upload [--folder-id ] [--convert] + drive create-folder [--parent-id ] + drive create-doc [--folder-id ] [--content ] + drive share --email [--role reader|writer] + drive move --to + drive delete [--permanent] + +━━━ Google Sheets ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + sheets info [--sheets-id ] + sheets read --range [--format table|json] + sheets write --range --values '[[...]]' + sheets append --sheet --values '[...]' + sheets search --sheet --query [--format json] + sheets clear --range + +━━━ Memory ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + memory pull Sync Sheets memory → local cache + memory push "" Save entry to Sheets memory + memory search Search memory entries + +━━━ Tasks ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + task list [--list ] [--completed] [--format json] + task show + task create "" [--due YYYY-MM-DD] [--notes <text>] [--list <id>] + task update <task_id> [--title] [--due] [--notes] + task done <task_id> Mark task complete + task delete <task_id> + task clear-completed Remove all completed tasks + task tasklists List all task lists + +━━━ People & Contacts ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + people search <query> Search personal contacts + people directory <query> Search GWS org directory + people list [--max N] + people show <contact_id> + people create --name <name> [--email] [--phone] [--company] [--title] + people update <contact_id> [--name] [--email] ... + people delete <contact_id> + people lookup <name> Quick name → email lookup + +━━━ Permission & Approval ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + auth suggest "<task>" Infer minimum OAuth scopes + auth check [--profile <p>] Verify current token scopes + auth login [--profile <p>] Launch OAuth2 flow + auth status Show token info + approve gate <task_type> Check execution gate + approve list List pending approvals + approve create "<task>" Create approval request + approve act <id> --action approve|reject + +━━━ Agents & Queue ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + agent list List registered agents + agent register [--file] Register from agents.yaml + agent show <id> Show agent details + ingress enqueue --text "<msg>" [--source gmail|manual] [--sender <email>] + ingress list [--status pending|done|failed|all] + ingress next [--agent <id>] + ingress run-once [--agent <id>] [--dry-run] → outputs Claude prompt + ingress execute-stub --queue-id <id> → show execution plan + ingress context --queue-id <id> → full Claude-readable bundle + ingress delegate --queue-id <id> --to <agent> + ingress handoff --queue-id <id> + ingress approve/resume --queue-id <id> + ingress done/fail --queue-id <id> [--note <text>] + ingress verify --queue-id <id> + ingress stats + +━━━ Daemon (Gmail Poller) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + daemon start [--interval <sec>] [--agent <id>] + daemon stop + daemon status + daemon restart + daemon poll-once Single poll cycle (foreground) + daemon logs [--follow] + daemon install Install macOS launchd service + +━━━ Knowledge Graph ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + kg build Build KG from Drive Docs + kg query "<concept>" Search knowledge graph + kg show <doc_id> Show doc + links + +━━━ System ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + heartbeat Log system state to Sheets + +Options: + --help, -h Show this help + --version, -v Show version + --debug Enable debug output + --dry-run Preview without executing + --confirm Auto-confirm preview-gated operations + +Config: ~/.garc/config.env | Cache: ~/.garc/cache/ +Docs: docs/google-cloud-setup.md | Quickstart: docs/quickstart.md +EOF +} + +# Parse global flags +DEBUG=false +DRY_RUN=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --debug) DEBUG=true; shift ;; + --dry-run) DRY_RUN=true; shift ;; + --help|-h) usage; exit 0 ;; + --version|-v) echo "garc ${VERSION}"; exit 0 ;; + *) break ;; + esac +done + +export DEBUG DRY_RUN GARC_DIR GARC_LIB GARC_CONFIG GARC_CACHE_DIR GARC_CACHE_TTL + +COMMAND="${1:-help}" +shift || true + +case "${COMMAND}" in + init) + source "${GARC_LIB}/bootstrap.sh" + garc_init "$@" + ;; + setup) + python3 "${GARC_DIR}/scripts/garc-setup.py" "${1:-all}" "${@:2}" + ;; + bootstrap) + source "${GARC_LIB}/bootstrap.sh" + garc_bootstrap "$@" + ;; + status) + source "${GARC_LIB}/bootstrap.sh" + garc_status "$@" + ;; + memory) + source "${GARC_LIB}/memory.sh" + garc_memory "$@" + ;; + gmail) + source "${GARC_LIB}/gmail.sh" + garc_gmail "$@" + ;; + send) + # Shorthand: garc send "<msg>" --to <email> + source "${GARC_LIB}/send.sh" + garc_send "$@" + ;; + calendar|cal) + source "${GARC_LIB}/calendar.sh" + garc_calendar "$@" + ;; + drive) + source "${GARC_LIB}/drive.sh" + garc_drive "$@" + ;; + sheets) + source "${GARC_LIB}/sheets.sh" + garc_sheets "$@" + ;; + task) + source "${GARC_LIB}/task.sh" + garc_task "$@" + ;; + people|contacts) + source "${GARC_LIB}/people.sh" + garc_people "$@" + ;; + approve) + source "${GARC_LIB}/approve.sh" + garc_approve "$@" + ;; + agent) + source "${GARC_LIB}/agent.sh" + garc_agent "$@" + ;; + auth) + source "${GARC_LIB}/auth.sh" + garc_auth "$@" + ;; + heartbeat) + source "${GARC_LIB}/heartbeat.sh" + garc_heartbeat "$@" + ;; + kg) + source "${GARC_LIB}/kg.sh" + garc_kg "$@" + ;; + ingress) + source "${GARC_LIB}/ingress.sh" + garc_ingress "$@" + ;; + daemon) + source "${GARC_LIB}/daemon.sh" + garc_daemon "$@" + ;; + help|--help|-h) + usage + ;; + *) + echo "garc: unknown command '${COMMAND}'" >&2 + echo "Run 'garc --help' for usage." >&2 + exit 1 + ;; +esac diff --git a/config/config.env.example b/config/config.env.example new file mode 100644 index 0000000..3cec564 --- /dev/null +++ b/config/config.env.example @@ -0,0 +1,35 @@ +# GARC Configuration +# Copy this to ~/.garc/config.env and fill in your values + +# Google Drive folder ID for agent workspace (disclosure chain files) +GARC_DRIVE_FOLDER_ID=1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# Google Sheets ID for memory, agent registry, queue, and heartbeat +GARC_SHEETS_ID=1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# Default Gmail recipient for agent notifications +GARC_GMAIL_DEFAULT_TO=you@gmail.com + +# Google Chat space ID (optional, for Chat-based notifications) +GARC_CHAT_SPACE_ID=spaces/xxxxxxxx + +# Google Calendar ID (use "primary" for default calendar) +GARC_CALENDAR_ID=primary + +# Cache TTL in seconds (default: 300 = 5 minutes) +GARC_CACHE_TTL=300 + +# OAuth2 client credentials JSON file (from Google Cloud Console) +GARC_CREDENTIALS_FILE=~/.garc/credentials.json + +# OAuth2 user token file (auto-generated after first login) +GARC_TOKEN_FILE=~/.garc/token.json + +# Service account JSON file (for bot/automated operations) +# GARC_SERVICE_ACCOUNT_FILE=~/.garc/service_account.json + +# Default agent ID +GARC_DEFAULT_AGENT=main + +# Cache directory +GARC_CACHE_DIR=~/.garc/cache diff --git a/config/gate-policy.json b/config/gate-policy.json new file mode 100644 index 0000000..8cfcb63 --- /dev/null +++ b/config/gate-policy.json @@ -0,0 +1,72 @@ +{ + "_comment": "Execution gate policy for GARC. Three tiers: none (immediate), preview (--confirm required), approval (approval gate).", + "gates": { + "none": { + "description": "Read-only operations. Execute immediately without confirmation.", + "tasks": [ + "read_document", + "read_spreadsheet", + "read_drive", + "read_email", + "read_calendar", + "read_tasks", + "read_contacts", + "read_chat", + "read_crm" + ] + }, + "preview": { + "description": "Medium-risk operations with external visibility or writes. Requires --confirm flag or user acknowledgment.", + "tasks": [ + "create_document", + "update_document", + "write_spreadsheet", + "create_drive", + "send_email", + "write_calendar", + "manage_tasks", + "send_chat", + "create_crm", + "followup_crm", + "create_event", + "schedule_meeting", + "update_event", + "delete_event", + "invite_attendees", + "write_report", + "send_report" + ] + }, + "approval": { + "description": "High-risk, irreversible, or financial operations. Must wait for explicit human approval.", + "tasks": [ + "manage_drive", + "manage_email", + "manage_contacts", + "create_expense", + "submit_approval" + ] + } + }, + "risk_examples": { + "none (immediate)": [ + "read_document", + "read_email", + "read_calendar", + "read_spreadsheet" + ], + "preview (confirmation)": [ + "create_document", + "update_document", + "send_email", + "write_calendar", + "write_spreadsheet" + ], + "approval (gate)": [ + "create_expense", + "submit_approval", + "manage_drive", + "manage_email" + ] + } +} \ No newline at end of file diff --git a/config/scope-map.json b/config/scope-map.json new file mode 100644 index 0000000..b2af2d8 --- /dev/null +++ b/config/scope-map.json @@ -0,0 +1,770 @@ +{ + "tasks": { + "read_document": { + "scopes": [ + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/documents.readonly" + ], + "identity": "user_access_token", + "gate": "none", + "description": "Read Google Drive files and Docs" + }, + "create_document": { + "scopes": [ + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/documents" + ], + "identity": "user_access_token", + "gate": "preview", + "description": "Create new Google Docs" + }, + "update_document": { + "scopes": [ + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/documents" + ], + "identity": "user_access_token", + "gate": "preview", + "description": "Update existing Google Docs" + }, + "read_drive": { + "scopes": [ + "https://www.googleapis.com/auth/drive.readonly" + ], + "identity": "user_access_token", + "gate": "none", + "description": "List and browse Google Drive files" + }, + "create_drive": { + "scopes": [ + "https://www.googleapis.com/auth/drive.file" + ], + "identity": "user_access_token", + "gate": "preview", + "description": "Create files and folders in Google Drive" + }, + "manage_drive": { + "scopes": [ + "https://www.googleapis.com/auth/drive" + ], + "identity": "user_access_token", + "gate": "approval", + "description": "Full Drive access including deletion and sharing" + }, + "share_file": { + "scopes": [ + "https://www.googleapis.com/auth/drive" + ], + "identity": "user_access_token", + "gate": "approval", + "description": "Share files with other users" + }, + "upload_file": { + "scopes": [ + "https://www.googleapis.com/auth/drive.file" + ], + "identity": "user_access_token", + "gate": "preview", + "description": "Upload files to Google Drive" + }, + "delete_file": { + "scopes": [ + "https://www.googleapis.com/auth/drive" + ], + "identity": "user_access_token", + "gate": "approval", + "description": "Delete files from Google Drive" + }, + "read_spreadsheet": { + "scopes": [ + "https://www.googleapis.com/auth/spreadsheets.readonly" + ], + "identity": "user_access_token", + "gate": "none", + "description": "Read data from Google Sheets" + }, + "write_spreadsheet": { + "scopes": [ + "https://www.googleapis.com/auth/spreadsheets" + ], + "identity": "user_access_token", + "gate": "preview", + "description": "Write data to Google Sheets" + }, + "read_email": { + "scopes": [ + "https://www.googleapis.com/auth/gmail.readonly" + ], + "identity": "user_access_token", + "gate": "none", + "description": "Read Gmail messages and threads" + }, + "send_email": { + "scopes": [ + "https://www.googleapis.com/auth/gmail.send" + ], + "identity": "user_access_token", + "gate": "preview", + "description": "Send Gmail messages" + }, + "reply_email": { + "scopes": [ + "https://www.googleapis.com/auth/gmail.send" + ], + "identity": "user_access_token", + "gate": "preview", + "description": "Reply to Gmail threads" + }, + "draft_email": { + "scopes": [ + "https://www.googleapis.com/auth/gmail.compose" + ], + "identity": "user_access_token", + "gate": "none", + "description": "Create Gmail drafts (not sent)" + }, + "manage_email": { + "scopes": [ + "https://www.googleapis.com/auth/gmail.modify" + ], + "identity": "user_access_token", + "gate": "approval", + "description": "Full Gmail access including delete and label management" + }, + "search_email": { + "scopes": [ + "https://www.googleapis.com/auth/gmail.readonly" + ], + "identity": "user_access_token", + "gate": "none", + "description": "Search Gmail messages" + }, + "read_calendar": { + "scopes": [ + "https://www.googleapis.com/auth/calendar.readonly" + ], + "identity": "user_access_token", + "gate": "none", + "description": "Read Google Calendar events" + }, + "create_event": { + "scopes": [ + "https://www.googleapis.com/auth/calendar" + ], + "identity": "user_access_token", + "gate": "preview", + "description": "Create Google Calendar events" + }, + "update_event": { + "scopes": [ + "https://www.googleapis.com/auth/calendar" + ], + "identity": "user_access_token", + "gate": "preview", + "description": "Update Google Calendar events" + }, + "delete_event": { + "scopes": [ + "https://www.googleapis.com/auth/calendar" + ], + "identity": "user_access_token", + "gate": "approval", + "description": "Delete Google Calendar events" + }, + "invite_attendees": { + "scopes": [ + "https://www.googleapis.com/auth/calendar" + ], + "identity": "user_access_token", + "gate": "preview", + "description": "Invite attendees to calendar events" + }, + "check_freebusy": { + "scopes": [ + "https://www.googleapis.com/auth/calendar.readonly" + ], + "identity": "user_access_token", + "gate": "none", + "description": "Check free/busy availability" + }, + "read_tasks": { + "scopes": [ + "https://www.googleapis.com/auth/tasks.readonly" + ], + "identity": "user_access_token", + "gate": "none", + "description": "Read Google Tasks" + }, + "create_task": { + "scopes": [ + "https://www.googleapis.com/auth/tasks" + ], + "identity": "user_access_token", + "gate": "preview", + "description": "Create Google Tasks" + }, + "complete_task": { + "scopes": [ + "https://www.googleapis.com/auth/tasks" + ], + "identity": "user_access_token", + "gate": "none", + "description": "Mark Google Tasks as complete" + }, + "manage_tasks": { + "scopes": [ + "https://www.googleapis.com/auth/tasks" + ], + "identity": "user_access_token", + "gate": "preview", + "description": "Full Google Tasks management" + }, + "read_contacts": { + "scopes": [ + "https://www.googleapis.com/auth/contacts.readonly", + "https://www.googleapis.com/auth/people.readonly" + ], + "identity": "user_access_token", + "gate": "none", + "description": "Read Google Contacts and People directory" + }, + "manage_contacts": { + "scopes": [ + "https://www.googleapis.com/auth/contacts" + ], + "identity": "user_access_token", + "gate": "approval", + "description": "Create and update Google Contacts" + }, + "search_people": { + "scopes": [ + "https://www.googleapis.com/auth/people.readonly" + ], + "identity": "user_access_token", + "gate": "none", + "description": "Search Google Workspace directory" + }, + "read_chat": { + "scopes": [ + "https://www.googleapis.com/auth/chat.messages.readonly" + ], + "identity": "bot_access_token", + "gate": "none", + "description": "Read Google Chat messages" + }, + "send_chat": { + "scopes": [ + "https://www.googleapis.com/auth/chat.messages" + ], + "identity": "bot_access_token", + "gate": "preview", + "description": "Send Google Chat messages" + }, + "read_crm": { + "scopes": [ + "https://www.googleapis.com/auth/spreadsheets.readonly" + ], + "identity": "user_access_token", + "gate": "none", + "description": "Read CRM data from Google Sheets" + }, + "create_crm": { + "scopes": [ + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/gmail.send" + ], + "identity": "user_access_token", + "gate": "preview", + "description": "Create CRM records and send follow-up" + }, + "followup_crm": { + "scopes": [ + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/gmail.send", + "https://www.googleapis.com/auth/calendar" + ], + "identity": "user_access_token", + "gate": "preview", + "description": "CRM follow-up: update record + email + calendar" + }, + "create_expense": { + "scopes": [ + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/gmail.send" + ], + "identity": "user_access_token", + "gate": "approval", + "description": "Create expense report and route to approval" + }, + "submit_approval": { + "scopes": [ + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/gmail.send" + ], + "identity": "user_access_token", + "gate": "approval", + "description": "Submit item for approval workflow" + }, + "write_report": { + "scopes": [ + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/documents", + "https://www.googleapis.com/auth/spreadsheets.readonly" + ], + "identity": "user_access_token", + "gate": "preview", + "description": "Write and save a report to Drive" + }, + "send_report": { + "scopes": [ + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/documents", + "https://www.googleapis.com/auth/gmail.send" + ], + "identity": "user_access_token", + "gate": "preview", + "description": "Write and send report by email" + }, + "schedule_meeting": { + "scopes": [ + "https://www.googleapis.com/auth/calendar", + "https://www.googleapis.com/auth/gmail.send", + "https://www.googleapis.com/auth/calendar.readonly" + ], + "identity": "user_access_token", + "gate": "preview", + "description": "Check availability and schedule a meeting" + }, + "read_analytics": { + "scopes": [ + "https://www.googleapis.com/auth/spreadsheets.readonly", + "https://www.googleapis.com/auth/drive.readonly" + ], + "identity": "user_access_token", + "gate": "none", + "description": "Read analytics data from Sheets or Drive" + }, + "write_analytics": { + "scopes": [ + "https://www.googleapis.com/auth/spreadsheets" + ], + "identity": "user_access_token", + "gate": "preview", + "description": "Write analytics data to Sheets" + } + }, + "profiles": { + "readonly": { + "scopes": [ + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/documents.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/people.readonly" + ], + "description": "Read-only access to all GWS resources" + }, + "writer": { + "scopes": [ + "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.compose", + "https://www.googleapis.com/auth/gmail.readonly", + "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" + ], + "description": "Write access to owned resources and send email" + }, + "admin": { + "scopes": [ + "https://www.googleapis.com/auth/drive", + "https://www.googleapis.com/auth/documents", + "https://www.googleapis.com/auth/spreadsheets", + "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", + "https://www.googleapis.com/auth/people", + "https://www.googleapis.com/auth/admin.directory.user.readonly" + ], + "description": "Full admin access across all GWS resources" + }, + "backoffice_agent": { + "scopes": [ + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/drive.readonly", + "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.compose", + "https://www.googleapis.com/auth/gmail.readonly", + "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/chat.messages" + ], + "description": "Back-office automation profile for office-work agents" + } + }, + "keyword_patterns": { + "read_document": [ + "read doc", + "open doc", + "view doc", + "check doc", + "get doc", + "fetch doc", + "meeting notes", + "notes", + "ドキュメント読む", + "文書確認", + "議事録", + "会議メモ", + "read file", + "ファイル読む", + "show doc", + "get document" + ], + "create_document": [ + "create doc", + "write doc", + "draft doc", + "new doc", + "create document", + "write document", + "作成", + "ドキュメント作成", + "new document", + "google doc", + "make a document", + "ドキュメントを作", + "新しいドキュメント" + ], + "update_document": [ + "update doc", + "edit doc", + "modify doc", + "revise doc", + "update document", + "更新", + "編集", + "edit document" + ], + "read_drive": [ + "list files", + "browse drive", + "find file", + "ファイル一覧", + "Drive確認", + "list drive", + "show files", + "download", + "ダウンロード", + "get file", + "fetch file", + "retrieve file", + "ファイルを取得", + "ファイルを開く" + ], + "upload_file": [ + "upload", + "add file", + "put file", + "アップロード", + "ファイルを追加" + ], + "create_drive": [ + "create folder", + "new folder", + "フォルダ作成", + "make folder" + ], + "share_file": [ + "share", + "共有", + "share file", + "share doc", + "share drive", + "grant access", + "give access" + ], + "delete_file": [ + "delete file", + "remove file", + "ファイル削除", + "trash file" + ], + "manage_drive": [ + "manage drive", + "full drive", + "drive admin" + ], + "read_spreadsheet": [ + "read sheet", + "check sheet", + "view sheet", + "スプレッドシート読む", + "シート確認", + "spreadsheet", + "data", + "records", + "データ", + "シート", + "read data" + ], + "write_spreadsheet": [ + "update sheet", + "write sheet", + "add row", + "シート更新", + "データ書き込む", + "write data", + "update data", + "シートに書く", + "スプレッドシートに追加", + "シートを更新", + "記録する" + ], + "read_email": [ + "read email", + "check inbox", + "view mail", + "メール確認", + "受信箱", + "inbox", + "check email", + "read mail", + "メールを読む", + "受信メール" + ], + "search_email": [ + "search email", + "find email", + "find mail", + "メール検索", + "search inbox" + ], + "send_email": [ + "send email", + "write email", + "compose email", + "メール送信", + "メール書く", + "send mail", + "email to", + "send a mail", + "メールを送", + "連絡する" + ], + "reply_email": [ + "reply email", + "reply to", + "respond to email", + "返信", + "reply" + ], + "draft_email": [ + "draft email", + "draft mail", + "メール下書き", + "下書き" + ], + "read_calendar": [ + "check calendar", + "view schedule", + "calendar", + "schedule", + "カレンダー確認", + "予定確認", + "what's on", + "agenda" + ], + "check_freebusy": [ + "free busy", + "freebusy", + "空き時間", + "availability", + "available", + "when is", + "暇な時間" + ], + "create_event": [ + "add event", + "create meeting", + "schedule meeting", + "予定追加", + "会議設定", + "book meeting", + "create event", + "new event", + "new meeting", + "schedule", + "set up meeting", + "book a meeting", + "arrange meeting", + "ミーティング", + "会議を作", + "スケジュール登録", + "日程調整" + ], + "update_event": [ + "update event", + "edit event", + "change meeting", + "reschedule", + "イベント更新", + "予定変更" + ], + "delete_event": [ + "delete event", + "cancel meeting", + "remove event", + "予定削除", + "キャンセル" + ], + "invite_attendees": [ + "invite", + "add attendee", + "invite to meeting", + "招待", + "参加者追加" + ], + "schedule_meeting": [ + "schedule meeting", + "set up meeting", + "会議をセット", + "ミーティング設定", + "book time", + "打ち合わせ", + "日程調整", + "会議の設定", + "mtg", + "schedule a call" + ], + "read_tasks": [ + "check tasks", + "list tasks", + "todo", + "タスク確認", + "ToDoリスト", + "my tasks", + "task list", + "やること" + ], + "create_task": [ + "add task", + "create task", + "new task", + "タスク追加", + "タスク作成", + "add to-do" + ], + "complete_task": [ + "complete task", + "done task", + "finish task", + "タスク完了", + "完了" + ], + "read_contacts": [ + "contacts", + "address book", + "連絡先", + "directory", + "people", + "find person", + "人を探す" + ], + "search_people": [ + "search people", + "find colleague", + "who is", + "directory search", + "社員検索", + "同僚検索" + ], + "send_chat": [ + "send chat", + "chat message", + "チャット送信", + "google chat", + "chat" + ], + "read_crm": [ + "crm", + "customer", + "client", + "顧客", + "lead", + "contact list" + ], + "create_crm": [ + "add customer", + "create contact", + "add lead", + "顧客追加", + "new client" + ], + "followup_crm": [ + "follow up", + "follow-up", + "フォローアップ", + "顧客フォロー", + "customer followup" + ], + "create_expense": [ + "expense", + "経費", + "reimbursement", + "精算", + "費用申請", + "expense report" + ], + "submit_approval": [ + "approve", + "approval", + "submit for approval", + "承認", + "申請", + "get approved" + ], + "write_report": [ + "write report", + "create report", + "レポート作成", + "報告書", + "weekly report", + "週次レポート" + ], + "send_report": [ + "send report", + "email report", + "レポートを送る", + "報告書送付" + ], + "read_analytics": [ + "analytics", + "data analysis", + "metrics", + "stats", + "分析", + "kpi", + "指標" + ], + "write_analytics": [ + "update analytics", + "write metrics", + "データ更新", + "kpi更新" + ] + } +} \ No newline at end of file diff --git a/docs/garc-architecture.md b/docs/garc-architecture.md new file mode 100644 index 0000000..abe948e --- /dev/null +++ b/docs/garc-architecture.md @@ -0,0 +1,229 @@ +# GARC Architecture + +## System Overview + +``` +Upper-layer agent (Claude Code / OpenClaw) + ↓ garc commands +GARC CLI (bin/garc + lib/*.sh) + ↓ Python helpers (scripts/*.py) +Google Workspace APIs + ├── Google Drive (disclosure chain, knowledge graph) + ├── Google Sheets (memory, agent registry, queue, approval) + ├── Gmail (messaging, approval notifications) + ├── Google Calendar (calendar operations) + ├── Google Tasks (task management) + └── Google Chat (optional Chat messaging) +``` + +## Layer 1: Disclosure Chain (Google Drive) + +Agent context is loaded from a Google Drive folder at bootstrap: + +``` +Google Drive Folder (GARC_DRIVE_FOLDER_ID) + ├── SOUL.md → Agent identity & principles + ├── USER.md → User profile & preferences + ├── MEMORY.md → Memory index (pointer to Sheets) + ├── RULES.md → Operating rules & constraints + ├── HEARTBEAT.md → Latest system state + └── memory/ + └── YYYY-MM-DD.md → Daily context notes + +Downloaded to: ~/.garc/cache/workspace/<agent_id>/ +Consolidated: ~/.garc/cache/workspace/<agent_id>/AGENT_CONTEXT.md +``` + +### Bootstrap Flow + +``` +garc bootstrap --agent main + ↓ Drive API: search for each file in folder + ↓ Download to ~/.garc/cache/workspace/main/ + ↓ Build AGENT_CONTEXT.md (concatenation) + → Agent has full context +``` + +## Layer 2: Structured Data (Google Sheets) + +One Google Sheets spreadsheet holds all GARC operational data: + +| Tab | Schema | Purpose | +|-----|--------|---------| +| `memory` | agent_id, timestamp, entry, source | Long-term memory | +| `agents` | id, model, scopes, description, status, registered_at | Agent registry | +| `queue` | queue_id, agent_id, message, status, gate, created_at | Task queue | +| `heartbeat` | agent_id, timestamp, status, notes, platform | System state log | +| `approval` | approval_id, agent_id, task, status, created_at, resolved_at | Approval flow | + +### Initial Sheet Setup + +Run `garc init` to provision the spreadsheet with the above tabs and headers. + +## Layer 3: Permission Intelligence + +The scope inference engine (`scripts/garc-auth-helper.py`) maps natural-language tasks to minimum OAuth scopes: + +``` +Input: "send expense report to manager and create calendar reminder" + ↓ keyword matching against config/scope-map.json +Output: + Matched tasks: [send_email, create_expense, write_calendar] + Scopes: [gmail.send, spreadsheets, drive.file, calendar] + Gate: approval (highest tier from matched tasks) + Identity: user_access_token +``` + +### Scope Map Structure + +```json +{ + "tasks": { + "<task_type>": { + "scopes": ["https://www.googleapis.com/auth/<scope>"], + "identity": "user_access_token | bot_access_token", + "gate": "none | preview | approval", + "description": "..." + } + }, + "profiles": { + "readonly | writer | admin | backoffice_agent": { + "scopes": [...], + "description": "..." + } + }, + "keyword_patterns": { + "<task_type>": ["keyword1", "keyword2", ...] + } +} +``` + +## Layer 4: Execution Gates + +Before any GARC action, the gate policy is checked: + +``` +garc approve gate <task_type> + +none → ✅ Execute immediately (read-only ops) +preview → ⚠️ Add --confirm flag (writes with external visibility) +approval → 🔒 Create approval request, wait for human +``` + +### Gate Flow + +``` +Agent wants to execute task + ↓ garc approve gate <task_type> + ↓ Check config/gate-policy.json + +[none] → Proceed +[preview] → Show preview, require --confirm + If --confirm given → Proceed + Else → Abort +[approval] → garc approve create "<task>" + → Append to Sheets "approval" tab + → Send Gmail notification to GARC_GMAIL_DEFAULT_TO + → Block execution until status = "approved" + → garc ingress approve <queue_id> + → Resume execution +``` + +## Layer 5: Agent Registry + +Agents are declared in `agents.yaml` and registered to the Sheets `agents` tab: + +```yaml +agents: + - id: main + model: claude-sonnet-4-6 + scopes: [drive.file, gmail.send, ...] + profile: backoffice_agent +``` + +```bash +garc agent register --file agents.yaml +# → Upsert rows to Sheets "agents" tab +``` + +The registry enables delegation: the upper-layer agent can query which registered agent has the right scopes for a given task, then dispatch to it. + +## Layer 6: Queue / Ingress + +The queue bridges incoming requests (manual or Gmail-triggered) to governed execution: + +``` +Incoming request (manual command / Gmail webhook) + ↓ garc ingress enqueue "<message>" + → ~/.garc/cache/queue/<id>.jsonl (local JSONL) + → Also upsert to Sheets "queue" tab (optional) + +garc ingress next + → Returns oldest "pending" item + +garc ingress run-once + ↓ Claim item (status → in_progress) + ↓ garc approve gate <inferred_gate> + [none] → Build context bundle → dispatch to upper agent + [preview] → Ask for confirmation + [approval] → Create approval → block +``` + +### Queue Item Schema + +```jsonl +{"queue_id":"abc123","message":"...","status":"pending","gate":"preview","source":"manual","created_at":"2026-04-15T10:00:00Z","agent":"main"} +``` + +## Layer 7: Knowledge Graph (Google Docs) + +Google Docs files in the Drive workspace folder are indexed as knowledge nodes: + +``` +garc kg build + ↓ Drive API: list all Docs in workspace folder + ↓ Export each Doc as plain text + ↓ Extract Google Doc links from content + → ~/.garc/cache/knowledge-graph.json + +garc kg query "expense approval" + → Search titles and content previews + → Return matching nodes + link neighbors +``` + +## MCP Integration (Claude Code) + +When GARC is used from Claude Code, the MCP tools can be used directly as an alternative to the Python helpers: + +| Operation | GARC Command | Claude Code MCP | +|-----------|-------------|-----------------| +| Authenticate Gmail | `garc auth login` | `mcp__claude_ai_Gmail__authenticate` | +| Authenticate Drive | `garc auth login` | `mcp__claude_ai_Google_Drive__authenticate` | +| Authenticate Calendar | `garc auth login` | `mcp__claude_ai_Google_Calendar__authenticate` | + +The MCP tools provide richer interactive authentication while the CLI helpers work better in automated/headless contexts. + +## Security Model + +### Principle of Least Privilege + +`garc auth suggest` always infers the **minimum** scopes needed for a task. Agents should use the suggested scopes rather than a broad profile unless the broader profile is genuinely needed. + +### Gate Policy Enforcement + +Gate policies are stored in `config/gate-policy.json` (local) and cannot be overridden at runtime. To change a policy, you must edit the file and restart. + +### Token Storage + +Tokens are stored in `~/.garc/token.json`. This file should be: +- Readable only by the owner (`chmod 600`) +- Never committed to version control +- Added to `.gitignore` + +### Service Account vs User Token + +| Scenario | Recommended | +|----------|-------------| +| Automated/headless agent | Service account | +| User-facing agent (acts as user) | OAuth2 user token | +| Mixed (read as bot, write as user) | Both configured | diff --git a/docs/garc-vs-larc.md b/docs/garc-vs-larc.md new file mode 100644 index 0000000..4ed3141 --- /dev/null +++ b/docs/garc-vs-larc.md @@ -0,0 +1,131 @@ +# GARC vs LARC — 比較ドキュメント + +## 概要 + +GARCとLARCは同じランタイムガバナンスモデルを共有する兄弟プロジェクトです。 +バックエンドプラットフォームのみが異なります。 + +| 側面 | LARC | GARC | +|------|------|------| +| バックエンド | Feishu / Lark | Google Workspace | +| ファイルストレージ | Lark Drive | Google Drive | +| 構造化DB | Lark Base | Google Sheets | +| メッセージング | Lark IM | Gmail / Google Chat | +| ナレッジ | Lark Wiki | Google Docs | +| 承認フロー | Lark Approval | Sheets + Gmail | +| カレンダー | Lark Calendar | Google Calendar | +| タスク | Lark Project | Google Tasks | +| 認証 | Lark OAuth + `lark-cli` | Google OAuth 2.0 | +| MCP統合 | `openclaw-lark` スキル | Gmail/Drive/Calendar MCP | + +## 共通アーキテクチャ(変わらないもの) + +### 1. 開示チェーン(Disclosure Chain) + +``` +SOUL.md → USER.md → MEMORY.md → RULES.md → HEARTBEAT.md +``` + +LARCではLark Drive、GARCではGoogle Driveに保存。 +読み込み後のローカルキャッシュ構造(`~/.larc/` / `~/.garc/`)は同一。 + +### 2. パーミッションインテリジェンス + +```bash +larc auth suggest "create expense report" # LARC +garc auth suggest "create expense report" # GARC +``` + +どちらも同じキーワードマッチング + スコープ推定ロジック。 +出力フォーマットも同一。LARCはLarkスコープ、GARCはGoogle OAuthスコープ。 + +### 3. 実行ゲート + +``` +none → 即時実行(読み取り系) +preview → --confirm フラグ必要(外部可視・書き込み) +approval → 承認ゲート(金銭・権限・不可逆) +``` + +ゲートポリシーのタスクカテゴリは共通。LARCはLark Approval、GARCはSheets+Gmailで管理。 + +### 4. エージェントレジストリ + +LARCはLark Baseにエージェント台帳、GARCはGoogle Sheetsに同構造で保存。 +`agents.yaml`のフォーマットは両プロジェクトで互換性があります。 + +### 5. メモリシステム + +LARCはLark Baseの`memory`テーブル、GARCはGoogle Sheetsの`memory`タブ。 +日次pull/pushのインターフェースは同一。 + +## 主な差異 + +### 認証フロー + +**LARC**: `lark-cli auth login` → Larkのブラウザ認証 → トークン保存 + +**GARC**: `garc auth login` → Google OAuth 2.0 フロー → `~/.garc/token.json` + +GARCの方が標準的なOAuth2フロー。credentials.jsonの事前ダウンロードが必要。 + +### MCP統合 + +**LARC**: `openclaw-lark` プラグイン(9スキル: bitable/calendar/doc/im/task等) + +**GARC**: +- `mcp__claude_ai_Gmail__*` — Gmail操作 +- `mcp__claude_ai_Google_Drive__*` — Drive操作 +- `mcp__claude_ai_Google_Calendar__*` — Calendar操作 +- Claude Code組み込みMCP → 直接使用可能 + +GARCはLARCより即座にMCPで動作確認できます(Claude Code側にMCPが既に組み込まれているため)。 + +### スコープ粒度 + +**LARC**: Larkのスコープは`docs:doc:readonly`等のパス形式 + +**GARC**: Googleのスコープは`https://www.googleapis.com/auth/drive.readonly`等のURL形式。 +より細かく、`drive`(全Drive)vs `drive.file`(自分が作成したファイルのみ)等の区別がある。 + +### 承認フロー + +**LARC**: Lark Approvalという専用承認ワークフロー機能がある + +**GARC**: Google Sheetsのapprovalタブ + Gmail通知で承認管理。 +Lark Approvalほどリッチな機能はないが、シンプルで確実。 + +## 実装状況比較 + +| 機能 | LARC | GARC | +|------|------|------| +| bootstrap | ✅ live | 🏗 実装済(API接続待ち) | +| memory pull/push | ✅ live | 🏗 実装済(API接続待ち) | +| memory search | ✅ live | 🏗 実装済(API接続待ち) | +| send message | ✅ live | 🏗 実装済(API接続待ち) | +| task list/create | ✅ live | 🏗 実装済(API接続待ち) | +| auth suggest | ✅ live | ✅ 実装済(Python、動作可) | +| approve gate | ✅ live | ✅ 実装済(JSON、動作可) | +| agent register | ✅ live | 🏗 実装済(API接続待ち) | +| heartbeat | ✅ live | 🏗 実装済(API接続待ち) | +| kg build/query | ✅ live | 🏗 実装済(API接続待ち) | +| ingress queue | ✅ live | ✅ 実装済(ローカルキャッシュ) | + +## 移行・使い分けガイド + +### LARCからGARCへの移行 + +1. `agents.yaml`はそのまま使用可能(スコープのみ変更) +2. 開示チェーンファイル(SOUL.md等)をGoogle Driveにアップロード +3. Lark BaseのデータをGoogle Sheetsにエクスポート +4. `larc` → `garc` コマンドは1:1対応 + +### 両方使う場合 + +``` +Feishu/Lark ユーザー向けタスク → LARC +Google Workspace ユーザー向けタスク → GARC +``` + +agents.yamlは共通形式のため、エージェントを両プラットフォームに登録可能。 +上位エージェント(OpenClaw)が状況に応じてどちらのランタイムを使うか選択します。 diff --git a/docs/google-cloud-setup.md b/docs/google-cloud-setup.md new file mode 100644 index 0000000..4887b3a --- /dev/null +++ b/docs/google-cloud-setup.md @@ -0,0 +1,80 @@ +# Google Cloud Console セットアップガイド + +## 有効化するAPI一覧 + +Google Cloud Console (https://console.cloud.google.com/) で以下を有効化してください。 + +### 必須 API(6種) + +| API名 | サービス名 | 用途 | +|-------|-----------|------| +| **Google Drive API** | `drive.googleapis.com` | ファイル読み書き・開示チェーン | +| **Google Sheets API** | `sheets.googleapis.com` | メモリ・エージェント台帳・キュー | +| **Gmail API** | `gmail.googleapis.com` | メール送受信・承認通知 | +| **Google Calendar API** | `calendar-json.googleapis.com` | 予定管理・会議調整 | +| **Google Tasks API** | `tasks.googleapis.com` | タスク管理 | +| **Google Docs API** | `docs.googleapis.com` | ドキュメント作成・編集 | + +### 推奨 API(2種) + +| API名 | サービス名 | 用途 | +|-------|-----------|------| +| **Google People API** | `people.googleapis.com` | 連絡先・組織メンバー検索 | +| **Google Chat API** | `chat.googleapis.com` | Chatボット・スペースへの送信 | + +--- + +## 有効化手順 + +1. https://console.cloud.google.com/ にアクセス +2. 上部のプロジェクト選択で **新しいプロジェクト** を作成(または既存を選択) +3. 左メニュー → **「APIとサービス」** → **「APIとサービスを有効化」** +4. 検索ボックスに上記のAPI名を入力 → **「有効にする」** + +--- + +## OAuth2 認証情報の作成 + +1. **「APIとサービス」** → **「認証情報」** +2. **「認証情報を作成」** → **「OAuth 2.0 クライアント ID」** +3. アプリケーションの種類: **「デスクトップアプリ」** +4. 名前: `GARC CLI` など任意 +5. 作成後 **「JSONをダウンロード」** → `~/.garc/credentials.json` に保存 + +--- + +## OAuth同意画面の設定 + +1. **「APIとサービス」** → **「OAuth 同意画面」** +2. ユーザーの種類: **「外部」**(個人Gmailの場合)または **「内部」**(Workspace組織の場合) +3. アプリ名: `GARC` など +4. **テストユーザー** に自分のGmailアドレスを追加 +5. スコープは空でOK(CLIが実行時に要求します) + +--- + +## サービスアカウント(ボット操作用・任意) + +自動化・ヘッドレス実行には Service Account が推奨: + +1. **「認証情報」** → **「認証情報を作成」** → **「サービスアカウント」** +2. 名前: `garc-bot` など +3. 作成後、サービスアカウントの **「キー」** タブ → **「鍵を追加」** → **「JSON」** +4. ダウンロードしたJSONを `~/.garc/service_account.json` に保存 +5. 使用するDriveフォルダ・SheetsをサービスアカウントのメールアドレスにShare + +--- + +## 確認用コマンド + +APIを有効化してcredentials.jsonを配置したら: + +```bash +garc auth login --profile backoffice_agent +# → ブラウザが開いてGoogleログイン画面 +# → 全スコープを承認 +# → ~/.garc/token.json が生成される + +garc status +# → 全項目が ✅ になることを確認 +``` diff --git a/docs/gws-api-alignment.md b/docs/gws-api-alignment.md new file mode 100644 index 0000000..186e04d --- /dev/null +++ b/docs/gws-api-alignment.md @@ -0,0 +1,84 @@ +# GWS API Command Alignment + +This document maps LARC's lark-cli commands to GARC's equivalent Google API calls. + +## Drive / File Storage + +| Operation | LARC (lark-cli) | GARC (garc-drive-helper.py) | +|-----------|----------------|------------------------------| +| List folder | `drive files list --params '{"folder_token":"..."}'` | `files().list(q="'{id}' in parents")` | +| Download file | `drive +download --file-token` | `files().get_media(fileId=...)` | +| Create folder | `drive files create_folder --data '...'` | `files().create(body={mimeType:'application/vnd.google-apps.folder'})` | +| Upload file | `drive files upload` | `files().create(media_body=MediaFileUpload(...))` | +| Export Doc | (n/a, native format) | `files().export_media(fileId=..., mimeType='text/plain')` | + +## Structured Data + +| Operation | LARC (Lark Base) | GARC (Google Sheets) | +|-----------|-----------------|----------------------| +| List records | `base +record-list --base-token` | `values().get(spreadsheetId=..., range='Sheet!A:Z')` | +| Create record | `base +record-upsert --base-token` | `values().append(spreadsheetId=..., range=..., body=...)` | +| Update record | `base +record-upsert --base-token` | `values().update(spreadsheetId=..., range=..., body=...)` | +| Search records | (filter in Base) | Python-side filter after `values().get()` | + +## Messaging + +| Operation | LARC (Lark IM) | GARC (Gmail) | +|-----------|---------------|--------------| +| Send message | `lark-cli im send --chat-id` | `messages().send(userId='me', body={raw:...})` | +| Read messages | `lark-cli im list` | `messages().list(userId='me', q='...')` | +| Send to space | (IM chat) | Google Chat API: `spaces.messages().create(parent=..., body=...)` | + +## Calendar + +| Operation | LARC (Lark Calendar) | GARC (Google Calendar) | +|-----------|---------------------|------------------------| +| List events | `lark-cli calendar +list` | `events().list(calendarId='primary', timeMin=..., timeMax=...)` | +| Create event | `lark-cli calendar +create` | `events().insert(calendarId='primary', body=...)` | +| Update event | `lark-cli calendar +update` | `events().update(calendarId='primary', eventId=..., body=...)` | + +## Tasks + +| Operation | LARC (Lark Project) | GARC (Google Tasks) | +|-----------|---------------------|---------------------| +| List tasks | `task +get-my-tasks` | `tasks().list(tasklist='@default')` | +| Create task | `task +create` | `tasks().insert(tasklist='@default', body=...)` | +| Complete task | `task +complete` | `tasks().patch(tasklist=..., task=..., body={status:'completed'})` | + +## OAuth Scope Reference + +### Minimal Scopes by Operation Type + +| Operations | Scope | +|-----------|-------| +| Read any Drive file | `drive.readonly` | +| Read/write owned Drive files | `drive.file` | +| Full Drive access | `drive` | +| Read Google Docs | `drive.readonly` + `documents.readonly` | +| Edit Google Docs | `drive.file` + `documents` | +| Read Sheets | `spreadsheets.readonly` | +| Write Sheets | `spreadsheets` | +| Send Gmail | `gmail.send` | +| Read Gmail | `gmail.readonly` | +| Full Gmail | `gmail.modify` | +| Read Calendar | `calendar.readonly` | +| Write Calendar | `calendar` | +| Read Tasks | `tasks.readonly` | +| Write Tasks | `tasks` | +| Read Contacts | `contacts.readonly` | +| Write Contacts | `contacts` | +| Google Chat (bot) | `chat.messages` | + +All Google OAuth scopes are prefixed with `https://www.googleapis.com/auth/`. + +## API Quotas and Rate Limits + +| API | Default Quota | Notes | +|-----|---------------|-------| +| Google Drive | 1,000 req/100s/user | File operations | +| Google Sheets | 300 req/min/project | Read: use batch | +| Gmail | 250 quota units/user/second | Send = 100 units | +| Google Calendar | 1,000,000 req/day | | +| Google Tasks | 50,000 req/day | | + +GARC implements basic retry with exponential backoff via `google-api-python-client`'s built-in retry mechanism. diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..5633f92 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,164 @@ +# GARC Quickstart — 15分で動かす + +## 前提 + +- Python 3.10+ +- pip3 +- Google アカウント(Gmail / Drive 使用中) + +--- + +## Step 1 — インストール + +```bash +git clone <this-repo> ~/study/garc-gws-agent-runtime +cd ~/study/garc-gws-agent-runtime + +# Python依存パッケージ +pip3 install -r requirements.txt + +# CLIをPATHに追加 +echo 'export PATH="$HOME/study/garc-gws-agent-runtime/bin:$PATH"' >> ~/.zshrc +source ~/.zshrc + +# 確認 +garc --version +# → garc 0.1.0 +``` + +--- + +## Step 2 — Google Cloud Console でAPIを有効化 + +1. https://console.cloud.google.com/ にアクセス +2. 新規プロジェクトを作成(または既存を選択) +3. **「APIとサービス」→「APIとサービスを有効化」** で以下を有効化: + +| API | サービス名 | +|-----|---------| +| Google Drive API | `drive.googleapis.com` | +| Google Sheets API | `sheets.googleapis.com` | +| Gmail API | `gmail.googleapis.com` | +| Google Calendar API | `calendar-json.googleapis.com` | +| Google Tasks API | `tasks.googleapis.com` | +| Google Docs API | `docs.googleapis.com` | +| Google People API | `people.googleapis.com` | + +4. **「認証情報」→「OAuth 2.0 クライアントID」** を作成 + - アプリタイプ: **デスクトップアプリ** + - JSONダウンロード → `~/.garc/credentials.json` に保存 + +5. **「OAuth同意画面」** → テストユーザーに自分のGmailを追加 + +--- + +## Step 3 — 認証 + +```bash +garc auth login --profile backoffice_agent +# → ブラウザが開く → Googleログイン → 全スコープを承認 +# → ~/.garc/token.json が生成される + +garc auth status +# → 付与されたスコープが表示される +``` + +--- + +## Step 4 — ワークスペースを自動プロビジョニング + +```bash +garc setup all +# → Google DriveにGARC Workspaceフォルダを作成 +# → Google Sheetsにすべてのタブを作成(memory/agents/queue/heartbeat/approval...) +# → 開示チェーンテンプレート(SOUL.md等)をDriveにアップロード +# → ~/.garc/config.env にIDを自動保存 +``` + +--- + +## Step 5 — 動作確認 + +```bash +garc status +# → 全項目が ✅ になることを確認 + +garc bootstrap --agent main +# → DriveからSOUL.md/USER.md/MEMORY.md等を読み込み +# → ~/.garc/cache/workspace/main/AGENT_CONTEXT.md に統合 + +garc auth suggest "send weekly report to manager" +# → スコープ推定が動く +``` + +--- + +## 主な操作例 + +```bash +# メール +garc gmail inbox --unread +garc gmail send --to boss@co.com --subject "週次レポート" --body "先週の進捗..." +garc gmail search "from:alice@co.com" --max 10 + +# カレンダー +garc calendar today +garc calendar week +garc calendar create --summary "MTG" --start "2026-04-16T14:00:00" --end "2026-04-16T15:00:00" --attendees alice@co.com +garc calendar freebusy --start 2026-04-16 --end 2026-04-17 --emails alice@co.com bob@co.com + +# Drive +garc drive list +garc drive search "議事録" --type doc +garc drive upload ./report.pdf --convert +garc drive create-doc "Meeting Notes 2026-04-15" + +# Sheets +garc sheets info +garc sheets read --range "memory!A:E" --format json +garc sheets search --sheet memory --query "経費" + +# メモリ +garc memory pull +garc memory push "顧客Aとの商談: 来週デモを実施することになった" +garc memory search "顧客A" + +# タスク +garc task list +garc task create "Q1レポートを作成" --due 2026-04-30 +garc task done <task_id> + +# 権限確認 +garc auth suggest "経費精算を申請してマネージャーに送る" +garc approve gate create_expense + +# エージェント登録 +garc agent register +garc agent list +``` + +--- + +## 設定ファイル + +`~/.garc/config.env`(`garc setup all` で自動生成): + +```bash +GARC_DRIVE_FOLDER_ID=1xxxxxxxxxxxxxxxxxxxxxxxxx +GARC_SHEETS_ID=1xxxxxxxxxxxxxxxxxxxxxxxxx +GARC_GMAIL_DEFAULT_TO=your@gmail.com +GARC_CALENDAR_ID=primary +GARC_DEFAULT_AGENT=main +``` + +--- + +## トラブルシューティング + +| エラー | 対処 | +|--------|------| +| `credentials.json not found` | Google Cloud ConsoleでOAuth認証情報をダウンロード | +| `Token refresh failed` | `garc auth login` で再認証 | +| `API not enabled` | Google Cloud ConsoleでAPIを有効化 | +| `403 insufficientPermissions` | `garc auth login --profile backoffice_agent` で再認証(スコープ追加) | +| `Sheets tab missing` | `garc setup sheets` でタブを再作成 | diff --git a/lib/agent.sh b/lib/agent.sh new file mode 100644 index 0000000..04b7933 --- /dev/null +++ b/lib/agent.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# GARC agent.sh — Agent registry management via Google Sheets + +garc_agent() { + local subcommand="${1:-help}" + shift || true + + case "${subcommand}" in + list) garc_agent_list "$@" ;; + register) garc_agent_register "$@" ;; + show) garc_agent_show "$@" ;; + *) + echo "Usage: garc agent <list|register|show>" + return 1 + ;; + esac +} + +# garc agent list +# Lists registered agents from Google Sheets +garc_agent_list() { + local sheets_id="${GARC_SHEETS_ID:-}" + if [[ -z "${sheets_id}" ]]; then + echo "Error: GARC_SHEETS_ID not set" >&2 + return 1 + fi + + echo "Registered agents (Sheets: ${sheets_id}):" + python3 "${GARC_DIR}/scripts/garc-sheets-helper.py" agent-list \ + --sheets-id "${sheets_id}" +} + +# garc agent register [--file <yaml_file>] +# Registers agents from agents.yaml into Google Sheets +garc_agent_register() { + local yaml_file="${GARC_DIR}/agents.yaml" + + while [[ $# -gt 0 ]]; do + case "$1" in + --file|-f) yaml_file="$2"; shift 2 ;; + *) shift ;; + esac + done + + if [[ ! -f "${yaml_file}" ]]; then + echo "Error: agents.yaml not found at ${yaml_file}" >&2 + return 1 + fi + + local sheets_id="${GARC_SHEETS_ID:-}" + if [[ -z "${sheets_id}" ]]; then + echo "Error: GARC_SHEETS_ID not set" >&2 + return 1 + fi + + echo "Registering agents from ${yaml_file}..." + python3 "${GARC_DIR}/scripts/garc-sheets-helper.py" agent-register \ + --sheets-id "${sheets_id}" \ + --yaml-file "${yaml_file}" +} + +# garc agent show <agent_id> +garc_agent_show() { + local agent_id="${1:-}" + + if [[ -z "${agent_id}" ]]; then + echo "Usage: garc agent show <agent_id>" + return 1 + fi + + python3 "${GARC_DIR}/scripts/garc-sheets-helper.py" agent-show \ + --sheets-id "${GARC_SHEETS_ID:-}" \ + --agent-id "${agent_id}" +} diff --git a/lib/approve.sh b/lib/approve.sh new file mode 100644 index 0000000..840125f --- /dev/null +++ b/lib/approve.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# GARC approve.sh — Execution gate and approval flow +# Uses Google Sheets as the approval tracking backend + +GATE_POLICY="${GARC_DIR}/config/gate-policy.json" + +garc_approve() { + local subcommand="${1:-help}" + shift || true + + case "${subcommand}" in + gate) garc_approve_gate "$@" ;; + list) garc_approve_list "$@" ;; + create) garc_approve_create "$@" ;; + act) garc_approve_act "$@" ;; + *) + echo "Usage: garc approve <gate|list|create|act>" + return 1 + ;; + esac +} + +# garc approve gate <task_type> +# Check the execution gate policy for a task type +garc_approve_gate() { + local task_type="${1:-}" + + if [[ -z "${task_type}" ]]; then + echo "Usage: garc approve gate <task_type>" + echo "" + echo "Available task types:" + python3 -c " +import json, sys +with open('${GATE_POLICY}') as f: + policy = json.load(f) +for gate, data in policy['gates'].items(): + print(f' [{gate.upper()}] {data[\"description\"]}') + for t in data['tasks']: + print(f' - {t}') +" + return 0 + fi + + python3 -c " +import json, sys +with open('${GATE_POLICY}') as f: + policy = json.load(f) + +task = '${task_type}' +for gate_name, gate_data in policy['gates'].items(): + if task in gate_data['tasks']: + icons = {'none': '✅', 'preview': '⚠️', 'approval': '🔒'} + icon = icons.get(gate_name, '❓') + print(f'{icon} Gate: {gate_name.upper()}') + print(f' {gate_data[\"description\"]}') + if gate_name == 'none': + print(f' Action: Execute immediately') + elif gate_name == 'preview': + print(f' Action: Add --confirm flag or get user acknowledgment') + else: + print(f' Action: Create approval request with: garc approve create \"{task}\"') + sys.exit(0) + +print(f'Task type \"{task}\" not found in gate policy') +sys.exit(1) +" +} + +# garc approve list +# List pending approval items from Google Sheets +garc_approve_list() { + local sheets_id="${GARC_SHEETS_ID:-}" + if [[ -z "${sheets_id}" ]]; then + echo "Error: GARC_SHEETS_ID not set" >&2 + return 1 + fi + + python3 "${GARC_DIR}/scripts/garc-sheets-helper.py" approval-list \ + --sheets-id "${sheets_id}" +} + +# garc approve create "<task>" +# Create an approval request in Google Sheets (and optionally send Gmail) +garc_approve_create() { + local task_description="$*" + + if [[ -z "${task_description}" ]]; then + echo "Usage: garc approve create \"<task description>\"" + return 1 + fi + + local sheets_id="${GARC_SHEETS_ID:-}" + if [[ -z "${sheets_id}" ]]; then + echo "Error: GARC_SHEETS_ID not set" >&2 + return 1 + fi + + local approval_id + approval_id="approval-$(date +%Y%m%d%H%M%S)-$$" + + echo "Creating approval request..." + echo " ID: ${approval_id}" + echo " Task: ${task_description}" + + python3 "${GARC_DIR}/scripts/garc-sheets-helper.py" approval-create \ + --sheets-id "${sheets_id}" \ + --approval-id "${approval_id}" \ + --task "${task_description}" \ + --agent-id "${GARC_DEFAULT_AGENT:-main}" \ + --timestamp "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + + # Optionally notify via Gmail + if [[ -n "${GARC_GMAIL_DEFAULT_TO:-}" ]]; then + python3 "${GARC_DIR}/scripts/garc-gmail-helper.py" send \ + --to "${GARC_GMAIL_DEFAULT_TO}" \ + --subject "[GARC Approval Required] ${task_description}" \ + --body "Approval Request ID: ${approval_id} +Task: ${task_description} +Agent: ${GARC_DEFAULT_AGENT:-main} +Time: $(date -u +"%Y-%m-%dT%H:%M:%SZ") + +To approve: garc approve act ${approval_id} --action approve +To reject: garc approve act ${approval_id} --action reject" 2>/dev/null || true + fi + + echo "✅ Approval request created: ${approval_id}" + echo " Waiting for human approval..." +} + +# garc approve act <id> --action <approve|reject> +garc_approve_act() { + local approval_id="${1:-}" + local action="" + + shift || true + while [[ $# -gt 0 ]]; do + case "$1" in + --action) action="$2"; shift 2 ;; + approve|reject) action="$1"; shift ;; + *) shift ;; + esac + done + + if [[ -z "${approval_id}" ]] || [[ -z "${action}" ]]; then + echo "Usage: garc approve act <id> --action <approve|reject>" + return 1 + fi + + python3 "${GARC_DIR}/scripts/garc-sheets-helper.py" approval-act \ + --sheets-id "${GARC_SHEETS_ID:-}" \ + --approval-id "${approval_id}" \ + --action "${action}" \ + --timestamp "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + + echo "✅ Approval ${approval_id} marked as: ${action}" +} diff --git a/lib/auth.sh b/lib/auth.sh new file mode 100644 index 0000000..800b256 --- /dev/null +++ b/lib/auth.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# GARC auth.sh — OAuth scope inference and authorization +# Mirrors LARC's auth.sh but for Google Workspace OAuth scopes + +GARC_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GARC_DIR="$(cd "${GARC_SCRIPT_DIR}/.." && pwd)" +SCOPE_MAP="${GARC_DIR}/config/scope-map.json" +AUTH_HELPER="${GARC_DIR}/scripts/garc-auth-helper.py" + +garc_auth() { + local subcommand="${1:-help}" + shift || true + + case "${subcommand}" in + suggest) garc_auth_suggest "$@" ;; + check) garc_auth_check "$@" ;; + login) garc_auth_login "$@" ;; + status) garc_auth_status "$@" ;; + *) + echo "Usage: garc auth <suggest|check|login|status>" + return 1 + ;; + esac +} + +# garc auth suggest "<task description>" +# Infers the minimum OAuth scopes required for the task +garc_auth_suggest() { + local task_description="$*" + + if [[ -z "${task_description}" ]]; then + echo "Usage: garc auth suggest \"<task description>\"" + echo "Example: garc auth suggest \"send expense report to manager\"" + return 1 + fi + + if ! command -v python3 &>/dev/null; then + echo "Error: python3 is required for scope inference" >&2 + return 1 + fi + + python3 "${AUTH_HELPER}" suggest "${task_description}" +} + +# garc auth check [--profile <profile>] +# Checks if current token has required scopes +garc_auth_check() { + local profile="backoffice_agent" + + while [[ $# -gt 0 ]]; do + case "$1" in + --profile) profile="$2"; shift 2 ;; + *) shift ;; + esac + done + + python3 "${AUTH_HELPER}" check --profile "${profile}" +} + +# garc auth login [--profile <profile>] +# Launches OAuth2 authorization flow +garc_auth_login() { + local profile="writer" + + while [[ $# -gt 0 ]]; do + case "$1" in + --profile) profile="$2"; shift 2 ;; + *) shift ;; + esac + done + + python3 "${AUTH_HELPER}" login --profile "${profile}" +} + +# garc auth status +# Shows current token scopes +garc_auth_status() { + python3 "${AUTH_HELPER}" status +} diff --git a/lib/bootstrap.sh b/lib/bootstrap.sh new file mode 100644 index 0000000..bfcd341 --- /dev/null +++ b/lib/bootstrap.sh @@ -0,0 +1,272 @@ +#!/usr/bin/env bash +# GARC bootstrap.sh — Disclosure chain loading from Google Drive +# Mirrors LARC's bootstrap.sh but uses Google Drive instead of Lark Drive + +GARC_DISCLOSURE_FILES=("SOUL.md" "USER.md" "MEMORY.md" "RULES.md" "HEARTBEAT.md") + +garc_init() { + echo "GARC v0.1.0 — Initializing workspace..." + + local config_dir="${HOME}/.garc" + mkdir -p "${config_dir}/cache/workspace" + + # Check for config file + if [[ ! -f "${config_dir}/config.env" ]]; then + echo "Config not found. Creating from example..." + cp "${GARC_DIR}/config/config.env.example" "${config_dir}/config.env" + echo "✅ Created ${config_dir}/config.env" + echo "" + echo "Next steps:" + echo " 1. Edit ~/.garc/config.env with your Google Drive folder ID and Sheets ID" + echo " 2. Download OAuth2 credentials.json from Google Cloud Console" + echo " 3. Save to ~/.garc/credentials.json" + echo " 4. Run: garc auth login --profile backoffice_agent" + echo " 5. Run: garc bootstrap --agent main" + return 0 + fi + + # Validate required config + source "${config_dir}/config.env" + + local missing=() + [[ -z "${GARC_DRIVE_FOLDER_ID:-}" ]] && missing+=("GARC_DRIVE_FOLDER_ID") + [[ -z "${GARC_SHEETS_ID:-}" ]] && missing+=("GARC_SHEETS_ID") + + if [[ ${#missing[@]} -gt 0 ]]; then + echo "⚠️ Missing required config values in ~/.garc/config.env:" + for key in "${missing[@]}"; do + echo " - ${key}" + done + return 1 + fi + + echo "✅ Config OK" + echo " Drive folder: ${GARC_DRIVE_FOLDER_ID}" + echo " Sheets ID: ${GARC_SHEETS_ID}" + + # Verify auth token + if [[ -f "${GARC_TOKEN_FILE:-${config_dir}/token.json}" ]]; then + echo "✅ Auth token present" + else + echo "⚠️ No auth token. Run: garc auth login --profile backoffice_agent" + fi + + echo "" + echo "Workspace initialized. Run 'garc bootstrap --agent main' to load context." +} + +garc_bootstrap() { + local agent_id="${GARC_DEFAULT_AGENT:-main}" + + while [[ $# -gt 0 ]]; do + case "$1" in + --agent) agent_id="$2"; shift 2 ;; + *) shift ;; + esac + done + + echo "Bootstrapping agent '${agent_id}' from Google Drive..." + + local workspace_dir="${GARC_CACHE_DIR}/workspace/${agent_id}" + mkdir -p "${workspace_dir}/memory" + + local drive_folder="${GARC_DRIVE_FOLDER_ID:-}" + if [[ -z "${drive_folder}" ]]; then + echo "Error: GARC_DRIVE_FOLDER_ID not set in ~/.garc/config.env" >&2 + return 1 + fi + + # Download disclosure chain files from Google Drive + echo "Downloading disclosure chain from Drive folder: ${drive_folder}" + + for filename in "${GARC_DISCLOSURE_FILES[@]}"; do + local local_path="${workspace_dir}/${filename}" + echo -n " ${filename}... " + + if python3 "${GARC_DIR}/scripts/garc-drive-helper.py" download \ + --folder-id "${drive_folder}" \ + --filename "${filename}" \ + --output "${local_path}" 2>/dev/null; then + echo "✅" + else + echo "⚠️ (not found, creating placeholder)" + _garc_create_placeholder "${local_path}" "${filename}" "${agent_id}" + fi + done + + # Download today's daily memory if present + local today + today=$(date +%Y-%m-%d) + local daily_memory="${workspace_dir}/memory/${today}.md" + echo -n " memory/${today}.md... " + if python3 "${GARC_DIR}/scripts/garc-drive-helper.py" download \ + --folder-id "${drive_folder}" \ + --filename "memory/${today}.md" \ + --output "${daily_memory}" 2>/dev/null; then + echo "✅" + else + echo "(none)" + fi + + # Build consolidated AGENT_CONTEXT.md + _garc_build_context "${workspace_dir}" "${agent_id}" + + echo "" + echo "✅ Bootstrap complete." + echo " Context: ${workspace_dir}/AGENT_CONTEXT.md" + echo "" + cat "${workspace_dir}/AGENT_CONTEXT.md" | head -20 + echo " [... $(wc -l < "${workspace_dir}/AGENT_CONTEXT.md") lines total]" +} + +_garc_create_placeholder() { + local filepath="$1" + local filename="$2" + local agent_id="$3" + + case "${filename}" in + SOUL.md) + cat > "${filepath}" <<EOF +# SOUL — Agent Identity + +agent_id: ${agent_id} +platform: Google Workspace +runtime: GARC v0.1.0 + +## Core Principles +- Permission-first: always check scopes before acting +- Transparency: explain actions before executing +- Gate compliance: respect none/preview/approval tiers + +## Created +$(date -u +"%Y-%m-%dT%H:%M:%SZ") (placeholder — upload your SOUL.md to Google Drive) +EOF + ;; + USER.md) + cat > "${filepath}" <<EOF +# USER — User Profile + +## Identity +name: (set in Google Drive) +email: ${GARC_GMAIL_DEFAULT_TO:-not set} + +## Preferences +language: Japanese / English +timezone: Asia/Tokyo + +## Created +$(date -u +"%Y-%m-%dT%H:%M:%SZ") (placeholder — upload your USER.md to Google Drive) +EOF + ;; + MEMORY.md) + cat > "${filepath}" <<EOF +# MEMORY — Long-term Memory Index + +Last sync: $(date -u +"%Y-%m-%dT%H:%M:%SZ") +Backend: Google Sheets (${GARC_SHEETS_ID:-not configured}) + +## Recent entries +(pull from Sheets with: garc memory pull) +EOF + ;; + RULES.md) + cat > "${filepath}" <<EOF +# RULES — Operating Rules + +1. Always run 'garc auth suggest' before new task categories +2. Respect execution gate policies (none/preview/approval) +3. Confirm with user before preview-gated operations +4. Wait for approval before approval-gated operations +5. Log all actions to heartbeat table +EOF + ;; + HEARTBEAT.md) + cat > "${filepath}" <<EOF +# HEARTBEAT — System State + +agent_id: ${agent_id} +last_bootstrap: $(date -u +"%Y-%m-%dT%H:%M:%SZ") +status: initialized +platform: Google Workspace +sheets_id: ${GARC_SHEETS_ID:-not configured} +EOF + ;; + esac +} + +_garc_build_context() { + local workspace_dir="$1" + local agent_id="$2" + local context_file="${workspace_dir}/AGENT_CONTEXT.md" + + cat > "${context_file}" <<EOF +# AGENT_CONTEXT — ${agent_id} +# Built: $(date -u +"%Y-%m-%dT%H:%M:%SZ") +# Source: Google Drive folder ${GARC_DRIVE_FOLDER_ID:-unknown} +# ════════════════════════════════════════════════════════ + +EOF + + for filename in "${GARC_DISCLOSURE_FILES[@]}"; do + local filepath="${workspace_dir}/${filename}" + if [[ -f "${filepath}" ]]; then + echo "## ${filename}" >> "${context_file}" + echo "" >> "${context_file}" + cat "${filepath}" >> "${context_file}" + echo "" >> "${context_file}" + echo "---" >> "${context_file}" + echo "" >> "${context_file}" + fi + done + + # Append daily memory if present + local today + today=$(date +%Y-%m-%d) + local daily_memory="${workspace_dir}/memory/${today}.md" + if [[ -f "${daily_memory}" ]]; then + echo "## Daily Memory (${today})" >> "${context_file}" + echo "" >> "${context_file}" + cat "${daily_memory}" >> "${context_file}" + echo "" >> "${context_file}" + fi +} + +garc_status() { + echo "GARC Status" + echo "===========" + + local config_file="${HOME}/.garc/config.env" + if [[ -f "${config_file}" ]]; then + source "${config_file}" + echo "Config: ✅ ${config_file}" + echo " Drive folder: ${GARC_DRIVE_FOLDER_ID:-❌ not set}" + echo " Sheets ID: ${GARC_SHEETS_ID:-❌ not set}" + echo " Gmail to: ${GARC_GMAIL_DEFAULT_TO:-❌ not set}" + echo " Calendar: ${GARC_CALENDAR_ID:-primary}" + else + echo "Config: ❌ not found (run: garc init)" + return 1 + fi + + local token_file="${GARC_TOKEN_FILE:-${HOME}/.garc/token.json}" + if [[ -f "${token_file}" ]]; then + echo "Auth token: ✅ ${token_file}" + else + echo "Auth token: ❌ not found (run: garc auth login)" + fi + + local creds_file="${GARC_CREDENTIALS_FILE:-${HOME}/.garc/credentials.json}" + if [[ -f "${creds_file}" ]]; then + echo "Credentials: ✅ ${creds_file}" + else + echo "Credentials: ❌ not found (download from Google Cloud Console)" + fi + + local agent_id="${GARC_DEFAULT_AGENT:-main}" + local workspace_dir="${GARC_CACHE_DIR:-${HOME}/.garc/cache}/workspace/${agent_id}" + if [[ -f "${workspace_dir}/AGENT_CONTEXT.md" ]]; then + echo "Context: ✅ ${workspace_dir}/AGENT_CONTEXT.md" + else + echo "Context: ❌ not bootstrapped (run: garc bootstrap --agent ${agent_id})" + fi +} diff --git a/lib/calendar.sh b/lib/calendar.sh new file mode 100644 index 0000000..50e4e6b --- /dev/null +++ b/lib/calendar.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +# GARC calendar.sh — Full Google Calendar operations +# list / create / update / delete / get / freebusy / quick-add / calendars + +CAL_HELPER="${GARC_DIR}/scripts/garc-calendar-helper.py" + +garc_calendar() { + local subcommand="${1:-help}" + shift || true + + case "${subcommand}" in + list) garc_calendar_list "$@" ;; + create) garc_calendar_create "$@" ;; + update) garc_calendar_update "$@" ;; + delete) garc_calendar_delete "$@" ;; + get) garc_calendar_get "$@" ;; + freebusy) garc_calendar_freebusy "$@" ;; + quick-add) garc_calendar_quick_add "$@" ;; + calendars) garc_calendar_list_cals "$@" ;; + today) garc_calendar_today "$@" ;; + week) garc_calendar_week "$@" ;; + *) + cat <<EOF +Usage: garc calendar <subcommand> [options] + +Subcommands: + list [--days N] [--back N] [--calendar <id>] [--query <text>] + today (events for today) + week (events for this week) + create --summary <text> --start <datetime> --end <datetime> [--description <text>] + [--location <text>] [--attendees email1 email2] [--all-day] [--recurrence <RRULE>] + update <event_id> [--summary <text>] [--start <dt>] [--end <dt>] [--add-attendees email...] + delete <event_id> + get <event_id> + freebusy --start <date> --end <date> --emails email1 [email2 ...] + quick-add "<natural language text>" + calendars (list all calendars) + +Examples: + garc calendar today + garc calendar list --days 14 + garc calendar create --summary "Team Standup" --start "2026-04-16T10:00:00" --end "2026-04-16T10:30:00" --attendees alice@co.com bob@co.com + garc calendar freebusy --start 2026-04-16 --end 2026-04-17 --emails alice@co.com bob@co.com + garc calendar quick-add "Lunch with Alice tomorrow at noon" +EOF + return 1 + ;; + esac +} + +garc_calendar_list() { + local days=7 back=0 calendar="primary" query="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --days|-n) days="$2"; shift 2 ;; + --back) back="$2"; shift 2 ;; + --calendar|-c) calendar="$2"; shift 2 ;; + --query|-q) query="$2"; shift 2 ;; + --max) shift 2 ;; # ignored, use default + *) shift ;; + esac + done + + python3 "${CAL_HELPER}" list \ + --calendar "${calendar}" \ + --days "${days}" \ + --back "${back}" \ + ${query:+--query "${query}"} +} + +garc_calendar_today() { + python3 "${CAL_HELPER}" list --calendar primary --days 1 --back 0 +} + +garc_calendar_week() { + local back=0 + # Calculate days until end of week + local day_of_week + day_of_week=$(date +%u) # 1=Mon, 7=Sun + local days_left=$(( 7 - day_of_week + 1 )) + python3 "${CAL_HELPER}" list --calendar primary --days "${days_left}" --back "${day_of_week}" +} + +garc_calendar_create() { + local summary="" start="" end="" description="" location="" timezone="Asia/Tokyo" + local all_day="" recurrence="" no_notify="" calendar="primary" + local attendees=() + + while [[ $# -gt 0 ]]; do + case "$1" in + --summary|-s) summary="$2"; shift 2 ;; + --start) start="$2"; shift 2 ;; + --end) end="$2"; shift 2 ;; + --description|-d) description="$2"; shift 2 ;; + --location|-l) location="$2"; shift 2 ;; + --attendees) shift + while [[ $# -gt 0 ]] && [[ "$1" != --* ]]; do + attendees+=("$1"); shift + done ;; + --all-day) all_day="--all-day"; shift ;; + --recurrence) recurrence="$2"; shift 2 ;; + --no-notify) no_notify="--no-notify"; shift ;; + --calendar|-c) calendar="$2"; shift 2 ;; + --timezone|-tz) timezone="$2"; shift 2 ;; + *) shift ;; + esac + done + + [[ -z "${summary}" ]] || [[ -z "${start}" ]] || [[ -z "${end}" ]] && { + echo "Usage: garc calendar create --summary <text> --start <datetime> --end <datetime>" + return 1 + } + + # Gate check for calendar write + if [[ "${DRY_RUN:-false}" == "true" ]]; then + echo "[dry-run] Would create event: ${summary} (${start} → ${end})" + return 0 + fi + + python3 "${CAL_HELPER}" create \ + --summary "${summary}" \ + --start "${start}" \ + --end "${end}" \ + --calendar "${calendar}" \ + --timezone "${timezone}" \ + ${description:+--description "${description}"} \ + ${location:+--location "${location}"} \ + ${all_day} \ + ${no_notify} \ + ${recurrence:+--recurrence "${recurrence}"} \ + ${attendees[@]:+--attendees "${attendees[@]}"} +} + +garc_calendar_update() { + local event_id="${1:-}" + shift || true + + [[ -z "${event_id}" ]] && { echo "Usage: garc calendar update <event_id> [--summary ...] [--start ...] [--end ...]"; return 1; } + + python3 "${CAL_HELPER}" update "${event_id}" "$@" +} + +garc_calendar_delete() { + local event_id="${1:-}" + local calendar="primary" + + [[ -z "${event_id}" ]] && { echo "Usage: garc calendar delete <event_id>"; return 1; } + + echo "⚠️ Delete event ${event_id}? [y/N]" + read -r confirm + [[ "${confirm}" != "y" ]] && echo "Cancelled." && return 0 + + python3 "${CAL_HELPER}" delete "${event_id}" --calendar "${calendar}" +} + +garc_calendar_get() { + [[ -z "${1:-}" ]] && { echo "Usage: garc calendar get <event_id>"; return 1; } + python3 "${CAL_HELPER}" get "$1" +} + +garc_calendar_freebusy() { + local start="" end="" timezone="Asia/Tokyo" + local emails=() + + while [[ $# -gt 0 ]]; do + case "$1" in + --start) start="$2"; shift 2 ;; + --end) end="$2"; shift 2 ;; + --timezone|-tz) timezone="$2"; shift 2 ;; + --emails) shift + while [[ $# -gt 0 ]] && [[ "$1" != --* ]]; do + emails+=("$1"); shift + done ;; + *) shift ;; + esac + done + + [[ -z "${start}" ]] || [[ ${#emails[@]} -eq 0 ]] && { + echo "Usage: garc calendar freebusy --start <date> --end <date> --emails email1 [email2 ...]" + return 1 + } + + [[ -z "${end}" ]] && end="${start}" + + python3 "${CAL_HELPER}" freebusy \ + --start "${start}" \ + --end "${end}" \ + --timezone "${timezone}" \ + --emails "${emails[@]}" +} + +garc_calendar_quick_add() { + local text="$*" + [[ -z "${text}" ]] && { echo "Usage: garc calendar quick-add \"<text>\""; return 1; } + python3 "${CAL_HELPER}" quick-add "${text}" +} + +garc_calendar_list_cals() { + python3 "${CAL_HELPER}" calendars +} diff --git a/lib/daemon.sh b/lib/daemon.sh new file mode 100644 index 0000000..bd64942 --- /dev/null +++ b/lib/daemon.sh @@ -0,0 +1,433 @@ +#!/usr/bin/env bash +# GARC daemon.sh — Background polling daemon +# +# Polls Gmail inbox for new messages and auto-enqueues them. +# This is the GWS equivalent of LARC's IM poller. +# +# Usage: +# garc daemon start — start Gmail poller + worker in background +# garc daemon stop — stop all daemon processes +# garc daemon status — show running daemon info +# garc daemon poll-once — single poll cycle (for testing) + +DAEMON_PID_DIR="${GARC_CACHE_DIR:-${HOME}/.garc/cache}/daemon" +DAEMON_LOG_DIR="${GARC_CACHE_DIR:-${HOME}/.garc/cache}/logs" +DAEMON_SEEN_DIR="${GARC_CACHE_DIR:-${HOME}/.garc/cache}/seen" + +GMAIL_POLLER_PID="${DAEMON_PID_DIR}/gmail-poller.pid" +GMAIL_POLLER_LOG="${DAEMON_LOG_DIR}/gmail-poller.log" +WORKER_PID="${DAEMON_PID_DIR}/worker.pid" +WORKER_LOG="${DAEMON_LOG_DIR}/worker.log" + +garc_daemon() { + local subcommand="${1:-help}" + shift || true + + case "${subcommand}" in + start) _daemon_start "$@" ;; + stop) _daemon_stop "$@" ;; + status) _daemon_status "$@" ;; + restart) _daemon_stop "$@"; sleep 1; _daemon_start "$@" ;; + poll-once) _daemon_poll_once "$@" ;; + logs) _daemon_logs "$@" ;; + install) _daemon_install_launchd "$@" ;; + *) + cat <<EOF +Usage: garc daemon <subcommand> + +Subcommands: + start Start Gmail poller and worker in background + stop Stop all daemon processes + status Show daemon status + restart Restart daemon + poll-once Run one Gmail poll cycle (foreground, for testing) + logs Tail daemon logs + install Install as macOS launchd service (auto-start on login) + +Options: + --agent <id> Agent ID to use (default: GARC_DEFAULT_AGENT) + --interval <sec> Poll interval in seconds (default: 60) + --label <filter> Gmail label to watch (default: INBOX) + --unread-only Only enqueue unread messages (default: true) + --max <N> Max messages per poll cycle (default: 10) + +Examples: + garc daemon start + garc daemon start --interval 30 --agent main + garc daemon poll-once + garc daemon status + garc daemon stop + garc daemon logs --follow +EOF + return 1 + ;; + esac +} + +# ───────────────────────────────────────────────────────────────── +# start +# ───────────────────────────────────────────────────────────────── + +_daemon_start() { + local agent="${GARC_DEFAULT_AGENT:-main}" + local interval=60 + local label="INBOX" + local max_msgs=10 + + while [[ $# -gt 0 ]]; do + case "$1" in + --agent|-a) agent="$2"; shift 2 ;; + --interval|-i) interval="$2"; shift 2 ;; + --label) label="$2"; shift 2 ;; + --max) max_msgs="$2"; shift 2 ;; + *) shift ;; + esac + done + + _daemon_ensure_dirs + + # Check if already running + if _daemon_is_running "${GMAIL_POLLER_PID}"; then + echo "⚠️ Gmail poller already running (PID $(cat "${GMAIL_POLLER_PID}"))" + else + _start_gmail_poller "${agent}" "${interval}" "${label}" "${max_msgs}" + fi + + echo "" + _daemon_status +} + +_daemon_ensure_dirs() { + mkdir -p "${DAEMON_PID_DIR}" "${DAEMON_LOG_DIR}" "${DAEMON_SEEN_DIR}" +} + +_daemon_is_running() { + local pid_file="$1" + [[ -f "${pid_file}" ]] || return 1 + local pid + pid=$(cat "${pid_file}") + kill -0 "${pid}" 2>/dev/null +} + +_start_gmail_poller() { + local agent_id="$1" + local interval="$2" + local label="$3" + local max_msgs="$4" + + # Export needed env vars for subprocess + export GARC_DIR GARC_LIB GARC_CONFIG GARC_CACHE_DIR GARC_DEFAULT_AGENT + [[ -f "${GARC_CONFIG}/config.env" ]] && export $(grep -v '^#' "${GARC_CONFIG}/config.env" | xargs) 2>/dev/null || true + + ( _gmail_poller_loop "${agent_id}" "${interval}" "${label}" "${max_msgs}" \ + >> "${GMAIL_POLLER_LOG}" 2>&1 ) & + + local poller_pid=$! + echo "${poller_pid}" > "${GMAIL_POLLER_PID}" + echo "✅ Gmail poller started (PID ${poller_pid}, interval ${interval}s)" + echo " Log: ${GMAIL_POLLER_LOG}" +} + +# ───────────────────────────────────────────────────────────────── +# Gmail polling loop — the core ingress driver +# ───────────────────────────────────────────────────────────────── + +_gmail_poller_loop() { + local agent_id="${1:-main}" + local interval="${2:-60}" + local label="${3:-INBOX}" + local max_msgs="${4:-10}" + + local seen_file="${DAEMON_SEEN_DIR}/seen-${agent_id}.txt" + touch "${seen_file}" + + echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] [gmail-poller] Starting (agent=${agent_id}, interval=${interval}s, label=${label})" + + # Reload config + [[ -f "${GARC_CONFIG:-${HOME}/.garc}/config.env" ]] && \ + source "${GARC_CONFIG:-${HOME}/.garc}/config.env" 2>/dev/null || true + + while true; do + # ── Fetch recent unread emails ─────────────────────────────── + local raw_msgs fetch_ok + raw_msgs=$(python3 "${GARC_DIR}/scripts/garc-gmail-helper.py" inbox \ + --max "${max_msgs}" --unread 2>/dev/null) && fetch_ok=1 || fetch_ok=0 + + if [[ "${fetch_ok}" -eq 0 ]] || [[ -z "${raw_msgs}" ]]; then + echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] [gmail-poller] fetch failed, retrying in ${interval}s" + sleep "${interval}" + continue + fi + + # ── Parse and enqueue new messages ─────────────────────────── + python3 - "${seen_file}" "${agent_id}" <<'PY' +import json, sys, subprocess, os, re + +seen_file = sys.argv[1] +agent_id = sys.argv[2] +garc_dir = os.environ.get("GARC_DIR", "") +garc_lib = os.environ.get("GARC_LIB", "") + +# Read seen message IDs +try: + with open(seen_file) as f: + seen = set(line.strip() for line in f if line.strip()) +except Exception: + seen = set() + +# Parse inbox output (table format from gmail helper) +# Format: ID | FROM | SUBJECT | DATE | SNIPPET +raw = sys.stdin.read() if not sys.stdin.isatty() else "" + +# Actually re-fetch as JSON for reliable parsing +result = subprocess.run( + ["python3", os.path.join(garc_dir, "scripts", "garc-gmail-helper.py"), + "inbox", "--max", "10", "--unread", "--format", "json"], + capture_output=True, text=True +) +if result.returncode != 0: + print(f"[gmail-poller] inbox fetch error: {result.stderr.strip()}", flush=True) + sys.exit(0) + +try: + messages = json.loads(result.stdout) +except Exception as e: + print(f"[gmail-poller] JSON parse error: {e}", flush=True) + sys.exit(0) + +if not isinstance(messages, list): + messages = [] + +new_seen = [] +for msg in messages: + msg_id = msg.get("id", "") + sender = msg.get("from", "") + subject = msg.get("subject", "(no subject)") + snippet = msg.get("snippet", "")[:120] + + if not msg_id or msg_id in seen: + new_seen.append(msg_id) + continue + + # Build a human-readable task description + text = f"Email from {sender}: {subject}" + if snippet: + text += f" — {snippet}" + + cmd = [ + "garc", "ingress", "enqueue", + "--text", text, + "--source", "gmail", + "--sender", sender, + "--agent", agent_id, + ] + # Use larc path + garc_bin = os.path.join(garc_dir, "bin", "garc") + cmd[0] = garc_bin + + env = os.environ.copy() + env["GARC_DIR"] = garc_dir + + r = subprocess.run(cmd, capture_output=True, text=True, env=env) + if r.returncode == 0: + print(f"[gmail-poller] Enqueued: {msg_id[:16]} from {sender[:30]}", flush=True) + else: + print(f"[gmail-poller] Enqueue failed: {r.stderr.strip()}", flush=True) + + new_seen.append(msg_id) + +if new_seen: + with open(seen_file, "a") as f: + f.write("\n".join(new_seen) + "\n") +PY + + sleep "${interval}" + done +} + +# ───────────────────────────────────────────────────────────────── +# stop +# ───────────────────────────────────────────────────────────────── + +_daemon_stop() { + local stopped=0 + + for pid_file in "${GMAIL_POLLER_PID}" "${WORKER_PID}"; do + if _daemon_is_running "${pid_file}"; then + local pid + pid=$(cat "${pid_file}") + kill "${pid}" 2>/dev/null && { + echo "✅ Stopped PID ${pid} ($(basename "${pid_file}" .pid))" + ((stopped++)) || true + } + fi + rm -f "${pid_file}" + done + + if [[ ${stopped} -eq 0 ]]; then + echo "No daemon processes running." + fi +} + +# ───────────────────────────────────────────────────────────────── +# status +# ───────────────────────────────────────────────────────────────── + +_daemon_status() { + echo "GARC Daemon Status" + echo "──────────────────" + + local name pid_file + for entry in "gmail-poller:${GMAIL_POLLER_PID}" "worker:${WORKER_PID}"; do + name="${entry%%:*}" + pid_file="${entry#*:}" + if _daemon_is_running "${pid_file}"; then + local pid + pid=$(cat "${pid_file}") + echo " ✅ ${name} — running (PID ${pid})" + else + echo " ⬜ ${name} — stopped" + fi + done + + echo "" + + # Queue stats + local q_dir="${GARC_QUEUE_DIR:-${HOME}/.garc/cache/queue}" + if [[ -d "${q_dir}" ]]; then + local pending + pending=$(find "${q_dir}" -name "*.jsonl" -exec python3 -c " +import json, sys +try: + q = json.loads(open(sys.argv[1]).readline()) + print(q.get('status','')) +except Exception: + pass +" {} \; 2>/dev/null | grep -c "^pending$" || echo 0) + echo " Queue: ${pending} pending item(s)" + fi + + echo "" + echo "Logs:" + echo " ${GMAIL_POLLER_LOG}" + echo " ${WORKER_LOG}" +} + +# ───────────────────────────────────────────────────────────────── +# poll-once — single cycle, foreground (for testing / manual trigger) +# ───────────────────────────────────────────────────────────────── + +_daemon_poll_once() { + local agent="${GARC_DEFAULT_AGENT:-main}" + local max_msgs=10 + + while [[ $# -gt 0 ]]; do + case "$1" in + --agent|-a) agent="$2"; shift 2 ;; + --max) max_msgs="$2"; shift 2 ;; + *) shift ;; + esac + done + + _daemon_ensure_dirs + + echo "🔍 Polling Gmail inbox (agent=${agent}, max=${max_msgs})..." + _gmail_poller_loop "${agent}" "0" "INBOX" "${max_msgs}" & + local pid=$! + # Wait a moment for one cycle to complete then stop + sleep 5 + kill "${pid}" 2>/dev/null || true + echo "" + echo "Poll cycle complete. Check: garc ingress list" +} + +# ───────────────────────────────────────────────────────────────── +# logs +# ───────────────────────────────────────────────────────────────── + +_daemon_logs() { + local follow=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --follow|-f) follow=true; shift ;; + *) shift ;; + esac + done + + if [[ "${follow}" == "true" ]]; then + tail -f "${GMAIL_POLLER_LOG}" "${WORKER_LOG}" 2>/dev/null + else + echo "=== Gmail Poller (last 30 lines) ===" + tail -30 "${GMAIL_POLLER_LOG}" 2>/dev/null || echo "(no log yet)" + echo "" + echo "=== Worker (last 30 lines) ===" + tail -30 "${WORKER_LOG}" 2>/dev/null || echo "(no log yet)" + fi +} + +# ───────────────────────────────────────────────────────────────── +# install — macOS launchd plist for auto-start on login +# ───────────────────────────────────────────────────────────────── + +_daemon_install_launchd() { + local agent="${GARC_DEFAULT_AGENT:-main}" + local interval=60 + local label="com.garc.gmail-poller" + local plist_path="${HOME}/Library/LaunchAgents/${label}.plist" + + while [[ $# -gt 0 ]]; do + case "$1" in + --agent|-a) agent="$2"; shift 2 ;; + --interval|-i) interval="$2"; shift 2 ;; + *) shift ;; + esac + done + + _daemon_ensure_dirs + + local garc_bin="${GARC_DIR}/bin/garc" + + cat > "${plist_path}" <<EOF +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" + "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>Label</key> + <string>${label}</string> + <key>ProgramArguments</key> + <array> + <string>${garc_bin}</string> + <string>daemon</string> + <string>poll-once</string> + <string>--agent</string> + <string>${agent}</string> + </array> + <key>StartInterval</key> + <integer>${interval}</integer> + <key>EnvironmentVariables</key> + <dict> + <key>GARC_DIR</key> + <string>${GARC_DIR}</string> + <key>PATH</key> + <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string> + </dict> + <key>StandardOutPath</key> + <string>${GMAIL_POLLER_LOG}</string> + <key>StandardErrorPath</key> + <string>${GMAIL_POLLER_LOG}</string> + <key>RunAtLoad</key> + <false/> +</dict> +</plist> +EOF + + echo "✅ Installed launchd plist: ${plist_path}" + echo "" + echo "To activate:" + echo " launchctl load ${plist_path}" + echo "" + echo "To unload:" + echo " launchctl unload ${plist_path}" +} diff --git a/lib/drive.sh b/lib/drive.sh new file mode 100644 index 0000000..f6206c2 --- /dev/null +++ b/lib/drive.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash +# GARC drive.sh — Full Google Drive operations +# list / search / info / download / upload / create-folder / create-doc / share / move / delete + +DRIVE_HELPER="${GARC_DIR}/scripts/garc-drive-helper.py" + +garc_drive() { + local subcommand="${1:-help}" + shift || true + + case "${subcommand}" in + list) garc_drive_list "$@" ;; + search) garc_drive_search "$@" ;; + info) garc_drive_info "$@" ;; + download) garc_drive_download "$@" ;; + upload) garc_drive_upload "$@" ;; + create-folder) garc_drive_create_folder "$@" ;; + create-doc) garc_drive_create_doc "$@" ;; + share) garc_drive_share "$@" ;; + move) garc_drive_move "$@" ;; + delete) garc_drive_delete "$@" ;; + *) + cat <<EOF +Usage: garc drive <subcommand> [options] + +Subcommands: + list [--folder-id <id>] [--max N] [--query <name>] + search <query> [--max N] [--type doc|sheet|slide|folder|pdf] + info <file_id> + download --file-id <id> | --folder-id <id> --filename <name> [--output <path>] + upload <local_path> [--folder-id <id>] [--name <name>] [--convert] + create-folder <name> [--parent-id <id>] + create-doc <name> [--folder-id <id>] [--content <text>] + share <file_id> --email <email> [--role reader|writer|commenter] + move <file_id> --to <folder_id> + delete <file_id> [--permanent] + +Examples: + garc drive list --folder-id 1xxxxxxxxx + garc drive search "Q1 report" --type doc + garc drive upload ./report.pdf --folder-id 1xxxxxxxxx --convert + garc drive create-doc "Meeting Notes 2026-04-15" --folder-id 1xxxxxxxxx + garc drive share 1xxxxxxxxx --email colleague@co.com --role writer +EOF + return 1 + ;; + esac +} + +garc_drive_list() { + local folder_id="${GARC_DRIVE_FOLDER_ID:-root}" max=50 query="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --folder-id|-f) folder_id="$2"; shift 2 ;; + --max|-n) max="$2"; shift 2 ;; + --query|-q) query="$2"; shift 2 ;; + *) shift ;; + esac + done + + python3 "${DRIVE_HELPER}" list \ + --folder-id "${folder_id}" \ + --max "${max}" \ + ${query:+--query "${query}"} +} + +garc_drive_search() { + local query="" max=30 type="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --max|-n) max="$2"; shift 2 ;; + --type|-t) type="$2"; shift 2 ;; + *) query="${query:+${query} }$1"; shift ;; + esac + done + + [[ -z "${query}" ]] && { echo "Usage: garc drive search <query> [--type doc|sheet|slide|folder|pdf]"; return 1; } + + python3 "${DRIVE_HELPER}" search "${query}" --max "${max}" ${type:+--type "${type}"} +} + +garc_drive_info() { + [[ -z "${1:-}" ]] && { echo "Usage: garc drive info <file_id>"; return 1; } + python3 "${DRIVE_HELPER}" info "$1" +} + +garc_drive_download() { + local file_id="" folder_id="" filename="" output="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --file-id) file_id="$2"; shift 2 ;; + --folder-id) folder_id="$2"; shift 2 ;; + --filename) filename="$2"; shift 2 ;; + --output|-o) output="$2"; shift 2 ;; + *) shift ;; + esac + done + + python3 "${DRIVE_HELPER}" download \ + ${file_id:+--file-id "${file_id}"} \ + ${folder_id:+--folder-id "${folder_id}"} \ + ${filename:+--filename "${filename}"} \ + ${output:+--output "${output}"} +} + +garc_drive_upload() { + local local_path="${1:-}" + shift || true + local folder_id="${GARC_DRIVE_FOLDER_ID:-root}" name="" convert="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --folder-id|-f) folder_id="$2"; shift 2 ;; + --name|-n) name="$2"; shift 2 ;; + --convert) convert="--convert"; shift ;; + *) shift ;; + esac + done + + [[ -z "${local_path}" ]] && { echo "Usage: garc drive upload <local_path> [--folder-id <id>] [--convert]"; return 1; } + + python3 "${DRIVE_HELPER}" upload "${local_path}" \ + --folder-id "${folder_id}" \ + ${name:+--name "${name}"} \ + ${convert} +} + +garc_drive_create_folder() { + local name="${1:-}" parent_id="${GARC_DRIVE_FOLDER_ID:-root}" + + while [[ $# -gt 0 ]]; do + case "$1" in + --parent-id|-p) parent_id="$2"; shift 2 ;; + *) name="${name:-$1}"; shift ;; + esac + done + + [[ -z "${name}" ]] && { echo "Usage: garc drive create-folder <name> [--parent-id <id>]"; return 1; } + + python3 "${DRIVE_HELPER}" create-folder "${name}" --parent-id "${parent_id}" +} + +garc_drive_create_doc() { + local name="${1:-}" folder_id="${GARC_DRIVE_FOLDER_ID:-root}" content="" + shift || true + + while [[ $# -gt 0 ]]; do + case "$1" in + --folder-id|-f) folder_id="$2"; shift 2 ;; + --content|-c) content="$2"; shift 2 ;; + *) name="${name:-$1}"; shift ;; + esac + done + + [[ -z "${name}" ]] && { echo "Usage: garc drive create-doc <name> [--folder-id <id>] [--content <text>]"; return 1; } + + python3 "${DRIVE_HELPER}" create-doc "${name}" \ + --folder-id "${folder_id}" \ + ${content:+--content "${content}"} +} + +garc_drive_share() { + local file_id="${1:-}" + shift || true + local email="" role="reader" no_notify="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --email|-e) email="$2"; shift 2 ;; + --role|-r) role="$2"; shift 2 ;; + --no-notify) no_notify="--no-notify"; shift ;; + *) shift ;; + esac + done + + [[ -z "${file_id}" ]] || [[ -z "${email}" ]] && { + echo "Usage: garc drive share <file_id> --email <email> [--role reader|writer|commenter]" + return 1 + } + + python3 "${DRIVE_HELPER}" share "${file_id}" \ + --email "${email}" \ + --role "${role}" \ + ${no_notify} +} + +garc_drive_move() { + local file_id="${1:-}" new_folder_id="" + shift || true + + while [[ $# -gt 0 ]]; do + case "$1" in + --to) new_folder_id="$2"; shift 2 ;; + *) shift ;; + esac + done + + [[ -z "${file_id}" ]] || [[ -z "${new_folder_id}" ]] && { + echo "Usage: garc drive move <file_id> --to <folder_id>" + return 1 + } + + python3 "${DRIVE_HELPER}" move "${file_id}" --to "${new_folder_id}" +} + +garc_drive_delete() { + local file_id="${1:-}" permanent="" + shift || true + + [[ "${1:-}" == "--permanent" ]] && permanent="--permanent" + + [[ -z "${file_id}" ]] && { echo "Usage: garc drive delete <file_id> [--permanent]"; return 1; } + + if [[ -z "${permanent}" ]]; then + echo "Move to trash: ${file_id}? [y/N]" + else + echo "⚠️ Permanently delete: ${file_id}? This cannot be undone. [y/N]" + fi + read -r confirm + [[ "${confirm}" != "y" ]] && echo "Cancelled." && return 0 + + python3 "${DRIVE_HELPER}" delete "${file_id}" ${permanent} +} diff --git a/lib/gmail.sh b/lib/gmail.sh new file mode 100644 index 0000000..7cf69ff --- /dev/null +++ b/lib/gmail.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# GARC gmail.sh — Full Gmail operations +# send / search / read / inbox / draft / reply / labels / profile + +GMAIL_HELPER="${GARC_DIR}/scripts/garc-gmail-helper.py" + +garc_gmail() { + local subcommand="${1:-help}" + shift || true + + case "${subcommand}" in + send) garc_gmail_send "$@" ;; + reply) garc_gmail_reply "$@" ;; + search) garc_gmail_search "$@" ;; + read) garc_gmail_read "$@" ;; + inbox) garc_gmail_inbox "$@" ;; + draft) garc_gmail_draft "$@" ;; + labels) garc_gmail_labels "$@" ;; + profile) garc_gmail_profile "$@" ;; + *) + cat <<EOF +Usage: garc gmail <subcommand> [options] + +Subcommands: + send --to <email> --subject <text> --body <text> [--cc <email>] [--html] + reply --thread-id <id> --message-id <id> --to <email> --subject <text> --body <text> + search <query> [--max N] [--body] + read <message_id> + inbox [--max N] [--unread] + draft --to <email> --subject <text> --body <text> [--cc <email>] + labels (list all Gmail labels) + profile (show account info) + +Examples: + garc gmail send --to manager@co.com --subject "Weekly Report" --body "..." + garc gmail search "from:boss@co.com subject:invoice" --max 10 + garc gmail inbox --unread --max 20 + garc gmail read abc123def456 +EOF + return 1 + ;; + esac +} + +garc_gmail_send() { + local to="" subject="" body="" cc="" bcc="" html="" reply_to="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --to) to="$2"; shift 2 ;; + --subject|-s) subject="$2"; shift 2 ;; + --body|-b) body="$2"; shift 2 ;; + --cc) cc="$2"; shift 2 ;; + --bcc) bcc="$2"; shift 2 ;; + --html) html="--html"; shift ;; + --reply-to) reply_to="$2"; shift 2 ;; + *) shift ;; + esac + done + + if [[ -z "${to}" ]] || [[ -z "${subject}" ]] || [[ -z "${body}" ]]; then + echo "Usage: garc gmail send --to <email> --subject <text> --body <text>" + return 1 + fi + + if [[ "${DRY_RUN:-false}" == "true" ]]; then + echo "[dry-run] Would send email:" + echo " To: ${to}" + echo " Subject: ${subject}" + echo " Body: ${body:0:100}..." + return 0 + fi + + local gate + gate=$(python3 "${GARC_DIR}/scripts/garc-auth-helper.py" suggest "send email" 2>/dev/null | \ + grep "Gate requirement" | grep -oE "(none|preview|approval)" || echo "preview") + + if [[ "${gate}" != "none" ]]; then + echo "⚠️ Gate: preview — Confirm send to ${to}? [y/N]" + read -r confirm + [[ "${confirm}" != "y" ]] && echo "Cancelled." && return 0 + fi + + python3 "${GMAIL_HELPER}" send \ + --to "${to}" \ + --subject "${subject}" \ + --body "${body}" \ + ${cc:+--cc "${cc}"} \ + ${bcc:+--bcc "${bcc}"} \ + ${html} \ + ${reply_to:+--reply-to "${reply_to}"} +} + +garc_gmail_reply() { + local thread_id="" message_id="" to="" subject="" body="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --thread-id) thread_id="$2"; shift 2 ;; + --message-id) message_id="$2"; shift 2 ;; + --to) to="$2"; shift 2 ;; + --subject|-s) subject="$2"; shift 2 ;; + --body|-b) body="$2"; shift 2 ;; + *) shift ;; + esac + done + + [[ -z "${thread_id}" ]] || [[ -z "${to}" ]] || [[ -z "${body}" ]] && { + echo "Usage: garc gmail reply --thread-id <id> --to <email> --body <text>" + return 1 + } + + python3 "${GMAIL_HELPER}" reply \ + --thread-id "${thread_id}" \ + --message-id "${message_id:-${thread_id}}" \ + --to "${to}" \ + --subject "${subject:-Re: (no subject)}" \ + --body "${body}" +} + +garc_gmail_search() { + local query="" max=20 body_flag="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --max|-n) max="$2"; shift 2 ;; + --body) body_flag="--body"; shift ;; + *) query="${query:+${query} }$1"; shift ;; + esac + done + + [[ -z "${query}" ]] && { echo "Usage: garc gmail search <query>"; return 1; } + + python3 "${GMAIL_HELPER}" search "${query}" --max "${max}" ${body_flag} +} + +garc_gmail_read() { + [[ -z "${1:-}" ]] && { echo "Usage: garc gmail read <message_id>"; return 1; } + python3 "${GMAIL_HELPER}" read "$1" +} + +garc_gmail_inbox() { + local max=20 unread_flag="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --max|-n) max="$2"; shift 2 ;; + --unread) unread_flag="--unread"; shift ;; + *) shift ;; + esac + done + + python3 "${GMAIL_HELPER}" inbox --max "${max}" ${unread_flag} +} + +garc_gmail_draft() { + local to="" subject="" body="" cc="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --to) to="$2"; shift 2 ;; + --subject|-s) subject="$2"; shift 2 ;; + --body|-b) body="$2"; shift 2 ;; + --cc) cc="$2"; shift 2 ;; + *) shift ;; + esac + done + + [[ -z "${to}" ]] || [[ -z "${subject}" ]] || [[ -z "${body}" ]] && { + echo "Usage: garc gmail draft --to <email> --subject <text> --body <text>" + return 1 + } + + python3 "${GMAIL_HELPER}" draft \ + --to "${to}" --subject "${subject}" --body "${body}" \ + ${cc:+--cc "${cc}"} +} + +garc_gmail_labels() { + python3 "${GMAIL_HELPER}" labels +} + +garc_gmail_profile() { + python3 "${GMAIL_HELPER}" profile +} diff --git a/lib/heartbeat.sh b/lib/heartbeat.sh new file mode 100644 index 0000000..c605760 --- /dev/null +++ b/lib/heartbeat.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# GARC heartbeat.sh — System state logging to Google Sheets + +garc_heartbeat() { + local agent_id="${GARC_DEFAULT_AGENT:-main}" + local status="ok" + local notes="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --agent) agent_id="$2"; shift 2 ;; + --status) status="$2"; shift 2 ;; + --notes) notes="$2"; shift 2 ;; + *) notes="$1"; shift ;; + esac + done + + local sheets_id="${GARC_SHEETS_ID:-}" + if [[ -z "${sheets_id}" ]]; then + echo "Error: GARC_SHEETS_ID not set" >&2 + return 1 + fi + + local timestamp + timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + echo "Recording heartbeat to Sheets..." + + python3 "${GARC_DIR}/scripts/garc-sheets-helper.py" heartbeat \ + --sheets-id "${sheets_id}" \ + --agent-id "${agent_id}" \ + --status "${status}" \ + --notes "${notes}" \ + --timestamp "${timestamp}" + + # Also update local HEARTBEAT.md + local workspace_dir="${GARC_CACHE_DIR}/workspace/${agent_id}" + if [[ -d "${workspace_dir}" ]]; then + cat > "${workspace_dir}/HEARTBEAT.md" <<EOF +# HEARTBEAT — System State + +agent_id: ${agent_id} +last_heartbeat: ${timestamp} +status: ${status} +notes: ${notes} +platform: Google Workspace +sheets_id: ${sheets_id} +EOF + fi + + echo "✅ Heartbeat logged: ${timestamp} [${status}]" +} diff --git a/lib/ingress.sh b/lib/ingress.sh new file mode 100644 index 0000000..026a57d --- /dev/null +++ b/lib/ingress.sh @@ -0,0 +1,644 @@ +#!/usr/bin/env bash +# GARC ingress.sh — Queue/ingress system (Claude Code execution bridge) +# +# Flow: +# enqueue → list → run-once → [execute-stub → Claude reads prompt] → done/fail +# +# Claude Code is the execution engine. run-once outputs a structured prompt +# that Claude Code reads and acts on — no external agent process needed. + +GARC_QUEUE_DIR="${GARC_CACHE_DIR:-${HOME}/.garc/cache}/queue" +INGRESS_HELPER="${GARC_DIR}/scripts/garc-ingress-helper.py" + +garc_ingress() { + local subcommand="${1:-help}" + shift || true + + case "${subcommand}" in + enqueue) _ingress_enqueue "$@" ;; + list) _ingress_list "$@" ;; + next) _ingress_next "$@" ;; + run-once) _ingress_run_once "$@" ;; + execute-stub) _ingress_execute_stub "$@" ;; + context) _ingress_context "$@" ;; + approve) _ingress_approve "$@" ;; + resume) _ingress_resume "$@" ;; + delegate) _ingress_delegate "$@" ;; + handoff) _ingress_handoff "$@" ;; + done) _ingress_done "$@" ;; + fail) _ingress_fail "$@" ;; + verify) _ingress_verify "$@" ;; + stats) _ingress_stats "$@" ;; + *) + cat <<EOF +Usage: garc ingress <subcommand> [options] + +Subcommands: + enqueue --text "<msg>" [--source gmail|manual] [--sender <email>] [--agent <id>] + list [--agent <id>] [--status pending|done|failed|all] + next [--agent <id>] + run-once [--agent <id>] [--dry-run] Run next pending item (outputs Claude prompt) + execute-stub --queue-id <id> Show execution plan for a queue item + context --queue-id <id> Output full Claude-readable context bundle + approve --queue-id <id> Unblock an approval-gated item + resume --queue-id <id> Resume a blocked item + delegate --queue-id <id> --to <agent> Reassign to another agent + handoff --queue-id <id> Handoff with full context (for multi-agent) + done --queue-id <id> [--note <text>] + fail --queue-id <id> [--note <text>] + verify --queue-id <id> Verify expected output was produced + stats Queue statistics + +Examples: + garc ingress enqueue --text "Send weekly report to manager" + garc ingress enqueue --text "Schedule team meeting next week" --source manual + garc ingress list + garc ingress run-once + garc ingress execute-stub --queue-id abc12345 + garc ingress done --queue-id abc12345 --note "Report sent to manager@co.com" +EOF + return 1 + ;; + esac +} + +# ───────────────────────────────────────────────────────────────── +# enqueue +# ───────────────────────────────────────────────────────────────── + +_ingress_enqueue() { + local text="" source="manual" sender="" agent="${GARC_DEFAULT_AGENT:-main}" + local dry_run=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --text|-t) text="$2"; shift 2 ;; + --source) source="$2"; shift 2 ;; + --sender) sender="$2"; shift 2 ;; + --agent|-a) agent="$2"; shift 2 ;; + --dry-run) dry_run=true; shift ;; + *) [[ -z "${text}" ]] && text="$1"; shift ;; + esac + done + + if [[ -z "${text}" ]]; then + echo "Usage: garc ingress enqueue --text \"<message>\" [--source gmail|manual] [--sender <email>]" + return 1 + fi + + mkdir -p "${GARC_QUEUE_DIR}" + + # Delegate entirely to Python — avoids shell quoting / multiline issues + python3 - \ + "${text}" "${source}" "${sender}" "${agent}" \ + "${GARC_QUEUE_DIR}" "${INGRESS_HELPER}" "${dry_run}" <<'PY' +import json, sys, subprocess, os, hashlib, time +from datetime import datetime, timezone + +text = sys.argv[1] +source = sys.argv[2] +sender = sys.argv[3] +agent_id = sys.argv[4] +queue_dir = sys.argv[5] +helper = sys.argv[6] +dry_run = sys.argv[7] == "true" + +# Build payload via helper +result = subprocess.run( + ["python3", helper, "build-payload", + "--text", text, "--source", source, + "--sender", sender, "--agent", agent_id], + capture_output=True, text=True +) + +# Parse helper stdout for display fields +queue_id = "" +gate = "preview" +task_types_str = "" +for line in result.stdout.splitlines(): + if "Queued:" in line: + queue_id = line.split()[-1].strip() + elif "gate:" in line and "gate_policy" not in line: + gate = line.split("gate:")[-1].strip() + elif "tasks:" in line: + task_types_str = line.split("tasks:")[-1].strip() + +# Fallback queue_id +if not queue_id: + digest = hashlib.sha256(f"{text}{time.time()}".encode()).hexdigest() + queue_id = digest[:8] + +# Parse task_types +task_types = [] +if task_types_str and task_types_str not in ("(none matched)", "(inferred offline)"): + task_types = [t.strip() for t in task_types_str.split(",") if t.strip()] + +payload = { + "queue_id": queue_id, + "message_text": text, + "source": source, + "sender": sender, + "agent_id": agent_id, + "task_types": task_types, + "gate": gate, + "status": "pending", + "created_at": datetime.now(timezone.utc).isoformat(), + "updated_at": None, + "approval_id": None, + "note": "", +} + +if dry_run: + print("[dry-run] Would enqueue:") + print(json.dumps(payload, indent=2, ensure_ascii=False)) + sys.exit(0) + +queue_file = os.path.join(queue_dir, f"{queue_id}.jsonl") +with open(queue_file, "w") as f: + f.write(json.dumps(payload, ensure_ascii=False)) + +print() +print(f"✅ Enqueued: {queue_id}") +print(f" Gate: {gate}") +print(f" Tasks: {', '.join(task_types) if task_types else 'unknown'}") +print(f" Source: {source}") +if sender: + print(f" Sender: {sender}") +print() +print("Next: garc ingress run-once") +PY +} + +# ───────────────────────────────────────────────────────────────── +# list +# ───────────────────────────────────────────────────────────────── + +_ingress_list() { + local agent="" status_filter="all" + + while [[ $# -gt 0 ]]; do + case "$1" in + --agent|-a) agent="$2"; shift 2 ;; + --status|-s) status_filter="$2"; shift 2 ;; + *) shift ;; + esac + done + + mkdir -p "${GARC_QUEUE_DIR}" + + python3 - <<PY +import json, glob, os + +queue_dir = "${GARC_QUEUE_DIR}" +agent_filter = "${agent}" +status_filter = "${status_filter}" + +STATUS_ICON = { + "pending": "⏳", + "in_progress": "🔄", + "blocked": "🔒", + "done": "✅", + "failed": "❌", +} +GATE_ICON = { + "none": "🟢", + "preview": "🟡", + "approval": "🔴", +} + +files = sorted(glob.glob(os.path.join(queue_dir, "*.jsonl"))) +items = [] +for f in files: + try: + q = json.loads(open(f).readline().strip()) + if agent_filter and q.get("agent_id", q.get("agent", "main")) != agent_filter: + continue + if status_filter != "all" and q.get("status") != status_filter: + continue + items.append(q) + except Exception: + continue + +if not items: + print("(queue is empty)") +else: + print(f"{'ID':10} {'STATUS':12} {'GATE':8} {'TASKS':30} MESSAGE") + print("─" * 80) + for q in items: + qid = q.get("queue_id", "?")[:10] + status = q.get("status", "?") + gate = q.get("gate", "?") + tasks = ", ".join(q.get("task_types", []))[:28] or "-" + msg = (q.get("message_text") or q.get("message", ""))[:40] + s_icon = STATUS_ICON.get(status, "❓") + g_icon = GATE_ICON.get(gate, "❓") + print(f"{qid:<10} {s_icon}{status:<11} {g_icon}{gate:<7} {tasks:<30} {msg}") +PY +} + +# ───────────────────────────────────────────────────────────────── +# next — return the next actionable queue item +# ───────────────────────────────────────────────────────────────── + +_ingress_next() { + local agent="${GARC_DEFAULT_AGENT:-main}" + + while [[ $# -gt 0 ]]; do + case "$1" in + --agent|-a) agent="$2"; shift 2 ;; + *) shift ;; + esac + done + + mkdir -p "${GARC_QUEUE_DIR}" + + python3 - <<PY +import json, glob, os, sys + +queue_dir = "${GARC_QUEUE_DIR}" +agent_id = "${agent}" + +files = sorted(glob.glob(os.path.join(queue_dir, "*.jsonl"))) +for f in files: + try: + q = json.loads(open(f).readline().strip()) + q_agent = q.get("agent_id", q.get("agent", "main")) + if q.get("status") == "pending" and (not agent_id or q_agent == agent_id or agent_id == "any"): + print(json.dumps(q, ensure_ascii=False)) + sys.exit(0) + except Exception: + continue + +print("(no pending items)") +PY +} + +# ───────────────────────────────────────────────────────────────── +# run-once — the core Claude Code execution bridge +# ───────────────────────────────────────────────────────────────── + +_ingress_run_once() { + local agent="${GARC_DEFAULT_AGENT:-main}" dry_run=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --agent|-a) agent="$2"; shift 2 ;; + --dry-run) dry_run=true; shift ;; + *) shift ;; + esac + done + + local next_raw + next_raw=$(_ingress_next --agent "${agent}") + + if [[ "${next_raw}" == "(no pending items)" ]]; then + echo "✅ Queue is empty — nothing to run." + return 0 + fi + + local queue_id gate message + queue_id=$(echo "${next_raw}" | python3 -c "import json,sys; q=json.loads(sys.stdin.read()); print(q.get('queue_id',''))") + gate=$(echo "${next_raw}" | python3 -c "import json,sys; q=json.loads(sys.stdin.read()); print(q.get('gate','preview'))") + message=$(echo "${next_raw}" | python3 -c "import json,sys; q=json.loads(sys.stdin.read()); print(q.get('message_text') or q.get('message',''))") + + echo "▶ Next queue item: ${queue_id}" + echo " Gate: ${gate}" + echo " Message: ${message}" + echo "" + + # ── Gate routing ────────────────────────────────────────────── + + if [[ "${gate}" == "approval" ]]; then + echo "🔒 Approval gate — creating approval request before execution." + if [[ "${dry_run}" != "true" ]]; then + _ingress_update_status "${queue_id}" "blocked" + source "${GARC_LIB}/approve.sh" + garc_approve_create "${message}" + fi + echo "" + echo "Status set to: blocked" + echo "Run after approval: garc ingress resume --queue-id ${queue_id}" + return 0 + fi + + if [[ "${gate}" == "preview" && "${dry_run}" != "true" ]]; then + # Claude Code context: output the plan and let Claude confirm with the user + # rather than blocking on stdin. Use --confirm flag to skip this prompt. + if [[ "${GARC_AUTO_CONFIRM:-false}" != "true" ]] && [[ -t 0 ]]; then + echo "⚠️ Preview gate — confirm before execution? [y/N]" + read -r confirm + [[ "${confirm}" != "y" ]] && echo "Cancelled." && return 0 + fi + fi + + if [[ "${dry_run}" == "true" ]]; then + echo "[dry-run] Would execute queue item ${queue_id}" + echo "" + _ingress_execute_stub --queue-id "${queue_id}" + return 0 + fi + + # ── Mark in_progress ────────────────────────────────────────── + _ingress_update_status "${queue_id}" "in_progress" + + # ── Output Claude Code prompt bundle ───────────────────────── + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "GARC → Claude Code: execute the following task" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + _ingress_context --queue-id "${queue_id}" +} + +# ───────────────────────────────────────────────────────────────── +# execute-stub — show the execution plan for a queue item +# ───────────────────────────────────────────────────────────────── + +_ingress_execute_stub() { + local queue_id="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --queue-id) queue_id="$2"; shift 2 ;; + *) [[ -z "${queue_id}" ]] && queue_id="$1"; shift ;; + esac + done + + [[ -z "${queue_id}" ]] && { echo "Usage: garc ingress execute-stub --queue-id <id>"; return 1; } + + local queue_file + queue_file=$(_find_queue_file "${queue_id}") + [[ -z "${queue_file}" ]] && { echo "Queue item not found: ${queue_id}" >&2; return 1; } + + python3 "${INGRESS_HELPER}" execute-stub --queue-file "${queue_file}" +} + +# ───────────────────────────────────────────────────────────────── +# context — Claude Code–readable prompt bundle +# ───────────────────────────────────────────────────────────────── + +_ingress_context() { + local queue_id="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --queue-id) queue_id="$2"; shift 2 ;; + *) [[ -z "${queue_id}" ]] && queue_id="$1"; shift ;; + esac + done + + [[ -z "${queue_id}" ]] && { echo "Usage: garc ingress context --queue-id <id>"; return 1; } + + local queue_file + queue_file=$(_find_queue_file "${queue_id}") + [[ -z "${queue_file}" ]] && { echo "Queue item not found: ${queue_id}" >&2; return 1; } + + local agent_id="${GARC_DEFAULT_AGENT:-main}" + local context_path="${GARC_CACHE_DIR}/workspace/${agent_id}/AGENT_CONTEXT.md" + + python3 "${INGRESS_HELPER}" build-prompt \ + --queue-file "${queue_file}" \ + --agent-context "${context_path}" +} + +# ───────────────────────────────────────────────────────────────── +# approve / resume — unblock an approval-gated item +# ───────────────────────────────────────────────────────────────── + +_ingress_approve() { + local queue_id="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --queue-id) queue_id="$2"; shift 2 ;; + *) [[ -z "${queue_id}" ]] && queue_id="$1"; shift ;; + esac + done + + [[ -z "${queue_id}" ]] && { echo "Usage: garc ingress approve --queue-id <id>"; return 1; } + + _ingress_update_status "${queue_id}" "pending" + echo "✅ Queue item ${queue_id} approved — status reset to pending." + echo " Run: garc ingress run-once" +} + +_ingress_resume() { + _ingress_approve "$@" +} + +# ───────────────────────────────────────────────────────────────── +# delegate — reassign a queue item to another agent +# ───────────────────────────────────────────────────────────────── + +_ingress_delegate() { + local queue_id="" to_agent="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --queue-id) queue_id="$2"; shift 2 ;; + --to) to_agent="$2"; shift 2 ;; + *) shift ;; + esac + done + + [[ -z "${queue_id}" ]] || [[ -z "${to_agent}" ]] && { + echo "Usage: garc ingress delegate --queue-id <id> --to <agent_id>" + return 1 + } + + local queue_file + queue_file=$(_find_queue_file "${queue_id}") + [[ -z "${queue_file}" ]] && { echo "Queue item not found: ${queue_id}" >&2; return 1; } + + python3 - <<PY +import json +f = "${queue_file}" +q = json.loads(open(f).readline()) +old_agent = q.get("agent_id", q.get("agent", "main")) +q["agent_id"] = "${to_agent}" +q["status"] = "pending" +q["note"] = f"Delegated from {old_agent} to ${to_agent}" +q["updated_at"] = "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" +with open(f, "w") as fh: + fh.write(json.dumps(q, ensure_ascii=False)) +print(f"✅ Delegated {q['queue_id'][:8]} → ${to_agent}") +PY +} + +# ───────────────────────────────────────────────────────────────── +# handoff — pass queue item with full context to another agent +# ───────────────────────────────────────────────────────────────── + +_ingress_handoff() { + local queue_id="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --queue-id) queue_id="$2"; shift 2 ;; + *) [[ -z "${queue_id}" ]] && queue_id="$1"; shift ;; + esac + done + + [[ -z "${queue_id}" ]] && { echo "Usage: garc ingress handoff --queue-id <id>"; return 1; } + + echo "## GARC Handoff Bundle" + echo "" + echo "Queue item \`${queue_id}\` is being handed off." + echo "Full context for the receiving agent:" + echo "" + _ingress_context --queue-id "${queue_id}" + echo "" + echo "---" + echo "To pick up this item:" + echo " garc ingress resume --queue-id ${queue_id}" + echo " garc ingress run-once --agent <receiving_agent>" +} + +# ───────────────────────────────────────────────────────────────── +# done / fail +# ───────────────────────────────────────────────────────────────── + +_ingress_done() { + local queue_id="" note="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --queue-id) queue_id="$2"; shift 2 ;; + --note|-n) note="$2"; shift 2 ;; + *) [[ -z "${queue_id}" ]] && queue_id="$1"; shift ;; + esac + done + + [[ -z "${queue_id}" ]] && { echo "Usage: garc ingress done --queue-id <id> [--note <text>]"; return 1; } + + _ingress_update_status "${queue_id}" "done" "${note}" + echo "✅ Queue item ${queue_id} — done." + [[ -n "${note}" ]] && echo " Note: ${note}" + + # Optionally log to Sheets heartbeat + if [[ -n "${GARC_SHEETS_ID:-}" ]]; then + python3 "${GARC_DIR}/scripts/garc-sheets-helper.py" heartbeat \ + --sheets-id "${GARC_SHEETS_ID}" \ + --agent-id "${GARC_DEFAULT_AGENT:-main}" \ + --status "ok" \ + --notes "ingress done: ${queue_id}${note:+ — }${note}" 2>/dev/null || true + fi +} + +_ingress_fail() { + local queue_id="" note="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --queue-id) queue_id="$2"; shift 2 ;; + --note|-n) note="$2"; shift 2 ;; + *) [[ -z "${queue_id}" ]] && queue_id="$1"; shift ;; + esac + done + + [[ -z "${queue_id}" ]] && { echo "Usage: garc ingress fail --queue-id <id> [--note <text>]"; return 1; } + + _ingress_update_status "${queue_id}" "failed" "${note}" + echo "❌ Queue item ${queue_id} — failed." + [[ -n "${note}" ]] && echo " Reason: ${note}" +} + +# ───────────────────────────────────────────────────────────────── +# verify — check that expected output was produced +# ───────────────────────────────────────────────────────────────── + +_ingress_verify() { + local queue_id="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --queue-id) queue_id="$2"; shift 2 ;; + *) [[ -z "${queue_id}" ]] && queue_id="$1"; shift ;; + esac + done + + [[ -z "${queue_id}" ]] && { echo "Usage: garc ingress verify --queue-id <id>"; return 1; } + + local queue_file + queue_file=$(_find_queue_file "${queue_id}") + [[ -z "${queue_file}" ]] && { echo "Queue item not found: ${queue_id}" >&2; return 1; } + + python3 - <<PY +import json +q = json.loads(open("${queue_file}").readline()) +status = q.get("status", "?") +note = q.get("note", "") +task_types = q.get("task_types", []) +updated = q.get("updated_at") or q.get("created_at", "?") + +print(f"Queue ID: {q.get('queue_id','?')}") +print(f"Status: {status}") +print(f"Updated: {updated}") +if note: + print(f"Note: {note}") +print() + +if status == "done": + print("✅ Task completed.") +elif status == "failed": + print("❌ Task failed.") +elif status in ("pending", "in_progress"): + print(f"⏳ Task still {status}.") +elif status == "blocked": + print("🔒 Task is waiting for approval.") + if q.get("approval_id"): + print(f" Approval ID: {q['approval_id']}") +PY +} + +# ───────────────────────────────────────────────────────────────── +# stats +# ───────────────────────────────────────────────────────────────── + +_ingress_stats() { + python3 "${INGRESS_HELPER}" stats --queue-dir "${GARC_QUEUE_DIR}" +} + +# ───────────────────────────────────────────────────────────────── +# Internal helpers +# ───────────────────────────────────────────────────────────────── + +_find_queue_file() { + local queue_id="$1" + local exact="${GARC_QUEUE_DIR}/${queue_id}.jsonl" + if [[ -f "${exact}" ]]; then + echo "${exact}" + return 0 + fi + # Partial match + local match + match=$(ls "${GARC_QUEUE_DIR}"/ 2>/dev/null | grep "^${queue_id}" | head -1) + if [[ -n "${match}" ]]; then + echo "${GARC_QUEUE_DIR}/${match}" + return 0 + fi + return 1 +} + +_ingress_update_status() { + local queue_id="$1" + local new_status="$2" + local note="${3:-}" + + local queue_file + queue_file=$(_find_queue_file "${queue_id}") + [[ -z "${queue_file}" ]] && return 1 + + python3 - <<PY +import json +f = "${queue_file}" +q = json.loads(open(f).readline()) +q["status"] = "${new_status}" +q["updated_at"] = "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" +note = """${note}""" +if note: + q["note"] = note +with open(f, "w") as fh: + fh.write(json.dumps(q, ensure_ascii=False)) +PY +} diff --git a/lib/kg.sh b/lib/kg.sh new file mode 100644 index 0000000..2e5e5e8 --- /dev/null +++ b/lib/kg.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# GARC kg.sh — Knowledge graph via Google Docs +# Google Docs replaces Lark Wiki as the knowledge graph surface + +GARC_KG_CACHE="${GARC_CACHE_DIR:-${HOME}/.garc/cache}/knowledge-graph.json" + +garc_kg() { + local subcommand="${1:-help}" + shift || true + + case "${subcommand}" in + build) garc_kg_build "$@" ;; + query) garc_kg_query "$@" ;; + show) garc_kg_show "$@" ;; + *) + echo "Usage: garc kg <build|query|show>" + return 1 + ;; + esac +} + +# garc kg build +# Crawls Google Drive folder and builds knowledge graph from Docs +garc_kg_build() { + local folder_id="${GARC_DRIVE_FOLDER_ID:-}" + + if [[ -z "${folder_id}" ]]; then + echo "Error: GARC_DRIVE_FOLDER_ID not set" >&2 + return 1 + fi + + echo "Building knowledge graph from Google Drive folder: ${folder_id}" + + python3 "${GARC_DIR}/scripts/garc-drive-helper.py" kg-build \ + --folder-id "${folder_id}" \ + --output "${GARC_KG_CACHE}" + + echo "✅ Knowledge graph built: ${GARC_KG_CACHE}" +} + +# garc kg query "<concept>" +garc_kg_query() { + local query="$*" + + if [[ -z "${query}" ]]; then + echo "Usage: garc kg query \"<concept>\"" + return 1 + fi + + if [[ ! -f "${GARC_KG_CACHE}" ]]; then + echo "Knowledge graph not built. Run: garc kg build" + return 1 + fi + + python3 -c " +import json, sys +query = '${query}'.lower() +with open('${GARC_KG_CACHE}') as f: + kg = json.load(f) + +matches = [] +for node in kg.get('nodes', []): + name = node.get('title', '').lower() + content = node.get('content_preview', '').lower() + if query in name or query in content: + matches.append(node) + +if not matches: + print(f'No results for: ${query}') + sys.exit(0) + +print(f'Results for \"{query}\" ({len(matches)} matches):') +for m in matches[:10]: + print(f' - [{m.get(\"doc_id\",\"\")}] {m.get(\"title\",\"\")}') + if m.get('content_preview'): + preview = m['content_preview'][:100].replace('\n', ' ') + print(f' {preview}...') + links = m.get('links', []) + if links: + print(f' Links: {len(links)} documents') +" +} + +# garc kg show <doc_id> +garc_kg_show() { + local doc_id="${1:-}" + + if [[ -z "${doc_id}" ]]; then + echo "Usage: garc kg show <doc_id>" + return 1 + fi + + if [[ ! -f "${GARC_KG_CACHE}" ]]; then + echo "Knowledge graph not built. Run: garc kg build" + return 1 + fi + + python3 -c " +import json +doc_id = '${doc_id}' +with open('${GARC_KG_CACHE}') as f: + kg = json.load(f) + +for node in kg.get('nodes', []): + if node.get('doc_id') == doc_id: + print(f'Title: {node.get(\"title\", \"\")}') + print(f'Doc ID: {doc_id}') + print(f'Type: {node.get(\"mime_type\", \"\")}') + print(f'Modified: {node.get(\"modified_time\", \"\")}') + print() + print('Content preview:') + print(node.get('content_preview', '(none)')) + print() + links = node.get('links', []) + if links: + print(f'Links ({len(links)}):') + for link in links: + print(f' -> {link}') + break +else: + print(f'Document {doc_id} not found in knowledge graph') +" +} diff --git a/lib/memory.sh b/lib/memory.sh new file mode 100644 index 0000000..15b574c --- /dev/null +++ b/lib/memory.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# GARC memory.sh — Memory sync with Google Sheets +# Google Sheets replaces Lark Base as the memory backend + +garc_memory() { + local subcommand="${1:-help}" + shift || true + + case "${subcommand}" in + pull) garc_memory_pull "$@" ;; + push) garc_memory_push "$@" ;; + search) garc_memory_search "$@" ;; + add) garc_memory_add "$@" ;; + *) + echo "Usage: garc memory <pull|push|search|add>" + return 1 + ;; + esac +} + +# garc memory pull +# Downloads memory entries from Google Sheets to local cache +garc_memory_pull() { + local agent_id="${GARC_DEFAULT_AGENT:-main}" + + while [[ $# -gt 0 ]]; do + case "$1" in + --agent) agent_id="$2"; shift 2 ;; + *) shift ;; + esac + done + + local sheets_id="${GARC_SHEETS_ID:-}" + if [[ -z "${sheets_id}" ]]; then + echo "Error: GARC_SHEETS_ID not set" >&2 + return 1 + fi + + echo "Pulling memory from Google Sheets (${sheets_id})..." + + local cache_dir="${GARC_CACHE_DIR}/workspace/${agent_id}" + mkdir -p "${cache_dir}/memory" + + local output_file="${cache_dir}/memory/$(date +%Y-%m-%d).md" + + python3 "${GARC_DIR}/scripts/garc-sheets-helper.py" memory-pull \ + --sheets-id "${sheets_id}" \ + --agent-id "${agent_id}" \ + --output "${output_file}" + + echo "✅ Memory pulled to ${output_file}" +} + +# garc memory push +# Uploads local memory to Google Sheets +garc_memory_push() { + local agent_id="${GARC_DEFAULT_AGENT:-main}" + local message="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --agent) agent_id="$2"; shift 2 ;; + --message|-m) message="$2"; shift 2 ;; + *) message="$1"; shift ;; + esac + done + + local sheets_id="${GARC_SHEETS_ID:-}" + if [[ -z "${sheets_id}" ]]; then + echo "Error: GARC_SHEETS_ID not set" >&2 + return 1 + fi + + if [[ -z "${message}" ]]; then + echo "Usage: garc memory push \"<memory entry>\"" + return 1 + fi + + echo "Pushing memory to Google Sheets..." + + python3 "${GARC_DIR}/scripts/garc-sheets-helper.py" memory-push \ + --sheets-id "${sheets_id}" \ + --agent-id "${agent_id}" \ + --entry "${message}" \ + --timestamp "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + + echo "✅ Memory entry saved" +} + +# garc memory search <query> +# Searches memory entries in Google Sheets +garc_memory_search() { + local query="$*" + + if [[ -z "${query}" ]]; then + echo "Usage: garc memory search \"<query>\"" + return 1 + fi + + local sheets_id="${GARC_SHEETS_ID:-}" + if [[ -z "${sheets_id}" ]]; then + echo "Error: GARC_SHEETS_ID not set" >&2 + return 1 + fi + + echo "Searching memory for: ${query}" + + python3 "${GARC_DIR}/scripts/garc-sheets-helper.py" memory-search \ + --sheets-id "${sheets_id}" \ + --query "${query}" +} + +# garc memory add "<entry>" +# Quick alias for pushing a single memory entry +garc_memory_add() { + garc_memory_push "$@" +} diff --git a/lib/people.sh b/lib/people.sh new file mode 100644 index 0000000..f9ca58d --- /dev/null +++ b/lib/people.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# GARC people.sh — Google People API: contacts and directory search + +PEOPLE_HELPER="${GARC_DIR}/scripts/garc-people-helper.py" + +garc_people() { + local subcommand="${1:-help}" + shift || true + + case "${subcommand}" in + search) garc_people_search "$@" ;; + directory) garc_people_directory "$@" ;; + list) garc_people_list "$@" ;; + show) garc_people_show "$@" ;; + create) garc_people_create "$@" ;; + update) garc_people_update "$@" ;; + delete) garc_people_delete "$@" ;; + lookup) garc_people_lookup "$@" ;; + *) + cat <<EOF +Usage: garc people <subcommand> [options] + +Subcommands: + search <query> Search personal contacts + directory <query> Search GWS org directory + list [--max N] [--format json] List all personal contacts + show <contact_id> Show contact details + create --name <name> [--email] [--phone] [--company] [--title] [--notes] + update <contact_id> [--name] [--email] [--phone] [--company] [--title] + delete <contact_id> + lookup <name> Quick: find email for a name + +Examples: + garc people search "Alice" + garc people directory "engineering" + garc people lookup "Bob Smith" + garc people create --name "Jane Doe" --email jane@co.com --company "Acme Corp" + garc people update abc123 --title "Senior Engineer" +EOF + return 1 + ;; + esac +} + +garc_people_search() { + local query="" max=20 format="table" + while [[ $# -gt 0 ]]; do + case "$1" in + --max|-n) max="$2"; shift 2 ;; + --format|-f) format="$2"; shift 2 ;; + *) query="${query:+${query} }$1"; shift ;; + esac + done + [[ -z "${query}" ]] && { echo "Usage: garc people search <query>"; return 1; } + python3 "${PEOPLE_HELPER}" --format "${format}" search "${query}" --max "${max}" +} + +garc_people_directory() { + local query="" max=20 format="table" + while [[ $# -gt 0 ]]; do + case "$1" in + --max|-n) max="$2"; shift 2 ;; + --format|-f) format="$2"; shift 2 ;; + *) query="${query:+${query} }$1"; shift ;; + esac + done + [[ -z "${query}" ]] && { echo "Usage: garc people directory <query>"; return 1; } + python3 "${PEOPLE_HELPER}" --format "${format}" directory "${query}" --max "${max}" +} + +garc_people_list() { + local max=50 format="table" + while [[ $# -gt 0 ]]; do + case "$1" in + --max|-n) max="$2"; shift 2 ;; + --format|-f) format="$2"; shift 2 ;; + *) shift ;; + esac + done + python3 "${PEOPLE_HELPER}" --format "${format}" list --max "${max}" +} + +garc_people_show() { + local contact_id="${1:-}" + [[ -z "${contact_id}" ]] && { echo "Usage: garc people show <contact_id>"; return 1; } + python3 "${PEOPLE_HELPER}" show "${contact_id}" +} + +garc_people_create() { + local name="" email="" phone="" company="" title="" notes="" + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --email) email="$2"; shift 2 ;; + --phone) phone="$2"; shift 2 ;; + --company) company="$2"; shift 2 ;; + --title) title="$2"; shift 2 ;; + --notes) notes="$2"; shift 2 ;; + *) shift ;; + esac + done + [[ -z "${name}" ]] && { echo "Usage: garc people create --name <name> [--email] [--phone] [--company] [--title]"; return 1; } + + python3 "${PEOPLE_HELPER}" create \ + --name "${name}" \ + ${email:+--email "${email}"} \ + ${phone:+--phone "${phone}"} \ + ${company:+--company "${company}"} \ + ${title:+--title "${title}"} \ + ${notes:+--notes "${notes}"} +} + +garc_people_update() { + local contact_id="${1:-}" + shift || true + local name="" email="" phone="" company="" title="" + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --email) email="$2"; shift 2 ;; + --phone) phone="$2"; shift 2 ;; + --company) company="$2"; shift 2 ;; + --title) title="$2"; shift 2 ;; + *) shift ;; + esac + done + [[ -z "${contact_id}" ]] && { echo "Usage: garc people update <contact_id> [--name] [--email] [--phone] [--company] [--title]"; return 1; } + + python3 "${PEOPLE_HELPER}" update "${contact_id}" \ + ${name:+--name "${name}"} \ + ${email:+--email "${email}"} \ + ${phone:+--phone "${phone}"} \ + ${company:+--company "${company}"} \ + ${title:+--title "${title}"} +} + +garc_people_delete() { + local contact_id="${1:-}" + [[ -z "${contact_id}" ]] && { echo "Usage: garc people delete <contact_id>"; return 1; } + echo "Delete contact ${contact_id}? [y/N]" + read -r confirm + [[ "${confirm}" != "y" ]] && echo "Cancelled." && return 0 + python3 "${PEOPLE_HELPER}" delete "${contact_id}" +} + +garc_people_lookup() { + local query="$*" + [[ -z "${query}" ]] && { echo "Usage: garc people lookup <name>"; return 1; } + python3 "${PEOPLE_HELPER}" lookup "${query}" +} diff --git a/lib/send.sh b/lib/send.sh new file mode 100644 index 0000000..e390983 --- /dev/null +++ b/lib/send.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# GARC send.sh — Gmail / Google Chat message sending +# Replaces Lark IM with Gmail or Google Chat + +garc_send() { + local message="" + local to="${GARC_GMAIL_DEFAULT_TO:-}" + local subject="GARC Agent Notification" + local use_chat=false + local space_id="${GARC_CHAT_SPACE_ID:-}" + + # Parse message (first non-flag argument) + while [[ $# -gt 0 ]]; do + case "$1" in + --to) to="$2"; shift 2 ;; + --subject|-s) subject="$2"; shift 2 ;; + --chat) use_chat=true; shift ;; + --space) space_id="$2"; shift 2 ;; + *) message="$1"; shift ;; + esac + done + + if [[ -z "${message}" ]]; then + echo "Usage: garc send \"<message>\" [--to <email>] [--chat] [--space <space_id>]" + return 1 + fi + + if [[ "${use_chat}" == "true" ]]; then + _garc_send_chat "${message}" "${space_id}" + else + _garc_send_gmail "${message}" "${to}" "${subject}" + fi +} + +_garc_send_gmail() { + local message="$1" + local to="$2" + local subject="$3" + + if [[ -z "${to}" ]]; then + echo "Error: No recipient. Set GARC_GMAIL_DEFAULT_TO or use --to <email>" >&2 + return 1 + fi + + echo "Sending Gmail to ${to}..." + echo "Subject: ${subject}" + echo "Message: ${message}" + + if [[ "${DRY_RUN:-false}" == "true" ]]; then + echo "[dry-run] Would send Gmail to ${to}" + return 0 + fi + + python3 "${GARC_DIR}/scripts/garc-gmail-helper.py" send \ + --to "${to}" \ + --subject "${subject}" \ + --body "${message}" +} + +_garc_send_chat() { + local message="$1" + local space_id="$2" + + if [[ -z "${space_id}" ]]; then + echo "Error: No Chat space. Set GARC_CHAT_SPACE_ID or use --space <space_id>" >&2 + return 1 + fi + + echo "Sending Google Chat message to space ${space_id}..." + + if [[ "${DRY_RUN:-false}" == "true" ]]; then + echo "[dry-run] Would send Chat message to ${space_id}" + return 0 + fi + + python3 "${GARC_DIR}/scripts/garc-chat-helper.py" send \ + --space-id "${space_id}" \ + --message "${message}" +} diff --git a/lib/sheets.sh b/lib/sheets.sh new file mode 100644 index 0000000..7762432 --- /dev/null +++ b/lib/sheets.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# GARC sheets.sh — Direct Google Sheets operations +# read / write / append / search / info / clear + +SHEETS_HELPER="${GARC_DIR}/scripts/garc-sheets-helper.py" + +garc_sheets() { + local subcommand="${1:-help}" + shift || true + + case "${subcommand}" in + read) garc_sheets_read "$@" ;; + write) garc_sheets_write "$@" ;; + append) garc_sheets_append "$@" ;; + search) garc_sheets_search "$@" ;; + info) garc_sheets_info "$@" ;; + clear) garc_sheets_clear "$@" ;; + *) + cat <<EOF +Usage: garc sheets <subcommand> [options] + +Subcommands: + read --sheets-id <id> --range <A1:Z10> [--format table|json] + write --sheets-id <id> --range <A1> --values '[[...]]' + append --sheets-id <id> --sheet <name> --values '[...]' + search --sheets-id <id> --sheet <name> --query <text> [--column N] [--format table|json] + info --sheets-id <id> + clear --sheets-id <id> --range <A2:Z> + +Defaults to GARC_SHEETS_ID if --sheets-id is omitted. + +Examples: + garc sheets info + garc sheets read --range "memory!A:E" --format json + garc sheets search --sheet memory --query "expense" + garc sheets append --sheet queue --values '["main","msg","pending","preview"]' + garc sheets write --range "agents!A2" --values '[["main","claude-sonnet-4-6"]]' +EOF + return 1 + ;; + esac +} + +_sheets_id() { + echo "${GARC_SHEETS_ID:-}" +} + +garc_sheets_read() { + local sheets_id="" range_="" format="table" + + while [[ $# -gt 0 ]]; do + case "$1" in + --sheets-id) sheets_id="$2"; shift 2 ;; + --range|-r) range_="$2"; shift 2 ;; + --format|-f) format="$2"; shift 2 ;; + *) shift ;; + esac + done + + sheets_id="${sheets_id:-$(_sheets_id)}" + [[ -z "${sheets_id}" ]] && { echo "Error: GARC_SHEETS_ID not set"; return 1; } + [[ -z "${range_}" ]] && { echo "Usage: garc sheets read --range <range>"; return 1; } + + python3 "${SHEETS_HELPER}" read \ + --sheets-id "${sheets_id}" \ + --range "${range_}" \ + --format "${format}" +} + +garc_sheets_write() { + local sheets_id="" range_="" values="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --sheets-id) sheets_id="$2"; shift 2 ;; + --range|-r) range_="$2"; shift 2 ;; + --values|-v) values="$2"; shift 2 ;; + *) shift ;; + esac + done + + sheets_id="${sheets_id:-$(_sheets_id)}" + [[ -z "${sheets_id}" ]] && { echo "Error: GARC_SHEETS_ID not set"; return 1; } + [[ -z "${range_}" ]] || [[ -z "${values}" ]] && { + echo "Usage: garc sheets write --range <range> --values '[[\"value1\", \"value2\"]]'" + return 1 + } + + python3 "${SHEETS_HELPER}" write \ + --sheets-id "${sheets_id}" \ + --range "${range_}" \ + --values "${values}" +} + +garc_sheets_append() { + local sheets_id="" sheet="" values="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --sheets-id) sheets_id="$2"; shift 2 ;; + --sheet|-s) sheet="$2"; shift 2 ;; + --values|-v) values="$2"; shift 2 ;; + *) shift ;; + esac + done + + sheets_id="${sheets_id:-$(_sheets_id)}" + [[ -z "${sheets_id}" ]] && { echo "Error: GARC_SHEETS_ID not set"; return 1; } + [[ -z "${sheet}" ]] || [[ -z "${values}" ]] && { + echo "Usage: garc sheets append --sheet <name> --values '[\"val1\", \"val2\"]'" + return 1 + } + + python3 "${SHEETS_HELPER}" append \ + --sheets-id "${sheets_id}" \ + --sheet "${sheet}" \ + --values "${values}" +} + +garc_sheets_search() { + local sheets_id="" sheet="" query="" column=-1 format="table" + + while [[ $# -gt 0 ]]; do + case "$1" in + --sheets-id) sheets_id="$2"; shift 2 ;; + --sheet|-s) sheet="$2"; shift 2 ;; + --query|-q) query="$2"; shift 2 ;; + --column|-c) column="$2"; shift 2 ;; + --format|-f) format="$2"; shift 2 ;; + *) query="${query:+${query} }$1"; shift ;; + esac + done + + sheets_id="${sheets_id:-$(_sheets_id)}" + [[ -z "${sheets_id}" ]] && { echo "Error: GARC_SHEETS_ID not set"; return 1; } + [[ -z "${sheet}" ]] || [[ -z "${query}" ]] && { + echo "Usage: garc sheets search --sheet <name> --query <text>" + return 1 + } + + python3 "${SHEETS_HELPER}" search \ + --sheets-id "${sheets_id}" \ + --sheet "${sheet}" \ + --query "${query}" \ + --column "${column}" \ + --format "${format}" +} + +garc_sheets_info() { + local sheets_id="${1:-}" + + [[ $# -gt 0 ]] && shift + while [[ $# -gt 0 ]]; do + case "$1" in + --sheets-id) sheets_id="$2"; shift 2 ;; + *) shift ;; + esac + done + + sheets_id="${sheets_id:-$(_sheets_id)}" + [[ -z "${sheets_id}" ]] && { echo "Error: GARC_SHEETS_ID not set"; return 1; } + + python3 "${SHEETS_HELPER}" info --sheets-id "${sheets_id}" +} + +garc_sheets_clear() { + local sheets_id="" range_="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --sheets-id) sheets_id="$2"; shift 2 ;; + --range|-r) range_="$2"; shift 2 ;; + *) shift ;; + esac + done + + sheets_id="${sheets_id:-$(_sheets_id)}" + [[ -z "${sheets_id}" ]] && { echo "Error: GARC_SHEETS_ID not set"; return 1; } + [[ -z "${range_}" ]] && { echo "Usage: garc sheets clear --range <range>"; return 1; } + + echo "⚠️ Clear range ${range_}? [y/N]" + read -r confirm + [[ "${confirm}" != "y" ]] && echo "Cancelled." && return 0 + + python3 "${SHEETS_HELPER}" clear --sheets-id "${sheets_id}" --range "${range_}" +} diff --git a/lib/task.sh b/lib/task.sh new file mode 100644 index 0000000..74788e0 --- /dev/null +++ b/lib/task.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# GARC task.sh — Google Tasks operations +# list / show / create / update / done / delete / clear-completed / tasklists + +TASKS_HELPER="${GARC_DIR}/scripts/garc-tasks-helper.py" + +garc_task() { + local subcommand="${1:-help}" + shift || true + + case "${subcommand}" in + list) garc_task_list "$@" ;; + show) garc_task_show "$@" ;; + create) garc_task_create "$@" ;; + update) garc_task_update "$@" ;; + done) garc_task_done "$@" ;; + delete) garc_task_delete "$@" ;; + clear-completed) garc_task_clear_completed "$@" ;; + tasklists) garc_task_tasklists "$@" ;; + *) + cat <<EOF +Usage: garc task <subcommand> [options] + +Subcommands: + list [--list <id>] [--completed] [--format table|json] + show <task_id> [--list <id>] + create "<title>" [--due YYYY-MM-DD] [--notes <text>] [--list <id>] [--parent <id>] + update <task_id> [--title <text>] [--due YYYY-MM-DD] [--notes <text>] [--list <id>] + done <task_id> [--list <id>] + delete <task_id> [--list <id>] + clear-completed [--list <id>] + tasklists Show all task lists + +Examples: + garc task list + garc task list --completed --format json + garc task create "Write Q1 report" --due 2026-04-30 --notes "Include revenue section" + garc task update abc123 --due 2026-05-01 + garc task done abc123 + garc task delete abc123 + garc task tasklists +EOF + return 1 + ;; + esac +} + +garc_task_list() { + local tasklist="@default" completed="" format="table" + + while [[ $# -gt 0 ]]; do + case "$1" in + --list|-l) tasklist="$2"; shift 2 ;; + --completed) completed="--completed"; shift ;; + --format|-f) format="$2"; shift 2 ;; + *) shift ;; + esac + done + + python3 "${TASKS_HELPER}" --tasklist "${tasklist}" --format "${format}" list ${completed} +} + +garc_task_show() { + local task_id="${1:-}" tasklist="@default" + shift || true + + while [[ $# -gt 0 ]]; do + case "$1" in + --list|-l) tasklist="$2"; shift 2 ;; + *) shift ;; + esac + done + + [[ -z "${task_id}" ]] && { echo "Usage: garc task show <task_id> [--list <id>]"; return 1; } + + python3 "${TASKS_HELPER}" --tasklist "${tasklist}" show --task-id "${task_id}" +} + +garc_task_create() { + local title="" tasklist="@default" due="" notes="" parent="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --list|-l) tasklist="$2"; shift 2 ;; + --due|-d) due="$2"; shift 2 ;; + --notes|-n) notes="$2"; shift 2 ;; + --parent|-p) parent="$2"; shift 2 ;; + *) title="${title:+${title} }$1"; shift ;; + esac + done + + [[ -z "${title}" ]] && { + echo "Usage: garc task create \"<title>\" [--due YYYY-MM-DD] [--notes <text>] [--list <id>]" + return 1 + } + + python3 "${TASKS_HELPER}" --tasklist "${tasklist}" create \ + --title "${title}" \ + ${due:+--due "${due}"} \ + ${notes:+--notes "${notes}"} \ + ${parent:+--parent "${parent}"} +} + +garc_task_update() { + local task_id="${1:-}" tasklist="@default" title="" due="" notes="" + shift || true + + while [[ $# -gt 0 ]]; do + case "$1" in + --list|-l) tasklist="$2"; shift 2 ;; + --title|-t) title="$2"; shift 2 ;; + --due|-d) due="$2"; shift 2 ;; + --notes|-n) notes="$2"; shift 2 ;; + *) shift ;; + esac + done + + [[ -z "${task_id}" ]] && { + echo "Usage: garc task update <task_id> [--title <text>] [--due YYYY-MM-DD] [--notes <text>]" + return 1 + } + + python3 "${TASKS_HELPER}" --tasklist "${tasklist}" update \ + --task-id "${task_id}" \ + ${title:+--title "${title}"} \ + ${due:+--due "${due}"} \ + ${notes:+--notes "${notes}"} +} + +garc_task_done() { + local task_id="${1:-}" tasklist="@default" + shift || true + + while [[ $# -gt 0 ]]; do + case "$1" in + --list|-l) tasklist="$2"; shift 2 ;; + *) shift ;; + esac + done + + [[ -z "${task_id}" ]] && { echo "Usage: garc task done <task_id> [--list <id>]"; return 1; } + + python3 "${TASKS_HELPER}" --tasklist "${tasklist}" complete --task-id "${task_id}" +} + +garc_task_delete() { + local task_id="${1:-}" tasklist="@default" + shift || true + + while [[ $# -gt 0 ]]; do + case "$1" in + --list|-l) tasklist="$2"; shift 2 ;; + *) shift ;; + esac + done + + [[ -z "${task_id}" ]] && { echo "Usage: garc task delete <task_id> [--list <id>]"; return 1; } + + echo "Delete task ${task_id}? [y/N]" + read -r confirm + [[ "${confirm}" != "y" ]] && echo "Cancelled." && return 0 + + python3 "${TASKS_HELPER}" --tasklist "${tasklist}" delete --task-id "${task_id}" +} + +garc_task_clear_completed() { + local tasklist="@default" + + while [[ $# -gt 0 ]]; do + case "$1" in + --list|-l) tasklist="$2"; shift 2 ;; + *) shift ;; + esac + done + + echo "Clear all completed tasks from '${tasklist}'? [y/N]" + read -r confirm + [[ "${confirm}" != "y" ]] && echo "Cancelled." && return 0 + + python3 "${TASKS_HELPER}" --tasklist "${tasklist}" clear-completed +} + +garc_task_tasklists() { + python3 "${TASKS_HELPER}" list-tasklists +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..963fc52 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +google-api-python-client>=2.100.0 +google-auth>=2.23.0 +google-auth-oauthlib>=1.1.0 +google-auth-httplib2>=0.1.1 +pyyaml>=6.0.1 +python-dateutil>=2.8.2 +rich>=13.0.0 diff --git a/scripts/garc-auth-helper.py b/scripts/garc-auth-helper.py new file mode 100755 index 0000000..e8737d3 --- /dev/null +++ b/scripts/garc-auth-helper.py @@ -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() diff --git a/scripts/garc-calendar-helper.py b/scripts/garc-calendar-helper.py new file mode 100644 index 0000000..560b4b6 --- /dev/null +++ b/scripts/garc-calendar-helper.py @@ -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() diff --git a/scripts/garc-core.py b/scripts/garc-core.py new file mode 100644 index 0000000..ac82ffc --- /dev/null +++ b/scripts/garc-core.py @@ -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 diff --git a/scripts/garc-drive-helper.py b/scripts/garc-drive-helper.py new file mode 100755 index 0000000..ac0ed2c --- /dev/null +++ b/scripts/garc-drive-helper.py @@ -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() diff --git a/scripts/garc-gmail-helper.py b/scripts/garc-gmail-helper.py new file mode 100755 index 0000000..0016f08 --- /dev/null +++ b/scripts/garc-gmail-helper.py @@ -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() diff --git a/scripts/garc-ingress-helper.py b/scripts/garc-ingress-helper.py new file mode 100644 index 0000000..81220b6 --- /dev/null +++ b/scripts/garc-ingress-helper.py @@ -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() diff --git a/scripts/garc-people-helper.py b/scripts/garc-people-helper.py new file mode 100644 index 0000000..a1f5a8b --- /dev/null +++ b/scripts/garc-people-helper.py @@ -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() diff --git a/scripts/garc-setup.py b/scripts/garc-setup.py new file mode 100644 index 0000000..3eaf9a9 --- /dev/null +++ b/scripts/garc-setup.py @@ -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() diff --git a/scripts/garc-sheets-helper.py b/scripts/garc-sheets-helper.py new file mode 100755 index 0000000..69bdb88 --- /dev/null +++ b/scripts/garc-sheets-helper.py @@ -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() diff --git a/scripts/garc-tasks-helper.py b/scripts/garc-tasks-helper.py new file mode 100755 index 0000000..60fde0f --- /dev/null +++ b/scripts/garc-tasks-helper.py @@ -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() diff --git a/scripts/setup-workspace.sh b/scripts/setup-workspace.sh new file mode 100755 index 0000000..62c44d4 --- /dev/null +++ b/scripts/setup-workspace.sh @@ -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"'