feat: initial release — GARC v0.1.0

Permission-first AI agent runtime for Google Workspace.
Ports the LARC/OpenClaw governance model (disclosure chain,
execution gates, queue/ingress) to Gmail, Calendar, Drive,
Sheets, Tasks, and People APIs with Claude Code as the
execution engine.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
林 駿甫 (Shunsuke Hayashi) 2026-04-15 08:59:12 +09:00
commit a69b9d9160
44 changed files with 9790 additions and 0 deletions

View file

@ -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 "<task description>"
```
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 <message_id>
```
### 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 <file_id>
```
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 <task_id> --due 2026-05-01
garc task done <task_id>
garc task delete <task_id>
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 <contact_id>
garc people create --name "Jane Doe" --email jane@co.com --company "Acme"
garc people update <contact_id> --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 "<task>"` 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) |

46
.gitignore vendored Normal file
View file

@ -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

30
CHANGELOG.md Normal file
View file

@ -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

189
CLAUDE.md Normal file
View file

@ -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 "<task>"` → 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/<agent_id>/
Consolidated: ~/.garc/cache/workspace/<agent_id>/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.

102
README.ja.md Normal file
View file

@ -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 APIsDrive, 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 バックエンド対応表
| 機能 | LARCLark | GARCGoogle 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 <this-repo>
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 <id>] # Driveから開示チェーン読み込み
garc memory pull/push/search # Sheetsとメモリ同期
garc send "<msg>" [--to <email>] # Gmail/Google Chat経由で送信
garc task list/create/done # Google Tasks操作
garc approve gate <task_type> # 実行ゲートポリシー確認
garc approve list/create # 承認管理
garc agent list/register # エージェントレジストリ操作
garc auth suggest "<タスク>" # OAuthスコープ推定
garc auth check [--profile <p>] # 現在のトークンスコープ確認
garc auth login [--profile <p>] # 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

234
README.md Normal file
View file

@ -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/<owner>/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 <message_id>
```
### 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 <id> --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/<agent>/
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

54
agents.yaml Normal file
View file

@ -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

265
bin/garc Executable file
View file

@ -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 <<EOF
GARC v${VERSION} — Google Workspace Agent Runtime CLI
Usage: garc <command> [subcommand] [options]
━━━ Core ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
init Initialize GARC workspace (config + dirs)
setup [all|check|sheets|drive] Provision GWS resources automatically
bootstrap [--agent <id>] Load disclosure chain from Google Drive
status Show config and connection health
━━━ Gmail ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
gmail send --to <email> --subject <text> --body <text> [--cc] [--html]
gmail reply --thread-id <id> --to <email> --body <text>
gmail search <query> [--max N] [--body]
gmail read <message_id>
gmail inbox [--max N] [--unread]
gmail draft --to <email> --subject <text> --body <text>
gmail labels
gmail profile
send "<msg>" --to <email> Shorthand for gmail send
━━━ Google Calendar ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
calendar today Events for today
calendar week Events for this week
calendar list [--days N] [--query <text>]
calendar create --summary <text> --start <dt> --end <dt> [--attendees ...]
calendar update <event_id> [--summary ...] [--start ...] [--end ...]
calendar delete <event_id>
calendar get <event_id>
calendar freebusy --start <date> --end <date> --emails email1 [...]
calendar quick-add "<natural language>"
calendar calendars List all accessible calendars
━━━ Google Drive ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
drive list [--folder-id <id>] [--query <name>]
drive search <query> [--type doc|sheet|slide|folder|pdf]
drive info <file_id>
drive download --file-id <id> | --folder-id + --filename [--output <path>]
drive upload <local_path> [--folder-id <id>] [--convert]
drive create-folder <name> [--parent-id <id>]
drive create-doc <name> [--folder-id <id>] [--content <text>]
drive share <file_id> --email <email> [--role reader|writer]
drive move <file_id> --to <folder_id>
drive delete <file_id> [--permanent]
━━━ Google Sheets ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
sheets info [--sheets-id <id>]
sheets read --range <A1:Z100> [--format table|json]
sheets write --range <A1> --values '[[...]]'
sheets append --sheet <name> --values '[...]'
sheets search --sheet <name> --query <text> [--format json]
sheets clear --range <range>
━━━ Memory ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
memory pull Sync Sheets memory → local cache
memory push "<entry>" Save entry to Sheets memory
memory search <query> Search memory entries
━━━ Tasks ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
task list [--list <id>] [--completed] [--format json]
task show <task_id>
task create "<title>" [--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

35
config/config.env.example Normal file
View file

@ -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

72
config/gate-policy.json Normal file
View file

@ -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"
]
}
}

770
config/scope-map.json Normal file
View file

@ -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更新"
]
}
}

229
docs/garc-architecture.md Normal file
View file

@ -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 |

131
docs/garc-vs-larc.md Normal file
View file

@ -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`全Drivevs `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が状況に応じてどちらのランタイムを使うか選択します。

View file

@ -0,0 +1,80 @@
# Google Cloud Console セットアップガイド
## 有効化するAPI一覧
Google Cloud Console (https://console.cloud.google.com/) で以下を有効化してください。
### 必須 API6種
| 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` | ドキュメント作成・編集 |
### 推奨 API2種
| 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. スコープは空でOKCLIが実行時に要求します
---
## サービスアカウント(ボット操作用・任意)
自動化・ヘッドレス実行には 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
# → 全項目が ✅ になることを確認
```

84
docs/gws-api-alignment.md Normal file
View file

@ -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.

164
docs/quickstart.md Normal file
View file

@ -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` でタブを再作成 |

74
lib/agent.sh Normal file
View file

@ -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}"
}

156
lib/approve.sh Normal file
View file

@ -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}"
}

79
lib/auth.sh Normal file
View file

@ -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
}

272
lib/bootstrap.sh Normal file
View file

@ -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
}

201
lib/calendar.sh Normal file
View file

@ -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
}

433
lib/daemon.sh Normal file
View file

@ -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}"
}

226
lib/drive.sh Normal file
View file

@ -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}
}

185
lib/gmail.sh Normal file
View file

@ -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
}

52
lib/heartbeat.sh Normal file
View file

@ -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}]"
}

644
lib/ingress.sh Normal file
View file

@ -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 Codereadable 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
}

123
lib/kg.sh Normal file
View file

@ -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')
"
}

117
lib/memory.sh Normal file
View file

@ -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 "$@"
}

150
lib/people.sh Normal file
View file

@ -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}"
}

79
lib/send.sh Normal file
View file

@ -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}"
}

186
lib/sheets.sh Normal file
View file

@ -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_}"
}

185
lib/task.sh Normal file
View file

@ -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
}

7
requirements.txt Normal file
View file

@ -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

257
scripts/garc-auth-helper.py Executable file
View file

@ -0,0 +1,257 @@
#!/usr/bin/env python3
"""
GARC Auth Helper OAuth scope inference and token management
Mirrors LARC's scope inference engine but for Google Workspace OAuth scopes
"""
import argparse
import json
import os
import sys
from pathlib import Path
GARC_DIR = Path(__file__).parent.parent
SCOPE_MAP_PATH = GARC_DIR / "config" / "scope-map.json"
GARC_CONFIG_DIR = Path.home() / ".garc"
TOKEN_FILE = Path(os.environ.get("GARC_TOKEN_FILE", str(GARC_CONFIG_DIR / "token.json")))
CREDENTIALS_FILE = Path(os.environ.get("GARC_CREDENTIALS_FILE", str(GARC_CONFIG_DIR / "credentials.json")))
def load_scope_map():
with open(SCOPE_MAP_PATH) as f:
return json.load(f)
def suggest_scopes(task_description: str):
"""Infer minimum OAuth scopes for a natural-language task description."""
scope_map = load_scope_map()
task_description_lower = task_description.lower()
matched_tasks = []
matched_scopes = set()
# Keyword pattern matching
for task_type, patterns in scope_map.get("keyword_patterns", {}).items():
for pattern in patterns:
if pattern.lower() in task_description_lower:
if task_type not in matched_tasks:
matched_tasks.append(task_type)
task_def = scope_map["tasks"].get(task_type, {})
for scope in task_def.get("scopes", []):
matched_scopes.add(scope)
if not matched_tasks:
print("No specific task types matched. General writer profile recommended.")
print("\nSuggested profile: writer")
profile = scope_map["profiles"]["writer"]
print(f"Description: {profile['description']}")
print("\nScopes:")
for scope in profile["scopes"]:
print(f" - {scope}")
return
print(f"Task analysis: \"{task_description}\"")
print(f"\nMatched task types: {', '.join(matched_tasks)}")
# Show gate policies
print("\nExecution gates:")
for task_type in matched_tasks:
task_def = scope_map["tasks"].get(task_type, {})
gate = task_def.get("gate", "none")
desc = task_def.get("description", "")
gate_icon = {"none": "", "preview": "⚠️", "approval": "🔒"}.get(gate, "")
print(f" {gate_icon} {task_type} ({gate}): {desc}")
print("\nRequired OAuth scopes:")
for scope in sorted(matched_scopes):
print(f" - {scope}")
# Identity type
identities = set()
for task_type in matched_tasks:
task_def = scope_map["tasks"].get(task_type, {})
identities.add(task_def.get("identity", "user_access_token"))
print(f"\nIdentity type: {', '.join(identities)}")
# Highest gate level
gate_order = {"none": 0, "preview": 1, "approval": 2}
max_gate = max(
(scope_map["tasks"].get(t, {}).get("gate", "none") for t in matched_tasks),
key=lambda g: gate_order.get(g, 0),
default="none"
)
gate_messages = {
"none": "✅ All operations are read-only. Can execute immediately.",
"preview": "⚠️ Some operations have external visibility. Use --confirm flag.",
"approval": "🔒 High-risk operations detected. Human approval required before execution."
}
print(f"\nGate requirement: {gate_messages.get(max_gate, '')}")
# Recommend profile
print("\nRecommended garc auth login command:")
if max_gate == "none":
print(" garc auth login --profile readonly")
elif max_gate == "preview":
print(" garc auth login --profile writer")
else:
print(" garc auth login --profile backoffice_agent")
def check_scopes(profile: str):
"""Check if current token has the required scopes for a profile."""
scope_map = load_scope_map()
if profile not in scope_map.get("profiles", {}):
print(f"Unknown profile: {profile}")
print(f"Available profiles: {', '.join(scope_map['profiles'].keys())}")
sys.exit(1)
required_scopes = set(scope_map["profiles"][profile]["scopes"])
if not TOKEN_FILE.exists():
print(f"No token file found at {TOKEN_FILE}")
print(f"Run: garc auth login --profile {profile}")
sys.exit(1)
try:
with open(TOKEN_FILE) as f:
token_data = json.load(f)
current_scopes = set(token_data.get("scopes", "").split() if isinstance(token_data.get("scopes"), str)
else token_data.get("scopes", []))
except (json.JSONDecodeError, KeyError):
print(f"Could not read token file: {TOKEN_FILE}")
sys.exit(1)
missing = required_scopes - current_scopes
if not missing:
print(f"✅ Current token satisfies '{profile}' profile requirements.")
print(f" Required: {len(required_scopes)} scopes — all present.")
else:
print(f"❌ Missing scopes for '{profile}' profile:")
for scope in sorted(missing):
print(f" - {scope}")
print(f"\nRun: garc auth login --profile {profile}")
def login(profile: str):
"""Launch OAuth2 authorization flow for the given profile."""
scope_map = load_scope_map()
if profile not in scope_map.get("profiles", {}):
print(f"Unknown profile: {profile}")
sys.exit(1)
scopes = scope_map["profiles"][profile]["scopes"]
description = scope_map["profiles"][profile]["description"]
print(f"OAuth2 authorization for profile: {profile}")
print(f"Description: {description}")
print(f"\nRequested scopes ({len(scopes)}):")
for scope in scopes:
print(f" - {scope}")
if not CREDENTIALS_FILE.exists():
print(f"\nError: credentials.json not found at {CREDENTIALS_FILE}")
print("Download it from Google Cloud Console → APIs & Services → Credentials")
print("OAuth 2.0 Client IDs → Download JSON → save as ~/.garc/credentials.json")
sys.exit(1)
try:
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
import google.oauth2.credentials
# Check if we have valid existing credentials
creds = None
if TOKEN_FILE.exists():
creds = google.oauth2.credentials.Credentials.from_authorized_user_file(str(TOKEN_FILE))
if creds and creds.valid:
print(f"\n✅ Already authenticated. Token file: {TOKEN_FILE}")
return
if creds and creds.expired and creds.refresh_token:
print("Refreshing expired token...")
creds.refresh(Request())
else:
flow = InstalledAppFlow.from_client_secrets_file(str(CREDENTIALS_FILE), scopes)
creds = flow.run_local_server(port=0)
GARC_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
with open(TOKEN_FILE, "w") as f:
f.write(creds.to_json())
print(f"\n✅ Token saved to {TOKEN_FILE}")
except ImportError:
print("\nNote: google-auth-oauthlib not installed.")
print("Install with: pip install google-auth-oauthlib google-api-python-client")
print("\nManual authorization URL would use scopes:")
for scope in scopes:
print(f" {scope}")
def show_status():
"""Show current token information."""
if not TOKEN_FILE.exists():
print(f"No token file found at {TOKEN_FILE}")
print("Run: garc auth login --profile writer")
return
try:
with open(TOKEN_FILE) as f:
token_data = json.load(f)
print(f"Token file: {TOKEN_FILE}")
print(f"Client ID: {token_data.get('client_id', 'N/A')[:20]}...")
scopes = token_data.get("scopes", [])
if isinstance(scopes, str):
scopes = scopes.split()
print(f"\nGranted scopes ({len(scopes)}):")
for scope in sorted(scopes):
short = scope.replace("https://www.googleapis.com/auth/", "")
print(f" - {short}")
expiry = token_data.get("expiry", "unknown")
print(f"\nExpiry: {expiry}")
except Exception as e:
print(f"Error reading token: {e}")
def main():
parser = argparse.ArgumentParser(description="GARC Auth Helper")
subparsers = parser.add_subparsers(dest="command")
# suggest
suggest_parser = subparsers.add_parser("suggest", help="Suggest scopes for a task")
suggest_parser.add_argument("task", nargs="+", help="Task description")
# check
check_parser = subparsers.add_parser("check", help="Check current token scopes")
check_parser.add_argument("--profile", default="writer", help="Profile to check against")
# login
login_parser = subparsers.add_parser("login", help="Launch OAuth2 flow")
login_parser.add_argument("--profile", default="writer", help="Profile to authorize")
# status
subparsers.add_parser("status", help="Show token status")
args = parser.parse_args()
if args.command == "suggest":
suggest_scopes(" ".join(args.task))
elif args.command == "check":
check_scopes(args.profile)
elif args.command == "login":
login(args.profile)
elif args.command == "status":
show_status()
else:
parser.print_help()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,332 @@
#!/usr/bin/env python3
"""
GARC Calendar Helper Full Google Calendar operations
list / create / update / delete / search / freebusy / quick-add
"""
import argparse
import json
import sys
from datetime import datetime, timezone, timedelta
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from garc_core import build_service, utc_now, with_retry
def get_svc():
return build_service("calendar", "v3")
def _parse_datetime(dt_str: str, tz: str = "Asia/Tokyo") -> dict:
"""Parse a datetime string into Google Calendar format."""
if "T" in dt_str or ":" in dt_str:
# Full datetime
if "T" not in dt_str:
dt_str = dt_str.replace(" ", "T")
return {"dateTime": dt_str, "timeZone": tz}
else:
# Date only
return {"date": dt_str}
def _format_event(event: dict) -> str:
"""Format an event for display."""
summary = event.get("summary", "(no title)")
start = event.get("start", {})
end = event.get("end", {})
start_str = start.get("dateTime", start.get("date", ""))[:16]
end_str = end.get("dateTime", end.get("date", ""))[:16]
location = event.get("location", "")
attendees = event.get("attendees", [])
attendee_str = f" ({len(attendees)} attendees)" if attendees else ""
loc_str = f" @ {location[:30]}" if location else ""
return f"[{event['id'][:10]}] {start_str}{end_str} {summary}{loc_str}{attendee_str}"
@with_retry()
def list_events(calendar_id: str = "primary", days_ahead: int = 7,
days_back: int = 0, max_results: int = 50, query: str = ""):
"""List calendar events."""
svc = get_svc()
now = datetime.now(timezone.utc)
time_min = (now - timedelta(days=days_back)).isoformat()
time_max = (now + timedelta(days=days_ahead)).isoformat()
kwargs = {
"calendarId": calendar_id,
"timeMin": time_min,
"timeMax": time_max,
"maxResults": max_results,
"singleEvents": True,
"orderBy": "startTime",
}
if query:
kwargs["q"] = query
result = svc.events().list(**kwargs).execute()
events = result.get("items", [])
if not events:
print(f"No events found" + (f" for: {query}" if query else ""))
return []
label = f"next {days_ahead}d" if not days_back else f"±{days_ahead}d"
print(f"Calendar events ({label}, {len(events)} results):")
print()
for event in events:
print(f" {_format_event(event)}")
return events
@with_retry()
def create_event(summary: str, start: str, end: str,
description: str = "", location: str = "",
attendees: list = None, calendar_id: str = "primary",
send_notifications: bool = True, all_day: bool = False,
recurrence: str = "", timezone: str = "Asia/Tokyo"):
"""Create a calendar event."""
svc = get_svc()
start_obj = {"date": start} if all_day else _parse_datetime(start, timezone)
end_obj = {"date": end} if all_day else _parse_datetime(end, timezone)
body = {
"summary": summary,
"start": start_obj,
"end": end_obj,
}
if description:
body["description"] = description
if location:
body["location"] = location
if attendees:
body["attendees"] = [{"email": a} for a in attendees]
if recurrence:
body["recurrence"] = [recurrence]
result = svc.events().insert(
calendarId=calendar_id,
body=body,
sendNotifications=send_notifications
).execute()
print(f"✅ Event created: {result['summary']}")
print(f" ID: {result['id']}")
print(f" Start: {result['start'].get('dateTime', result['start'].get('date', ''))}")
print(f" End: {result['end'].get('dateTime', result['end'].get('date', ''))}")
print(f" Link: {result.get('htmlLink', '')}")
return result
@with_retry()
def update_event(event_id: str, calendar_id: str = "primary", **updates):
"""Update a calendar event."""
svc = get_svc()
# Get existing event
event = svc.events().get(calendarId=calendar_id, eventId=event_id).execute()
if "summary" in updates:
event["summary"] = updates["summary"]
if "description" in updates:
event["description"] = updates["description"]
if "location" in updates:
event["location"] = updates["location"]
if "start" in updates:
event["start"] = _parse_datetime(updates["start"])
if "end" in updates:
event["end"] = _parse_datetime(updates["end"])
if "attendees_add" in updates:
existing = {a["email"] for a in event.get("attendees", [])}
for email in updates["attendees_add"]:
if email not in existing:
event.setdefault("attendees", []).append({"email": email})
result = svc.events().update(
calendarId=calendar_id, eventId=event_id, body=event
).execute()
print(f"✅ Event updated: {result['summary']}")
return result
@with_retry()
def delete_event(event_id: str, calendar_id: str = "primary"):
"""Delete a calendar event."""
svc = get_svc()
svc.events().delete(calendarId=calendar_id, eventId=event_id).execute()
print(f"✅ Event deleted: {event_id}")
@with_retry()
def get_event(event_id: str, calendar_id: str = "primary"):
"""Get event details."""
svc = get_svc()
event = svc.events().get(calendarId=calendar_id, eventId=event_id).execute()
print(f"Summary: {event.get('summary', '')}")
print(f"ID: {event['id']}")
start = event.get("start", {})
end = event.get("end", {})
print(f"Start: {start.get('dateTime', start.get('date', ''))}")
print(f"End: {end.get('dateTime', end.get('date', ''))}")
print(f"Location: {event.get('location', '')}")
print(f"Description: {event.get('description', '')}")
attendees = event.get("attendees", [])
if attendees:
print(f"Attendees ({len(attendees)}):")
for a in attendees:
status = a.get("responseStatus", "unknown")
status_icon = {"accepted": "", "declined": "", "tentative": "", "needsAction": ""}.get(status, "")
print(f" {status_icon} {a.get('email', '')} ({a.get('displayName', '')})")
print(f"Link: {event.get('htmlLink', '')}")
return event
@with_retry()
def freebusy(start: str, end: str, emails: list, timezone: str = "Asia/Tokyo"):
"""Check free/busy status for given email addresses."""
svc = get_svc()
body = {
"timeMin": start if "T" in start else f"{start}T00:00:00Z",
"timeMax": end if "T" in end else f"{end}T23:59:59Z",
"timeZone": timezone,
"items": [{"id": email} for email in emails]
}
result = svc.freebusy().query(body=body).execute()
calendars = result.get("calendars", {})
print(f"Free/Busy ({start}{end}):")
for email, data in calendars.items():
busy = data.get("busy", [])
if busy:
print(f"\n 🔴 {email} — BUSY ({len(busy)} slots):")
for slot in busy:
print(f" {slot['start'][:16]}{slot['end'][:16]}")
else:
print(f"\n{email} — FREE")
return result
@with_retry()
def quick_add(text: str, calendar_id: str = "primary"):
"""Quick add an event from natural language text."""
svc = get_svc()
result = svc.events().quickAdd(calendarId=calendar_id, text=text).execute()
print(f"✅ Quick add: {result.get('summary', '')}")
print(f" {result.get('start', {}).get('dateTime', '')[:16]}")
return result
@with_retry()
def list_calendars():
"""List all accessible calendars."""
svc = get_svc()
result = svc.calendarList().list().execute()
calendars = result.get("items", [])
print(f"Calendars ({len(calendars)}):")
for cal in calendars:
primary = " (primary)" if cal.get("primary") else ""
print(f" [{cal['id'][:30]:<30}] {cal['summary']}{primary}")
return calendars
def main():
parser = argparse.ArgumentParser(description="GARC Calendar Helper")
sub = parser.add_subparsers(dest="command")
# list
lp = sub.add_parser("list", help="List events")
lp.add_argument("--calendar", default="primary")
lp.add_argument("--days", type=int, default=7)
lp.add_argument("--back", type=int, default=0, help="Days to look back")
lp.add_argument("--max", type=int, default=50)
lp.add_argument("--query", default="")
# create
cp = sub.add_parser("create", help="Create event")
cp.add_argument("--summary", required=True)
cp.add_argument("--start", required=True)
cp.add_argument("--end", required=True)
cp.add_argument("--description", default="")
cp.add_argument("--location", default="")
cp.add_argument("--attendees", nargs="+", default=[])
cp.add_argument("--calendar", default="primary")
cp.add_argument("--no-notify", action="store_true")
cp.add_argument("--all-day", action="store_true")
cp.add_argument("--recurrence", default="")
cp.add_argument("--timezone", default="Asia/Tokyo")
# update
up = sub.add_parser("update", help="Update event")
up.add_argument("event_id")
up.add_argument("--summary")
up.add_argument("--description")
up.add_argument("--location")
up.add_argument("--start")
up.add_argument("--end")
up.add_argument("--add-attendees", nargs="+", dest="attendees_add", default=[])
up.add_argument("--calendar", default="primary")
# delete
dp = sub.add_parser("delete", help="Delete event")
dp.add_argument("event_id")
dp.add_argument("--calendar", default="primary")
# get
gp = sub.add_parser("get", help="Get event details")
gp.add_argument("event_id")
gp.add_argument("--calendar", default="primary")
# freebusy
fb = sub.add_parser("freebusy", help="Check free/busy")
fb.add_argument("--start", required=True)
fb.add_argument("--end", required=True)
fb.add_argument("--emails", nargs="+", required=True)
fb.add_argument("--timezone", default="Asia/Tokyo")
# quick-add
qa = sub.add_parser("quick-add", help="Quick add from natural language")
qa.add_argument("text")
qa.add_argument("--calendar", default="primary")
# calendars
sub.add_parser("calendars", help="List all calendars")
args = parser.parse_args()
if args.command == "list":
list_events(args.calendar, args.days, args.back, args.max, args.query)
elif args.command == "create":
create_event(args.summary, args.start, args.end, args.description,
args.location, args.attendees, args.calendar,
not args.no_notify, args.all_day, args.recurrence, args.timezone)
elif args.command == "update":
updates = {}
for k in ["summary", "description", "location", "start", "end"]:
v = getattr(args, k, None)
if v:
updates[k] = v
if args.attendees_add:
updates["attendees_add"] = args.attendees_add
update_event(args.event_id, args.calendar, **updates)
elif args.command == "delete":
delete_event(args.event_id, args.calendar)
elif args.command == "get":
get_event(args.event_id, args.calendar)
elif args.command == "freebusy":
freebusy(args.start, args.end, args.emails, args.timezone)
elif args.command == "quick-add":
quick_add(args.text, args.calendar)
elif args.command == "calendars":
list_calendars()
else:
parser.print_help()
if __name__ == "__main__":
main()

207
scripts/garc-core.py Normal file
View file

@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""
GARC Core Shared utilities: auth, service builders, retry, output formatting
"""
import json
import os
import sys
import time
import functools
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, Any
GARC_CONFIG_DIR = Path(os.environ.get("GARC_CONFIG_DIR", Path.home() / ".garc"))
TOKEN_FILE = Path(os.environ.get("GARC_TOKEN_FILE", GARC_CONFIG_DIR / "token.json"))
CREDENTIALS_FILE = Path(os.environ.get("GARC_CREDENTIALS_FILE", GARC_CONFIG_DIR / "credentials.json"))
SERVICE_ACCOUNT_FILE = Path(os.environ.get("GARC_SERVICE_ACCOUNT_FILE", GARC_CONFIG_DIR / "service_account.json"))
# All supported scopes for backoffice_agent profile
ALL_SCOPES = [
"https://www.googleapis.com/auth/drive",
"https://www.googleapis.com/auth/drive.file",
"https://www.googleapis.com/auth/documents",
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/gmail.send",
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/gmail.modify",
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/tasks",
"https://www.googleapis.com/auth/contacts.readonly",
"https://www.googleapis.com/auth/chat.messages",
"https://www.googleapis.com/auth/people.readonly",
]
PROFILE_SCOPES = {
"readonly": [
"https://www.googleapis.com/auth/drive.readonly",
"https://www.googleapis.com/auth/spreadsheets.readonly",
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/calendar.readonly",
"https://www.googleapis.com/auth/tasks.readonly",
"https://www.googleapis.com/auth/contacts.readonly",
"https://www.googleapis.com/auth/documents",
],
"writer": [
"https://www.googleapis.com/auth/drive.file",
"https://www.googleapis.com/auth/documents",
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/gmail.send",
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/tasks",
],
"backoffice_agent": ALL_SCOPES,
"admin": ALL_SCOPES + [
"https://www.googleapis.com/auth/admin.directory.user.readonly",
],
}
def get_credentials(scopes: Optional[list] = None, use_service_account: bool = False):
"""
Get valid Google credentials.
Tries: service account existing token OAuth flow
"""
if scopes is None:
scopes = ALL_SCOPES
# Service account path
if use_service_account and SERVICE_ACCOUNT_FILE.exists():
try:
from google.oauth2 import service_account
creds = service_account.Credentials.from_service_account_file(
str(SERVICE_ACCOUNT_FILE), scopes=scopes
)
return creds
except Exception as e:
print(f"⚠️ Service account error: {e}", file=sys.stderr)
# User OAuth token
try:
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
creds = None
if TOKEN_FILE.exists():
creds = Credentials.from_authorized_user_file(str(TOKEN_FILE), scopes)
if creds and creds.valid:
return creds
if creds and creds.expired and creds.refresh_token:
try:
creds.refresh(Request())
_save_token(creds)
return creds
except Exception as e:
print(f"⚠️ Token refresh failed: {e}", file=sys.stderr)
# Need fresh OAuth flow
if not CREDENTIALS_FILE.exists():
print(f"❌ credentials.json not found: {CREDENTIALS_FILE}", file=sys.stderr)
print(" Download from Google Cloud Console → APIs & Services → Credentials", file=sys.stderr)
print(" Run: garc auth login --profile backoffice_agent", file=sys.stderr)
sys.exit(1)
from google_auth_oauthlib.flow import InstalledAppFlow
flow = InstalledAppFlow.from_client_secrets_file(str(CREDENTIALS_FILE), scopes)
creds = flow.run_local_server(port=0, open_browser=True)
_save_token(creds)
return creds
except ImportError as e:
print(f"❌ Missing dependency: {e}", file=sys.stderr)
print(" Run: pip install -r requirements.txt", file=sys.stderr)
sys.exit(1)
def _save_token(creds):
"""Save credentials to token file."""
GARC_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
with open(TOKEN_FILE, "w") as f:
f.write(creds.to_json())
TOKEN_FILE.chmod(0o600)
def build_service(service_name: str, version: str, scopes: Optional[list] = None):
"""Build a Google API service with proper credentials."""
try:
from googleapiclient.discovery import build
creds = get_credentials(scopes)
return build(service_name, version, credentials=creds, cache_discovery=False)
except ImportError:
print("❌ google-api-python-client not installed", file=sys.stderr)
print(" Run: pip install -r requirements.txt", file=sys.stderr)
sys.exit(1)
def with_retry(max_retries: int = 3, backoff: float = 1.5):
"""Decorator: retry on transient Google API errors with exponential backoff."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_error = None
for attempt in range(max_retries):
try:
return func(*args, **kwargs)
except Exception as e:
error_str = str(e).lower()
# Retry on rate limit, server error
if any(code in error_str for code in ["429", "500", "503", "quota", "rate"]):
wait = backoff ** attempt
if attempt < max_retries - 1:
print(f" ⏳ Rate limit hit, waiting {wait:.1f}s...", file=sys.stderr)
time.sleep(wait)
last_error = e
continue
raise
raise last_error
return wrapper
return decorator
def utc_now() -> str:
"""Return current UTC time as ISO 8601 string."""
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def format_table(rows: list[dict], columns: list[str], max_width: int = 120) -> str:
"""Format a list of dicts as a simple table."""
if not rows:
return "(no results)"
# Calculate column widths
widths = {col: len(col) for col in columns}
for row in rows:
for col in columns:
val = str(row.get(col, ""))
widths[col] = min(max(widths[col], len(val)), 40)
header = " ".join(col.ljust(widths[col]) for col in columns)
sep = " ".join("" * widths[col] for col in columns)
lines = [header, sep]
for row in rows:
line = " ".join(str(row.get(col, "")).ljust(widths[col])[:widths[col]] for col in columns)
lines.append(line)
return "\n".join(lines)
def load_config() -> dict:
"""Load GARC config from environment / config.env file."""
config_file = GARC_CONFIG_DIR / "config.env"
config = {}
if config_file.exists():
with open(config_file) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, _, val = line.partition("=")
config[key.strip()] = val.strip()
# Override with env vars
for key in ["GARC_DRIVE_FOLDER_ID", "GARC_SHEETS_ID", "GARC_GMAIL_DEFAULT_TO",
"GARC_CALENDAR_ID", "GARC_CHAT_SPACE_ID", "GARC_DEFAULT_AGENT"]:
if os.environ.get(key):
config[key] = os.environ[key]
return config

522
scripts/garc-drive-helper.py Executable file
View file

@ -0,0 +1,522 @@
#!/usr/bin/env python3
"""
GARC Drive Helper Full Google Drive operations
list / search / download / upload / create-doc / create-folder / share / move / delete / kg-build
"""
import argparse
import io
import json
import mimetypes
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from garc_core import build_service, utc_now, with_retry
def get_svc():
return build_service("drive", "v3")
MIME_FOLDER = "application/vnd.google-apps.folder"
MIME_DOC = "application/vnd.google-apps.document"
MIME_SHEET = "application/vnd.google-apps.spreadsheet"
MIME_SLIDE = "application/vnd.google-apps.presentation"
def _format_file(f: dict) -> str:
kind = f.get("mimeType", "")
if MIME_FOLDER in kind:
icon = "📁"
elif "document" in kind:
icon = "📄"
elif "spreadsheet" in kind:
icon = "📊"
elif "presentation" in kind:
icon = "📺"
elif "image" in kind:
icon = "🖼️"
else:
icon = "📎"
size = f.get("size", "")
size_str = f" ({int(size):,}B)" if size else ""
mod = f.get("modifiedTime", "")[:10]
return f"{icon} [{f['id'][:12]}] {f['name']}{size_str} {mod}"
@with_retry()
def list_files(folder_id: str = "root", max_results: int = 50,
query: str = "", order_by: str = "modifiedTime desc"):
"""List files in a Drive folder."""
svc = get_svc()
q_parts = [f"'{folder_id}' in parents", "trashed = false"]
if query:
q_parts.append(f"name contains '{query}'")
result = svc.files().list(
q=" and ".join(q_parts),
pageSize=max_results,
orderBy=order_by,
fields="files(id,name,mimeType,size,modifiedTime,webViewLink,parents)"
).execute()
files = result.get("files", [])
if not files:
print(f"No files found in folder: {folder_id}")
return []
print(f"Files in {folder_id} ({len(files)}):")
for f in files:
print(f" {_format_file(f)}")
return files
@with_retry()
def search_files(query: str, max_results: int = 30, file_type: str = ""):
"""Search Drive files by name or content."""
svc = get_svc()
q_parts = ["trashed = false"]
q_parts.append(f"(name contains '{query}' or fullText contains '{query}')")
mime_map = {
"doc": MIME_DOC,
"sheet": MIME_SHEET,
"slide": MIME_SLIDE,
"folder": MIME_FOLDER,
"pdf": "application/pdf",
}
if file_type and file_type in mime_map:
q_parts.append(f"mimeType = '{mime_map[file_type]}'")
result = svc.files().list(
q=" and ".join(q_parts),
pageSize=max_results,
orderBy="modifiedTime desc",
fields="files(id,name,mimeType,size,modifiedTime,webViewLink)"
).execute()
files = result.get("files", [])
if not files:
print(f"No files found for: {query}")
return []
print(f"Search results for '{query}' ({len(files)}):")
for f in files:
print(f" {_format_file(f)}")
print(f" 🔗 {f.get('webViewLink', '')}")
return files
@with_retry()
def get_file_info(file_id: str):
"""Get detailed file information."""
svc = get_svc()
f = svc.files().get(
fileId=file_id,
fields="id,name,mimeType,size,createdTime,modifiedTime,webViewLink,parents,owners,shared,sharingUser"
).execute()
print(f"Name: {f['name']}")
print(f"ID: {f['id']}")
print(f"Type: {f['mimeType']}")
print(f"Size: {f.get('size', 'N/A')}")
print(f"Created: {f.get('createdTime', '')[:19]}")
print(f"Modified: {f.get('modifiedTime', '')[:19]}")
print(f"Shared: {f.get('shared', False)}")
print(f"Link: {f.get('webViewLink', '')}")
owners = f.get("owners", [])
if owners:
print(f"Owner: {owners[0].get('emailAddress', '')}")
return f
@with_retry()
def download_file(file_id: str = "", folder_id: str = "", filename: str = "",
output: str = ""):
"""Download a file from Google Drive."""
svc = get_svc()
# Find file by name in folder if file_id not given
if not file_id and folder_id and filename:
parts = filename.split("/")
current_folder = folder_id
for i, part in enumerate(parts):
q = f"'{current_folder}' in parents and name='{part}' and trashed=false"
results = svc.files().list(q=q, fields="files(id,mimeType)").execute()
files = results.get("files", [])
if not files:
print(f"Not found: {filename}", file=sys.stderr)
sys.exit(1)
if i == len(parts) - 1:
file_id = files[0]["id"]
mime_type = files[0]["mimeType"]
else:
current_folder = files[0]["id"]
if not file_id:
print("Error: provide --file-id or --folder-id + --filename", file=sys.stderr)
sys.exit(1)
# Get file info
f = svc.files().get(fileId=file_id, fields="name,mimeType").execute()
mime_type = f.get("mimeType", "")
out_path = Path(output) if output else Path(f["name"])
out_path.parent.mkdir(parents=True, exist_ok=True)
if "google-apps.document" in mime_type:
request = svc.files().export_media(fileId=file_id, mimeType="text/plain")
elif "google-apps.spreadsheet" in mime_type:
request = svc.files().export_media(fileId=file_id,
mimeType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
if not str(out_path).endswith(".xlsx"):
out_path = Path(str(out_path) + ".xlsx")
elif "google-apps" in mime_type:
request = svc.files().export_media(fileId=file_id, mimeType="application/pdf")
if not str(out_path).endswith(".pdf"):
out_path = Path(str(out_path) + ".pdf")
else:
request = svc.files().get_media(fileId=file_id)
from googleapiclient.http import MediaIoBaseDownload
fh = io.BytesIO()
downloader = MediaIoBaseDownload(fh, request)
done = False
while not done:
_, done = downloader.next_chunk()
with open(out_path, "wb") as out:
out.write(fh.getvalue())
print(f"✅ Downloaded: {out_path} ({len(fh.getvalue()):,} bytes)")
return str(out_path)
@with_retry()
def upload_file(local_path: str, folder_id: str = "root",
name: str = "", convert: bool = False):
"""Upload a local file to Google Drive."""
svc = get_svc()
local = Path(local_path)
if not local.exists():
print(f"File not found: {local_path}", file=sys.stderr)
sys.exit(1)
file_name = name or local.name
mime_type, _ = mimetypes.guess_type(str(local))
mime_type = mime_type or "application/octet-stream"
# Convert to Google format if requested
convert_mime = None
if convert:
if local.suffix in (".docx", ".doc", ".txt", ".md"):
convert_mime = MIME_DOC
elif local.suffix in (".xlsx", ".xls", ".csv"):
convert_mime = MIME_SHEET
from googleapiclient.http import MediaFileUpload
media = MediaFileUpload(str(local), mimetype=mime_type, resumable=True)
body = {"name": file_name, "parents": [folder_id]}
if convert_mime:
body["mimeType"] = convert_mime
result = svc.files().create(
body=body, media_body=media, fields="id,name,webViewLink"
).execute()
print(f"✅ Uploaded: {result['name']}")
print(f" ID: {result['id']}")
print(f" Link: {result.get('webViewLink', '')}")
return result
@with_retry()
def create_folder(name: str, parent_id: str = "root"):
"""Create a folder in Google Drive."""
svc = get_svc()
result = svc.files().create(body={
"name": name,
"mimeType": MIME_FOLDER,
"parents": [parent_id]
}, fields="id,name,webViewLink").execute()
print(f"✅ Folder created: {result['name']}")
print(f" ID: {result['id']}")
print(f" Link: {result.get('webViewLink', '')}")
return result
@with_retry()
def create_doc(name: str, folder_id: str = "root", content: str = ""):
"""Create a Google Doc (optionally with initial content)."""
svc_drive = get_svc()
svc_docs = build_service("docs", "v1")
# Create empty Doc via Drive
result = svc_drive.files().create(body={
"name": name,
"mimeType": MIME_DOC,
"parents": [folder_id]
}, fields="id,name,webViewLink").execute()
doc_id = result["id"]
# Add initial content if provided
if content:
svc_docs.documents().batchUpdate(
documentId=doc_id,
body={"requests": [{"insertText": {"location": {"index": 1}, "text": content}}]}
).execute()
print(f"✅ Doc created: {name}")
print(f" ID: {doc_id}")
print(f" Link: {result.get('webViewLink', '')}")
return result
@with_retry()
def share_file(file_id: str, email: str, role: str = "reader",
send_notification: bool = True):
"""Share a file with a user."""
svc = get_svc()
valid_roles = ["reader", "writer", "commenter", "owner"]
if role not in valid_roles:
print(f"Invalid role. Choose: {', '.join(valid_roles)}", file=sys.stderr)
sys.exit(1)
result = svc.permissions().create(
fileId=file_id,
body={"type": "user", "role": role, "emailAddress": email},
sendNotificationEmail=send_notification,
fields="id,emailAddress,role"
).execute()
print(f"✅ Shared with {email} as {role}")
return result
@with_retry()
def move_file(file_id: str, new_folder_id: str):
"""Move a file to a different folder."""
svc = get_svc()
# Get current parents
f = svc.files().get(fileId=file_id, fields="parents").execute()
current_parents = ",".join(f.get("parents", []))
result = svc.files().update(
fileId=file_id,
addParents=new_folder_id,
removeParents=current_parents,
fields="id,name,parents"
).execute()
print(f"✅ Moved: {result.get('name', file_id)}{new_folder_id}")
return result
@with_retry()
def delete_file(file_id: str, permanent: bool = False):
"""Delete or trash a file."""
svc = get_svc()
if permanent:
svc.files().delete(fileId=file_id).execute()
print(f"✅ Permanently deleted: {file_id}")
else:
svc.files().update(fileId=file_id, body={"trashed": True}).execute()
print(f"✅ Moved to trash: {file_id}")
@with_retry()
def kg_build(folder_id: str, output: str, depth: int = 3):
"""Build knowledge graph from Drive folder."""
svc = get_svc()
svc_docs = build_service("docs", "v1")
print(f"Building knowledge graph from: {folder_id} (depth={depth})")
nodes = []
visited = set()
import re
def crawl(fid: str, level: int = 0):
if level > depth or fid in visited:
return
visited.add(fid)
try:
q = f"'{fid}' in parents and trashed = false"
results = svc.files().list(
q=q, pageSize=50,
fields="files(id,name,mimeType,modifiedTime,webViewLink)"
).execute()
files = results.get("files", [])
except Exception as e:
return
for f in files:
file_id = f["id"]
if MIME_FOLDER in f["mimeType"]:
crawl(file_id, level + 1)
continue
if MIME_DOC not in f["mimeType"]:
continue
content_preview = ""
links = []
try:
req = svc.files().export_media(fileId=file_id, mimeType="text/plain")
fh = io.BytesIO()
from googleapiclient.http import MediaIoBaseDownload
dl = MediaIoBaseDownload(fh, req)
done = False
while not done:
_, done = dl.next_chunk()
content = fh.getvalue().decode("utf-8", errors="replace")
content_preview = content[:800]
links = list(set(re.findall(
r'docs\.google\.com/document/d/([a-zA-Z0-9_-]{10,})', content
)))
except Exception:
pass
nodes.append({
"doc_id": file_id,
"title": f["name"],
"mime_type": f["mimeType"],
"modified_time": f.get("modifiedTime", ""),
"web_link": f.get("webViewLink", ""),
"content_preview": content_preview,
"links": links,
"depth": level,
})
indent = " " * level
print(f" {indent}{f['name']} ({len(links)} links)")
crawl(folder_id)
import datetime
kg = {
"built_at": utc_now(),
"folder_id": folder_id,
"node_count": len(nodes),
"nodes": nodes,
}
out = Path(output)
out.parent.mkdir(parents=True, exist_ok=True)
with open(out, "w") as fp:
json.dump(kg, fp, ensure_ascii=False, indent=2)
print(f"\n✅ Knowledge graph: {len(nodes)} docs indexed → {output}")
return kg
def main():
parser = argparse.ArgumentParser(description="GARC Drive Helper")
sub = parser.add_subparsers(dest="command")
# list
lp = sub.add_parser("list", help="List files in folder")
lp.add_argument("--folder-id", default="root")
lp.add_argument("--max", type=int, default=50)
lp.add_argument("--query", default="")
# search
sp = sub.add_parser("search", help="Search files")
sp.add_argument("query")
sp.add_argument("--max", type=int, default=30)
sp.add_argument("--type", choices=["doc", "sheet", "slide", "folder", "pdf"], default="")
# info
ip = sub.add_parser("info", help="Get file info")
ip.add_argument("file_id")
# download
dp = sub.add_parser("download", help="Download file")
dp.add_argument("--file-id", default="")
dp.add_argument("--folder-id", default="")
dp.add_argument("--filename", default="")
dp.add_argument("--output", default="")
# upload
up = sub.add_parser("upload", help="Upload file")
up.add_argument("local_path")
up.add_argument("--folder-id", default="root")
up.add_argument("--name", default="")
up.add_argument("--convert", action="store_true", help="Convert to Google format")
# create-folder
cf = sub.add_parser("create-folder", help="Create folder")
cf.add_argument("name")
cf.add_argument("--parent-id", default="root")
# create-doc
cd = sub.add_parser("create-doc", help="Create Google Doc")
cd.add_argument("name")
cd.add_argument("--folder-id", default="root")
cd.add_argument("--content", default="")
# share
sh = sub.add_parser("share", help="Share file")
sh.add_argument("file_id")
sh.add_argument("--email", required=True)
sh.add_argument("--role", default="reader", choices=["reader", "writer", "commenter", "owner"])
sh.add_argument("--no-notify", action="store_true")
# move
mv = sub.add_parser("move", help="Move file to folder")
mv.add_argument("file_id")
mv.add_argument("--to", required=True, dest="new_folder_id")
# delete
delp = sub.add_parser("delete", help="Delete/trash file")
delp.add_argument("file_id")
delp.add_argument("--permanent", action="store_true")
# kg-build
kb = sub.add_parser("kg-build", help="Build knowledge graph")
kb.add_argument("--folder-id", required=True)
kb.add_argument("--output", required=True)
kb.add_argument("--depth", type=int, default=3)
args = parser.parse_args()
if args.command == "list":
list_files(args.folder_id, args.max, args.query)
elif args.command == "search":
search_files(args.query, args.max, args.type)
elif args.command == "info":
get_file_info(args.file_id)
elif args.command == "download":
download_file(args.file_id, args.folder_id, args.filename, args.output)
elif args.command == "upload":
upload_file(args.local_path, args.folder_id, args.name, args.convert)
elif args.command == "create-folder":
create_folder(args.name, args.parent_id)
elif args.command == "create-doc":
create_doc(args.name, args.folder_id, args.content)
elif args.command == "share":
share_file(args.file_id, args.email, args.role, not args.no_notify)
elif args.command == "move":
move_file(args.file_id, args.new_folder_id)
elif args.command == "delete":
delete_file(args.file_id, args.permanent)
elif args.command == "kg-build":
kg_build(args.folder_id, args.output, args.depth)
else:
parser.print_help()
if __name__ == "__main__":
main()

316
scripts/garc-gmail-helper.py Executable file
View file

@ -0,0 +1,316 @@
#!/usr/bin/env python3
"""
GARC Gmail Helper Full Gmail operations
send / search / read / list / draft / thread / label / reply / forward
"""
import argparse
import base64
import json
import sys
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from garc_core import build_service, utc_now, with_retry
def get_svc():
return build_service("gmail", "v1")
@with_retry()
def send_email(to: str, subject: str, body: str, cc: str = "",
bcc: str = "", html: bool = False, reply_to: str = ""):
"""Send an email via Gmail."""
svc = get_svc()
if html:
msg = MIMEMultipart("alternative")
msg.attach(MIMEText(body, "html"))
else:
msg = MIMEText(body, "plain")
msg["to"] = to
msg["subject"] = subject
if cc:
msg["cc"] = cc
if bcc:
msg["bcc"] = bcc
if reply_to:
msg["reply-to"] = reply_to
raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
result = svc.users().messages().send(userId="me", body={"raw": raw}).execute()
print(f"✅ Email sent")
print(f" To: {to}")
print(f" Subject: {subject}")
print(f" ID: {result['id']}")
return result
@with_retry()
def reply_to_thread(thread_id: str, message_id: str, to: str, subject: str, body: str):
"""Reply to an existing Gmail thread."""
svc = get_svc()
msg = MIMEText(body, "plain")
msg["to"] = to
msg["subject"] = subject if subject.startswith("Re:") else f"Re: {subject}"
msg["in-reply-to"] = message_id
msg["references"] = message_id
raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
result = svc.users().messages().send(userId="me", body={
"raw": raw, "threadId": thread_id
}).execute()
print(f"✅ Reply sent (thread: {thread_id[:12]})")
return result
@with_retry()
def search_emails(query: str, max_results: int = 20, include_body: bool = False):
"""Search Gmail messages."""
svc = get_svc()
result = svc.users().messages().list(
userId="me", q=query, maxResults=max_results
).execute()
messages = result.get("messages", [])
if not messages:
print(f"No results for: {query}")
return []
print(f"Found {len(messages)} messages for: {query}")
print()
detailed = []
for m in messages:
msg = svc.users().messages().get(
userId="me", id=m["id"],
format="full" if include_body else "metadata",
metadataHeaders=["From", "To", "Subject", "Date"]
).execute()
headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])}
entry = {
"id": msg["id"],
"thread_id": msg["threadId"],
"subject": headers.get("Subject", "(no subject)"),
"from": headers.get("From", ""),
"to": headers.get("To", ""),
"date": headers.get("Date", ""),
"labels": msg.get("labelIds", []),
"snippet": msg.get("snippet", ""),
}
if include_body:
entry["body"] = _extract_body(msg.get("payload", {}))
detailed.append(entry)
print(f" [{entry['id'][:10]}] {entry['subject'][:50]}")
print(f" From: {entry['from'][:50]} Date: {entry['date'][:24]}")
if entry["snippet"]:
print(f" {entry['snippet'][:100]}...")
print()
return detailed
@with_retry()
def read_email(message_id: str):
"""Read a specific email message."""
svc = get_svc()
msg = svc.users().messages().get(
userId="me", id=message_id, format="full"
).execute()
headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])}
body = _extract_body(msg.get("payload", {}))
print(f"Subject: {headers.get('Subject', '(no subject)')}")
print(f"From: {headers.get('From', '')}")
print(f"To: {headers.get('To', '')}")
print(f"Date: {headers.get('Date', '')}")
print(f"Labels: {', '.join(msg.get('labelIds', []))}")
print()
print("" * 60)
print(body)
return {"headers": headers, "body": body, "id": message_id}
@with_retry()
def list_inbox(max_results: int = 20, label: str = "INBOX", unread_only: bool = False, format_: str = "table"):
"""List inbox messages."""
import json as _json
svc = get_svc()
q = "is:unread" if unread_only else f"label:{label}"
result = svc.users().messages().list(userId="me", q=q, maxResults=max_results).execute()
message_ids = [m["id"] for m in result.get("messages", [])]
messages = []
for msg_id in message_ids:
msg = svc.users().messages().get(userId="me", id=msg_id, format="metadata",
metadataHeaders=["From", "Subject", "Date"]).execute()
headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])}
messages.append({
"id": msg["id"],
"from": headers.get("From", ""),
"subject": headers.get("Subject", "(no subject)"),
"date": headers.get("Date", ""),
"snippet": msg.get("snippet", "")[:100],
})
if format_ == "json":
print(_json.dumps(messages, ensure_ascii=False, indent=2))
return messages
print(f"Inbox {'(unread) ' if unread_only else ''}({len(messages)}):")
for m in messages:
sender = m["from"][:30]
subj = m["subject"][:45]
print(f" [{m['id'][:12]}] {sender:<30} {subj}")
return messages
@with_retry()
def create_draft(to: str, subject: str, body: str, cc: str = ""):
"""Create a Gmail draft."""
svc = get_svc()
msg = MIMEText(body, "plain")
msg["to"] = to
msg["subject"] = subject
if cc:
msg["cc"] = cc
raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
result = svc.users().drafts().create(
userId="me", body={"message": {"raw": raw}}
).execute()
print(f"✅ Draft created: {result['id']}")
return result
@with_retry()
def list_labels():
"""List all Gmail labels."""
svc = get_svc()
result = svc.users().labels().list(userId="me").execute()
labels = result.get("labels", [])
print(f"Gmail labels ({len(labels)}):")
for label in sorted(labels, key=lambda x: x["name"]):
print(f" [{label['id'][:15]:<15}] {label['name']}")
@with_retry()
def get_profile():
"""Get Gmail account profile."""
svc = get_svc()
profile = svc.users().getProfile(userId="me").execute()
print(f"Gmail: {profile['emailAddress']}")
print(f"Messages: {profile.get('messagesTotal', 'N/A'):,}")
print(f"Threads: {profile.get('threadsTotal', 'N/A'):,}")
return profile
def _extract_body(payload: dict, prefer_plain: bool = True) -> str:
"""Recursively extract email body text."""
mime_type = payload.get("mimeType", "")
body_data = payload.get("body", {}).get("data", "")
if body_data:
text = base64.urlsafe_b64decode(body_data + "==").decode("utf-8", errors="replace")
if prefer_plain and mime_type == "text/plain":
return text
if not prefer_plain and mime_type == "text/html":
return text
if mime_type in ("text/plain", "text/html"):
return text
for part in payload.get("parts", []):
result = _extract_body(part, prefer_plain)
if result:
return result
return ""
def main():
parser = argparse.ArgumentParser(description="GARC Gmail Helper")
sub = parser.add_subparsers(dest="command")
# send
sp = sub.add_parser("send", help="Send email")
sp.add_argument("--to", required=True)
sp.add_argument("--subject", required=True)
sp.add_argument("--body", required=True)
sp.add_argument("--cc", default="")
sp.add_argument("--bcc", default="")
sp.add_argument("--html", action="store_true")
sp.add_argument("--reply-to", default="")
# reply
rp = sub.add_parser("reply", help="Reply to thread")
rp.add_argument("--thread-id", required=True)
rp.add_argument("--message-id", required=True)
rp.add_argument("--to", required=True)
rp.add_argument("--subject", required=True)
rp.add_argument("--body", required=True)
# search
sp2 = sub.add_parser("search", help="Search emails")
sp2.add_argument("query")
sp2.add_argument("--max", type=int, default=20)
sp2.add_argument("--body", action="store_true", help="Include body")
# read
rp2 = sub.add_parser("read", help="Read email")
rp2.add_argument("message_id")
# inbox
ip = sub.add_parser("inbox", help="List inbox")
ip.add_argument("--max", type=int, default=20)
ip.add_argument("--unread", action="store_true")
ip.add_argument("--format", dest="format_", default="table", choices=["table", "json"])
# draft
dp = sub.add_parser("draft", help="Create draft")
dp.add_argument("--to", required=True)
dp.add_argument("--subject", required=True)
dp.add_argument("--body", required=True)
dp.add_argument("--cc", default="")
# labels
sub.add_parser("labels", help="List labels")
# profile
sub.add_parser("profile", help="Show account profile")
args = parser.parse_args()
if args.command == "send":
send_email(args.to, args.subject, args.body, args.cc, args.bcc, args.html, args.reply_to)
elif args.command == "reply":
reply_to_thread(args.thread_id, args.message_id, args.to, args.subject, args.body)
elif args.command == "search":
search_emails(args.query, args.max, args.body)
elif args.command == "read":
read_email(args.message_id)
elif args.command == "inbox":
list_inbox(args.max, unread_only=args.unread, format_=args.format_)
elif args.command == "draft":
create_draft(args.to, args.subject, args.body, args.cc)
elif args.command == "labels":
list_labels()
elif args.command == "profile":
get_profile()
else:
parser.print_help()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,454 @@
#!/usr/bin/env python3
"""
GARC Ingress Helper Queue payload builder + Claude Code execution bridge
Commands:
build-payload --text <msg> [--source <src>] [--sender <email>] [--agent <id>]
execute-stub --queue-file <path>
build-prompt --queue-file <path> [--agent-context <path>]
stats --queue-dir <path>
"""
import argparse
import json
import os
import sys
import hashlib
import time
from pathlib import Path
from datetime import datetime, timezone
GARC_DIR = Path(__file__).parent.parent
SCOPE_MAP_PATH = GARC_DIR / "config" / "scope-map.json"
GATE_POLICY_PATH = GARC_DIR / "config" / "gate-policy.json"
# ─────────────────────────────────────────────────────────────────
# Task type → GARC CLI tool mapping
# This is the GWS equivalent of LARC's TASK_OPENCLAW_TOOLS
# ─────────────────────────────────────────────────────────────────
TASK_GARC_TOOLS: dict[str, list[str]] = {
"send_email": ["garc gmail send --to <recipient> --subject <subject> --body <body>"],
"reply_email": ["garc gmail search <query>", "garc gmail read <message_id>", "garc gmail send --to <sender> --subject <Re: subject> --body <body>"],
"read_email": ["garc gmail inbox --unread", "garc gmail search <query>", "garc gmail read <message_id>"],
"search_email": ["garc gmail search <query> --max 10"],
"draft_email": ["garc gmail draft --to <recipient> --subject <subject> --body <body>"],
"read_document": ["garc drive search <query>", "garc drive download --file-id <id> --output /tmp/doc.txt"],
"create_document": ["garc drive create-doc <title>", "garc drive upload <path>"],
"update_document": ["garc drive download --file-id <id>", "# edit locally", "garc drive upload <path>"],
"search_document": ["garc drive search <query> --type doc"],
"read_spreadsheet": ["garc sheets read --range <Sheet!A:Z>", "garc sheets search --sheet <name> --query <text>"],
"write_spreadsheet": ["garc sheets append --sheet <name> --values '[\"v1\",\"v2\"]'", "garc sheets write --range <A1> --values '[[...]]'"],
"read_calendar": ["garc calendar today", "garc calendar list --days <N>", "garc calendar list --query <keyword>"],
"create_event": ["garc calendar freebusy --start <date> --end <date> --emails <attendees>", "garc calendar create --summary <title> --start <dt> --end <dt> --attendees <emails>"],
"update_event": ["garc calendar list --query <keyword>", "garc calendar update <event_id> --summary <new_title>"],
"delete_event": ["garc calendar list --query <keyword>", "garc calendar delete <event_id>"],
"check_availability": ["garc calendar freebusy --start <date> --end <date> --emails <emails>"],
"create_task": ["garc task create \"<title>\" --due <YYYY-MM-DD> --notes <notes>"],
"update_task": ["garc task list", "garc task update <task_id> --due <date>"],
"complete_task": ["garc task list", "garc task done <task_id>"],
"read_tasks": ["garc task list", "garc task list --completed"],
"upload_file": ["garc drive upload <local_path> --folder-id <id>"],
"download_file": ["garc drive search <query>", "garc drive download --file-id <id> --output <path>"],
"share_file": ["garc drive share <file_id> --email <email> --role writer"],
"create_folder": ["garc drive create-folder <name>"],
"search_contact": ["garc people search <name>", "garc people lookup <name>"],
"read_contact": ["garc people show <contact_id>"],
"create_contact": ["garc people create --name <name> --email <email> --company <company>"],
"write_memory": ["garc memory push \"<entry>\""],
"read_memory": ["garc memory search <query>", "garc memory pull"],
"create_expense": ["garc sheets append --sheet approval --values '[\"expense\",\"<amount>\",\"<desc>\",\"pending\"]'", "garc approve create \"expense: <description>\"", "garc gmail send --to <approver> --subject \"[GARC] Expense Approval Required\""],
"submit_approval": ["garc approve create \"<task description>\"", "garc approve list"],
"read_approval": ["garc approve list"],
"register_agent": ["garc agent register", "garc agent list"],
"read_agent": ["garc agent list", "garc agent show <agent_id>"],
}
# Task description templates for execute-stub output
TASK_PLANS: dict[str, str] = {
"send_email": "Compose and send an email to the target recipient(s).",
"reply_email": "Find the original email thread and compose a reply.",
"read_email": "Search and read relevant emails from Gmail.",
"search_email": "Search Gmail for emails matching the criteria.",
"draft_email": "Prepare an email draft without sending.",
"read_document": "Search for and read the target document from Google Drive.",
"create_document": "Create a new document or file in Google Drive.",
"update_document": "Download, modify, and re-upload the target document.",
"search_document": "Search Google Drive for documents matching the criteria.",
"read_spreadsheet": "Read data from the target Google Sheet.",
"write_spreadsheet": "Write or append data to the target Google Sheet.",
"read_calendar": "Retrieve calendar events for the specified time range.",
"create_event": "Check availability and create a calendar event.",
"update_event": "Find and update the target calendar event.",
"delete_event": "Find and delete the target calendar event.",
"check_availability": "Query free/busy status for the specified attendees.",
"create_task": "Create a new task in Google Tasks.",
"update_task": "Find and update the target task.",
"complete_task": "Find and mark the target task as completed.",
"read_tasks": "List current Google Tasks.",
"upload_file": "Upload a local file to Google Drive.",
"download_file": "Search for and download a file from Google Drive.",
"share_file": "Share a Google Drive file with the specified user.",
"create_folder": "Create a new folder in Google Drive.",
"search_contact": "Search Google Contacts for the specified person.",
"read_contact": "Get full contact details.",
"create_contact": "Create a new contact in Google People.",
"write_memory": "Save an important context entry to agent memory (Google Sheets).",
"read_memory": "Search or sync agent memory from Google Sheets.",
"create_expense": "Prepare expense record, create approval request, and notify approver.",
"submit_approval": "Create an approval request and notify the approver via Gmail.",
"read_approval": "List pending approval requests.",
"register_agent": "Register a new agent in the GARC agent registry.",
"read_agent": "List or show agent details from the registry.",
}
# ─────────────────────────────────────────────────────────────────
# Payload builder
# ─────────────────────────────────────────────────────────────────
def load_scope_map() -> dict:
if not SCOPE_MAP_PATH.exists():
return {}
with open(SCOPE_MAP_PATH) as f:
return json.load(f)
def load_gate_policy() -> dict:
if not GATE_POLICY_PATH.exists():
return {}
with open(GATE_POLICY_PATH) as f:
return json.load(f)
def infer_task_types(text: str, scope_map: dict) -> list[str]:
"""Match text against scope-map keyword patterns."""
text_lower = text.lower()
matched = []
# scope-map.json uses "keyword_patterns" key
patterns = scope_map.get("keyword_patterns", scope_map.get("patterns", {}))
for task_type, keywords in patterns.items():
for kw in keywords:
if kw.lower() in text_lower:
if task_type not in matched:
matched.append(task_type)
break
return matched if matched else [] # empty = unknown, caller decides fallback
def infer_gate(task_types: list[str], gate_policy: dict) -> str:
"""Return the highest-risk gate for the given task types."""
gates = gate_policy.get("gates", {})
highest = "none"
order = ["none", "preview", "approval"]
for task in task_types:
for gate_name, gate_data in gates.items():
if task in gate_data.get("tasks", []):
if order.index(gate_name) > order.index(highest):
highest = gate_name
return highest
def infer_scopes(task_types: list[str], scope_map: dict) -> list[str]:
"""Collect all OAuth scopes needed for the task types."""
tasks = scope_map.get("tasks", {})
scopes: set[str] = set()
for task in task_types:
if task in tasks:
scopes.update(tasks[task].get("scopes", []))
return sorted(scopes)
def build_queue_id(text: str) -> str:
digest = hashlib.sha256(f"{text}{time.time()}".encode()).hexdigest()
return digest[:8]
def build_payload(text: str, source: str = "manual", sender: str = "", agent: str = "main") -> dict:
scope_map = load_scope_map()
gate_policy = load_gate_policy()
task_types = infer_task_types(text, scope_map)
gate = infer_gate(task_types, gate_policy)
scopes = infer_scopes(task_types, scope_map)
# authority: who is sending this request
authority = "human_operator" if source == "manual" else "gmail_trigger"
return {
"queue_id": build_queue_id(text),
"message_text": text,
"source": source,
"sender": sender,
"agent_id": agent,
"task_types": task_types,
"scopes": scopes,
"gate": gate,
"authority": authority,
"status": "pending",
"created_at": datetime.now(timezone.utc).isoformat(),
"updated_at": None,
"approval_id": None,
"session_id": None,
"note": "",
}
def cmd_build_payload(args):
payload = build_payload(args.text, args.source, args.sender, args.agent)
print()
print(" Queue item preview")
print(f" queue_id: {payload['queue_id']}")
print(f" agent_id: {payload['agent_id']}")
print(f" source: {payload['source']}")
print(f" sender: {payload['sender'] or '-'}")
print(f" task_types: {', '.join(payload['task_types']) if payload['task_types'] else '(none matched)'}")
print(f" scopes: {len(payload['scopes'])} scope(s)")
print(f" gate: {payload['gate']}")
print(f" status: {payload['status']}")
print()
print(f"Queued: {payload['queue_id']}")
print(f" status: {payload['status']}")
print(f" gate: {payload['gate']}")
print(f" tasks: {', '.join(payload['task_types']) if payload['task_types'] else '(none matched)'}")
return payload
# ─────────────────────────────────────────────────────────────────
# Execute stub — maps queue item to execution plan
# ─────────────────────────────────────────────────────────────────
def cmd_execute_stub(args):
"""Generate an execution plan from a queue item."""
queue_file = Path(args.queue_file)
if not queue_file.exists():
print(f"Error: queue file not found: {queue_file}", file=sys.stderr)
sys.exit(1)
with open(queue_file) as f:
q = json.loads(f.readline().strip())
task_types = q.get("task_types", [])
message = q.get("message_text", q.get("message", ""))
queue_id = q.get("queue_id", "")
gate = q.get("gate", "preview")
agent_id = q.get("agent_id", q.get("agent", "main"))
print()
print("=" * 60)
print("GARC Execution Stub")
print("=" * 60)
print(f"Queue ID: {queue_id}")
print(f"Agent: {agent_id}")
print(f"Gate: {gate}")
print(f"Task types: {', '.join(task_types) if task_types else '(generic)'}")
print()
print("Task:")
print(f" {message}")
print()
# Step-by-step plan
print("Execution plan:")
print("-" * 40)
step = 1
seen_tools: set[str] = set()
for task in task_types:
plan = TASK_PLANS.get(task, f"Execute {task} operation.")
tools = TASK_GARC_TOOLS.get(task, [])
print(f"[Step {step}] {plan}")
for tool in tools:
if tool not in seen_tools:
print(f"{tool}")
seen_tools.add(tool)
step += 1
print()
if not task_types:
print("[Step 1] Execute the requested task using available GARC tools.")
print(" → garc gmail send / garc drive search / garc sheets read / ...")
print()
print("-" * 40)
print("When complete, run:")
print(f" garc ingress done --queue-id {queue_id} --note \"<what was done>\"")
print(f" (or: garc ingress fail --queue-id {queue_id} --note \"<reason>\")")
# ─────────────────────────────────────────────────────────────────
# Build prompt — Claude Code readable output
# ─────────────────────────────────────────────────────────────────
def cmd_build_prompt(args):
"""Build a Claude Codeready 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()

View file

@ -0,0 +1,392 @@
#!/usr/bin/env python3
"""
GARC People Helper Google People API (Contacts & Directory)
search / list / show / create / update / delete
"""
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from garc_core import build_service, with_retry
PERSON_FIELDS = "names,emailAddresses,phoneNumbers,organizations,addresses,biographies"
CONTACT_SCOPES = [
"https://www.googleapis.com/auth/contacts",
"https://www.googleapis.com/auth/directory.readonly",
]
def get_service():
return build_service("people", "v1", scopes=CONTACT_SCOPES)
def _fmt_person(p: dict, short: bool = False) -> str:
"""Format a person resource as a readable string."""
name = p.get("names", [{}])[0].get("displayName", "(no name)")
emails = [e.get("value", "") for e in p.get("emailAddresses", [])]
phones = [ph.get("value", "") for ph in p.get("phoneNumbers", [])]
orgs = [o.get("name", "") for o in p.get("organizations", [])]
resource = p.get("resourceName", "")
short_id = resource.split("/")[-1] if "/" in resource else resource
if short:
email_str = emails[0] if emails else ""
org_str = f" ({orgs[0]})" if orgs else ""
return f"[{short_id[:10]}] {name}{org_str}{email_str}"
lines = [f"Name: {name}", f"ID: {short_id}"]
for e in emails:
lines.append(f"Email: {e}")
for ph in phones:
lines.append(f"Phone: {ph}")
for o in p.get("organizations", []):
org_parts = [x for x in [o.get("name"), o.get("title"), o.get("department")] if x]
lines.append(f"Org: {' / '.join(org_parts)}")
for bio in p.get("biographies", []):
lines.append(f"Bio: {bio.get('value', '')[:80]}")
return "\n".join(lines)
# ─────────────────────────────────────────────
# Search (Directory + personal contacts)
# ─────────────────────────────────────────────
@with_retry()
def search_contacts(query: str, max_results: int = 20, format_: str = "table"):
"""Search contacts from the user's personal contacts."""
service = get_service()
result = service.people().searchContacts(
query=query,
readMask=PERSON_FIELDS,
pageSize=min(max_results, 30),
).execute()
results = result.get("results", [])
if not results:
print(f"No contacts found for: {query}")
return
if format_ == "json":
print(json.dumps([r.get("person", {}) for r in results], ensure_ascii=False, indent=2))
return
print(f"Contacts matching '{query}' ({len(results)}):")
for r in results:
print(f" {_fmt_person(r.get('person', {}), short=True)}")
@with_retry()
def search_directory(query: str, max_results: int = 20, format_: str = "table"):
"""Search the Google Workspace directory (org-wide)."""
service = get_service()
try:
result = service.people().searchDirectoryPeople(
query=query,
readMask=PERSON_FIELDS,
sources=["DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE", "DIRECTORY_SOURCE_TYPE_DOMAIN_CONTACT"],
pageSize=min(max_results, 30),
).execute()
except Exception as e:
if "403" in str(e):
print("Directory search requires Google Workspace (not personal Gmail).", file=sys.stderr)
else:
raise
return
people = result.get("people", [])
if not people:
print(f"No directory results for: {query}")
return
if format_ == "json":
print(json.dumps(people, ensure_ascii=False, indent=2))
return
print(f"Directory results for '{query}' ({len(people)}):")
for p in people:
print(f" {_fmt_person(p, short=True)}")
# ─────────────────────────────────────────────
# Contacts CRUD
# ─────────────────────────────────────────────
@with_retry()
def list_contacts(max_results: int = 50, format_: str = "table"):
"""List all personal contacts."""
service = get_service()
result = service.people().connections().list(
resourceName="people/me",
personFields=PERSON_FIELDS,
pageSize=min(max_results, 1000),
sortOrder="LAST_MODIFIED_DESCENDING",
).execute()
people = result.get("connections", [])
if not people:
print("No contacts found.")
return
if format_ == "json":
print(json.dumps(people, ensure_ascii=False, indent=2))
return
print(f"Contacts ({len(people)}):")
for p in people:
print(f" {_fmt_person(p, short=True)}")
@with_retry()
def show_contact(contact_id: str):
"""Show full details of a contact."""
service = get_service()
# Accept short ID or full resource name
if not contact_id.startswith("people/"):
contact_id = f"people/{contact_id}"
person = service.people().get(
resourceName=contact_id,
personFields=PERSON_FIELDS,
).execute()
print(_fmt_person(person))
@with_retry()
def create_contact(
name: str,
email: str = None,
phone: str = None,
company: str = None,
title: str = None,
notes: str = None,
):
"""Create a new contact."""
service = get_service()
body: dict = {}
# Name
parts = name.split(" ", 1)
body["names"] = [{
"givenName": parts[0],
"familyName": parts[1] if len(parts) > 1 else "",
}]
if email:
body["emailAddresses"] = [{"value": email, "type": "work"}]
if phone:
body["phoneNumbers"] = [{"value": phone, "type": "work"}]
if company or title:
body["organizations"] = [{
"name": company or "",
"title": title or "",
"type": "work",
}]
if notes:
body["biographies"] = [{"value": notes, "contentType": "TEXT_PLAIN"}]
result = service.people().createContact(body=body).execute()
resource_id = result.get("resourceName", "").split("/")[-1]
print(f"✅ Contact created: [{resource_id}] {name}")
if email:
print(f" Email: {email}")
@with_retry()
def update_contact(
contact_id: str,
name: str = None,
email: str = None,
phone: str = None,
company: str = None,
title: str = None,
):
"""Update fields of an existing contact."""
service = get_service()
if not contact_id.startswith("people/"):
contact_id = f"people/{contact_id}"
# Fetch current
person = service.people().get(
resourceName=contact_id,
personFields=PERSON_FIELDS,
).execute()
etag = person.get("etag")
update_fields = []
if name:
parts = name.split(" ", 1)
person["names"] = [{
"givenName": parts[0],
"familyName": parts[1] if len(parts) > 1 else "",
}]
update_fields.append("names")
if email:
person["emailAddresses"] = [{"value": email, "type": "work"}]
update_fields.append("emailAddresses")
if phone:
person["phoneNumbers"] = [{"value": phone, "type": "work"}]
update_fields.append("phoneNumbers")
if company or title:
existing_org = (person.get("organizations") or [{}])[0]
person["organizations"] = [{
"name": company or existing_org.get("name", ""),
"title": title or existing_org.get("title", ""),
"type": "work",
}]
update_fields.append("organizations")
if not update_fields:
print("No updates specified.")
return
person["etag"] = etag
service.people().updateContact(
resourceName=contact_id,
updatePersonFields=",".join(update_fields),
body=person,
).execute()
short_id = contact_id.split("/")[-1]
print(f"✅ Contact updated: [{short_id}]")
@with_retry()
def delete_contact(contact_id: str):
"""Delete a contact."""
service = get_service()
if not contact_id.startswith("people/"):
contact_id = f"people/{contact_id}"
service.people().deleteContact(resourceName=contact_id).execute()
short_id = contact_id.split("/")[-1]
print(f"🗑️ Contact deleted: [{short_id}]")
# ─────────────────────────────────────────────
# Email Lookup helper (used by gmail.sh)
# ─────────────────────────────────────────────
@with_retry()
def lookup_email(name_or_email: str):
"""Quick lookup: find email for a name. Tries contacts then directory."""
service = get_service()
# Try personal contacts first
try:
result = service.people().searchContacts(
query=name_or_email,
readMask="names,emailAddresses",
pageSize=5,
).execute()
for r in result.get("results", []):
p = r.get("person", {})
emails = p.get("emailAddresses", [])
if emails:
name = p.get("names", [{}])[0].get("displayName", "")
email = emails[0].get("value", "")
print(f"{name} <{email}>")
return
except Exception:
pass
# Try directory
try:
result = service.people().searchDirectoryPeople(
query=name_or_email,
readMask="names,emailAddresses",
sources=["DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE"],
pageSize=5,
).execute()
for p in result.get("people", []):
emails = p.get("emailAddresses", [])
if emails:
name = p.get("names", [{}])[0].get("displayName", "")
email = emails[0].get("value", "")
print(f"{name} <{email}>")
return
except Exception:
pass
print(f"Not found: {name_or_email}")
# ─────────────────────────────────────────────
# CLI
# ─────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="GARC People Helper — Google Contacts & Directory")
parser.add_argument("--format", "-f", dest="format_", default="table", choices=["table", "json"])
subparsers = parser.add_subparsers(dest="command", required=True)
# search
sp = subparsers.add_parser("search", help="Search personal contacts")
sp.add_argument("query", nargs="+")
sp.add_argument("--max", type=int, default=20)
# directory
dp = subparsers.add_parser("directory", help="Search GWS directory (org-wide)")
dp.add_argument("query", nargs="+")
dp.add_argument("--max", type=int, default=20)
# list
lp = subparsers.add_parser("list", help="List all personal contacts")
lp.add_argument("--max", type=int, default=50)
# show
shp = subparsers.add_parser("show", help="Show a contact by ID")
shp.add_argument("contact_id")
# create
cp = subparsers.add_parser("create", help="Create a new contact")
cp.add_argument("--name", required=True)
cp.add_argument("--email")
cp.add_argument("--phone")
cp.add_argument("--company")
cp.add_argument("--title")
cp.add_argument("--notes")
# update
up = subparsers.add_parser("update", help="Update a contact")
up.add_argument("contact_id")
up.add_argument("--name")
up.add_argument("--email")
up.add_argument("--phone")
up.add_argument("--company")
up.add_argument("--title")
# delete
delp = subparsers.add_parser("delete", help="Delete a contact")
delp.add_argument("contact_id")
# lookup
look = subparsers.add_parser("lookup", help="Quick email lookup by name")
look.add_argument("query", nargs="+")
args = parser.parse_args()
try:
if args.command == "search":
search_contacts(" ".join(args.query), args.max, args.format_)
elif args.command == "directory":
search_directory(" ".join(args.query), args.max, args.format_)
elif args.command == "list":
list_contacts(args.max, args.format_)
elif args.command == "show":
show_contact(args.contact_id)
elif args.command == "create":
create_contact(args.name, args.email, args.phone, args.company, args.title, args.notes)
elif args.command == "update":
update_contact(args.contact_id, args.name, args.email, args.phone, args.company, args.title)
elif args.command == "delete":
delete_contact(args.contact_id)
elif args.command == "lookup":
lookup_email(" ".join(args.query))
except KeyboardInterrupt:
sys.exit(0)
if __name__ == "__main__":
main()

516
scripts/garc-setup.py Normal file
View file

@ -0,0 +1,516 @@
#!/usr/bin/env python3
"""
GARC Setup Interactive workspace provisioner
- Creates Google Drive folder structure
- Provisions Google Sheets with all required tabs and headers
- Uploads initial disclosure chain templates to Drive
- Validates all APIs are accessible
"""
import argparse
import json
import os
import sys
from pathlib import Path
# Add scripts dir to path for garc-core
sys.path.insert(0, str(Path(__file__).parent))
from garc_core import build_service, load_config, utc_now, GARC_CONFIG_DIR
# Sheet definitions: tab name → headers
SHEET_TABS = {
"memory": ["agent_id", "timestamp", "entry", "source", "tags"],
"agents": ["id", "model", "scopes", "description", "profile", "status", "drive_folder", "registered_at"],
"queue": ["queue_id", "agent_id", "message", "status", "gate", "source", "created_at", "updated_at", "assigned_to"],
"heartbeat": ["agent_id", "timestamp", "status", "notes", "platform", "context_file"],
"approval": ["approval_id", "agent_id", "task", "status", "created_at", "resolved_at", "resolver", "notes"],
"tasks_log": ["task_id", "agent_id", "title", "google_task_id", "status", "created_at", "completed_at"],
"email_log": ["msg_id", "agent_id", "to", "subject", "sent_at", "thread_id"],
"calendar_log": ["event_id", "agent_id", "title", "start", "end", "created_at"],
}
# Disclosure chain templates
DISCLOSURE_TEMPLATES = {
"SOUL.md": """# SOUL — Agent Identity
agent_id: main
platform: Google Workspace
runtime: GARC v0.1.0
created: {timestamp}
## Core Principles
1. **Permission-first**: Always run `garc auth suggest` before a new task category.
2. **Minimum viable scopes**: Request only what is needed.
3. **Gate compliance**: Respect none / preview / approval execution gates.
4. **Transparency**: Explain actions before executing them.
5. **Reversibility preference**: Prefer reversible operations; flag irreversible ones.
## Identity
This agent operates within Google Workspace on behalf of the registered user.
It has access to Drive, Sheets, Gmail, Calendar, and Tasks as configured.
## Persona
Helpful, precise, audit-minded. Acts as a trusted digital colleague.
""",
"USER.md": """# USER — User Profile
## Identity
name: (your name)
email: (your Gmail)
timezone: Asia/Tokyo
language: Japanese / English
## Work Context
role: (your role)
organization: (your organization)
primary_tools: [Gmail, Google Drive, Google Sheets, Google Calendar]
## Preferences
- Communication style: direct, structured
- Approval threshold: always confirm before sending external emails
- Memory: persist important decisions and context
- Calendar: treat work hours as 09:00-18:00 JST
## Created
{timestamp} edit this file in Google Drive to customize.
""",
"MEMORY.md": """# MEMORY — Long-term Memory Index
Last sync: {timestamp}
Backend: Google Sheets (configured in ~/.garc/config.env)
## How to use
- Pull latest: `garc memory pull`
- Add entry: `garc memory push "key decision: ..."`
- Search: `garc memory search "keyword"`
- View raw: Google Sheets memory tab
## Recent context
(populated by `garc memory pull`)
""",
"RULES.md": """# RULES — Operating Rules
## Execution Rules
1. Always check execution gate before any write operation
- `garc approve gate <task_type>`
2. For `preview` gate: show preview, ask for confirmation
3. For `approval` gate: create approval request, wait for human
4. Never send email without explicit confirmation unless gate is `none`
5. Never delete files or calendar events without `approval` gate clearance
## Memory Rules
1. After any significant decision, push to memory
- `garc memory push "decided: ..."`
2. Pull memory at session start for context
- `garc memory pull`
3. Heartbeat at session end
- `garc heartbeat`
## Communication Rules
1. Default reply-to: use GARC_GMAIL_DEFAULT_TO for notifications
2. Subject prefix for agent emails: `[GARC]`
3. CC user on all outbound approvals
## Safety Rules
1. Max 50 emails/hour limit (self-imposed)
2. Max 100 Drive file operations/hour
3. Never modify shared Drives without explicit instruction
4. Always confirm before recurring calendar events
""",
"HEARTBEAT.md": """# HEARTBEAT — System State
agent_id: main
last_bootstrap: {timestamp}
status: initialized
platform: Google Workspace
## Latest State
(updated by `garc heartbeat`)
""",
}
def check_api_access(config: dict) -> dict:
"""Verify all required APIs are accessible."""
results = {}
print("\n🔍 Checking API access...")
# Drive
try:
svc = build_service("drive", "v3")
svc.about().get(fields="user").execute()
results["Drive API"] = ""
except Exception as e:
results["Drive API"] = f"{str(e)[:60]}"
# Sheets
try:
svc = build_service("sheets", "v4")
results["Sheets API"] = ""
except Exception as e:
results["Sheets API"] = f"{str(e)[:60]}"
# Gmail
try:
svc = build_service("gmail", "v1")
svc.users().getProfile(userId="me").execute()
results["Gmail API"] = ""
except Exception as e:
results["Gmail API"] = f"{str(e)[:60]}"
# Calendar
try:
svc = build_service("calendar", "v3")
svc.calendarList().list(maxResults=1).execute()
results["Calendar API"] = ""
except Exception as e:
results["Calendar API"] = f"{str(e)[:60]}"
# Tasks
try:
svc = build_service("tasks", "v1")
svc.tasklists().list(maxResults=1).execute()
results["Tasks API"] = ""
except Exception as e:
results["Tasks API"] = f"{str(e)[:60]}"
# Docs
try:
svc = build_service("docs", "v1")
results["Docs API"] = ""
except Exception as e:
results["Docs API"] = f"{str(e)[:60]}"
# People
try:
svc = build_service("people", "v1", scopes=["https://www.googleapis.com/auth/contacts.readonly"])
svc.people().connections().list(
resourceName="people/me",
personFields="names",
pageSize=1,
).execute()
results["People API"] = ""
except Exception as e:
results["People API"] = f"{str(e)[:60]}"
for api, status in results.items():
print(f" {status} {api}")
return results
def provision_sheets(config: dict) -> str:
"""Create or update Google Sheets with all required tabs."""
sheets_id = config.get("GARC_SHEETS_ID", "")
svc = build_service("sheets", "v4")
if sheets_id:
print(f"\n📊 Using existing Sheets: {sheets_id}")
# Get existing sheet info
try:
meta = svc.spreadsheets().get(spreadsheetId=sheets_id).execute()
existing_tabs = {s["properties"]["title"] for s in meta.get("sheets", [])}
print(f" Existing tabs: {', '.join(existing_tabs)}")
except Exception as e:
print(f"❌ Cannot access Sheets {sheets_id}: {e}")
sheets_id = ""
if not sheets_id:
print("\n📊 Creating new Google Sheets for GARC...")
result = svc.spreadsheets().create(body={
"properties": {"title": "GARC Workspace Data"},
"sheets": [{"properties": {"title": tab}} for tab in SHEET_TABS.keys()]
}).execute()
sheets_id = result["spreadsheetId"]
existing_tabs = set(SHEET_TABS.keys())
print(f" ✅ Created: https://docs.google.com/spreadsheets/d/{sheets_id}")
else:
existing_tabs = existing_tabs # from above
# Add missing tabs
meta = svc.spreadsheets().get(spreadsheetId=sheets_id).execute()
existing_tab_names = {s["properties"]["title"] for s in meta.get("sheets", [])}
add_requests = []
for tab_name in SHEET_TABS:
if tab_name not in existing_tab_names:
add_requests.append({
"addSheet": {"properties": {"title": tab_name}}
})
if add_requests:
svc.spreadsheets().batchUpdate(
spreadsheetId=sheets_id,
body={"requests": add_requests}
).execute()
print(f" ✅ Added tabs: {[r['addSheet']['properties']['title'] for r in add_requests]}")
# Write headers to each tab
batch_data = []
for tab_name, headers in SHEET_TABS.items():
batch_data.append({
"range": f"{tab_name}!A1:{chr(65 + len(headers) - 1)}1",
"values": [headers]
})
svc.spreadsheets().values().batchUpdate(
spreadsheetId=sheets_id,
body={
"valueInputOption": "RAW",
"data": batch_data
}
).execute()
print(f" ✅ Headers written to all {len(SHEET_TABS)} tabs")
# Bold the header row in each sheet (formatting)
meta = svc.spreadsheets().get(spreadsheetId=sheets_id).execute()
sheet_id_map = {s["properties"]["title"]: s["properties"]["sheetId"] for s in meta.get("sheets", [])}
format_requests = []
for tab_name in SHEET_TABS:
sid = sheet_id_map.get(tab_name)
if sid is not None:
format_requests.append({
"repeatCell": {
"range": {"sheetId": sid, "startRowIndex": 0, "endRowIndex": 1},
"cell": {"userEnteredFormat": {"textFormat": {"bold": True}, "backgroundColor": {"red": 0.9, "green": 0.9, "blue": 0.9}}},
"fields": "userEnteredFormat(textFormat,backgroundColor)"
}
})
# Freeze header row
format_requests.append({
"updateSheetProperties": {
"properties": {"sheetId": sid, "gridProperties": {"frozenRowCount": 1}},
"fields": "gridProperties.frozenRowCount"
}
})
if format_requests:
svc.spreadsheets().batchUpdate(
spreadsheetId=sheets_id,
body={"requests": format_requests}
).execute()
print(" ✅ Headers formatted (bold + freeze)")
return sheets_id
def provision_drive(config: dict) -> str:
"""Create Drive folder structure for agent workspace."""
folder_id = config.get("GARC_DRIVE_FOLDER_ID", "")
svc = build_service("drive", "v3")
if folder_id:
print(f"\n📁 Using existing Drive folder: {folder_id}")
try:
meta = svc.files().get(fileId=folder_id, fields="id,name").execute()
print(f" Folder: {meta['name']}")
except Exception:
print(f"⚠️ Folder not accessible, creating new one...")
folder_id = ""
if not folder_id:
print("\n📁 Creating GARC Drive folder...")
result = svc.files().create(body={
"name": "GARC Workspace",
"mimeType": "application/vnd.google-apps.folder"
}, fields="id,name").execute()
folder_id = result["id"]
print(f" ✅ Created folder: {result['name']} ({folder_id})")
# Create memory subfolder
memory_query = f"'{folder_id}' in parents and name='memory' and mimeType='application/vnd.google-apps.folder'"
existing = svc.files().list(q=memory_query, fields="files(id,name)").execute()
if not existing.get("files"):
svc.files().create(body={
"name": "memory",
"mimeType": "application/vnd.google-apps.folder",
"parents": [folder_id]
}).execute()
print(" ✅ Created memory/ subfolder")
return folder_id
def upload_disclosure_chain(folder_id: str):
"""Upload disclosure chain template files to Google Drive."""
svc = build_service("drive", "v3")
ts = utc_now()
print("\n📝 Uploading disclosure chain templates...")
for filename, template in DISCLOSURE_TEMPLATES.items():
content = template.replace("{timestamp}", ts).encode("utf-8")
# Check if file exists
query = f"'{folder_id}' in parents and name='{filename}' and trashed=false"
existing = svc.files().list(q=query, fields="files(id,name)").execute()
if existing.get("files"):
# Skip if already exists (don't overwrite user's customized files)
print(f" ⏭️ {filename} (already exists, skipping)")
continue
from googleapiclient.http import MediaIoBaseUpload
import io
media = MediaIoBaseUpload(io.BytesIO(content), mimetype="text/plain")
svc.files().create(
body={"name": filename, "parents": [folder_id]},
media_body=media,
fields="id,name"
).execute()
print(f"{filename}")
def save_config(config_updates: dict):
"""Save updated config values to ~/.garc/config.env."""
config_file = GARC_CONFIG_DIR / "config.env"
GARC_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
# Read existing
existing = {}
if config_file.exists():
with open(config_file) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, _, v = line.partition("=")
existing[k.strip()] = v.strip()
existing.update(config_updates)
# Write back
template_file = Path(__file__).parent.parent / "config" / "config.env.example"
if template_file.exists():
with open(template_file) as f:
template_lines = f.readlines()
out_lines = []
written_keys = set()
for line in template_lines:
stripped = line.strip()
if stripped and not stripped.startswith("#") and "=" in stripped:
key = stripped.split("=")[0].strip()
if key in existing:
out_lines.append(f"{key}={existing[key]}\n")
written_keys.add(key)
continue
out_lines.append(line)
# Add any extra keys not in template
for key, val in existing.items():
if key not in written_keys:
out_lines.append(f"{key}={val}\n")
with open(config_file, "w") as f:
f.writelines(out_lines)
else:
with open(config_file, "w") as f:
for key, val in existing.items():
f.write(f"{key}={val}\n")
config_file.chmod(0o600)
print(f"\n✅ Config saved: {config_file}")
def main():
parser = argparse.ArgumentParser(description="GARC Setup Wizard")
subparsers = parser.add_subparsers(dest="command")
# Full setup
setup_p = subparsers.add_parser("all", help="Run full setup wizard")
setup_p.add_argument("--skip-upload", action="store_true", help="Skip disclosure chain upload")
# Check only
subparsers.add_parser("check", help="Check API access only")
# Provision sheets only
subparsers.add_parser("sheets", help="Provision Sheets tabs only")
# Provision drive only
subparsers.add_parser("drive", help="Provision Drive folder only")
args = parser.parse_args()
config = load_config()
if args.command == "check":
check_api_access(config)
return
if args.command == "sheets":
sheets_id = provision_sheets(config)
save_config({"GARC_SHEETS_ID": sheets_id})
return
if args.command == "drive":
folder_id = provision_drive(config)
save_config({"GARC_DRIVE_FOLDER_ID": folder_id})
return
# Full setup
print("=" * 60)
print("GARC Workspace Setup")
print("=" * 60)
# 1. Check APIs
api_results = check_api_access(config)
failed_apis = [k for k, v in api_results.items() if v.startswith("")]
if failed_apis:
print(f"\n⚠️ {len(failed_apis)} APIs not accessible: {', '.join(failed_apis)}")
print(" See docs/google-cloud-setup.md for setup instructions")
if len(failed_apis) > 3:
print(" Too many failures. Please enable APIs first.")
return
# 2. Provision Drive
folder_id = provision_drive(config)
# 3. Provision Sheets
sheets_id = provision_sheets(config)
# 4. Upload disclosure chain
if not getattr(args, "skip_upload", False):
upload_disclosure_chain(folder_id)
# 5. Save config
save_config({
"GARC_DRIVE_FOLDER_ID": folder_id,
"GARC_SHEETS_ID": sheets_id,
})
# 6. Summary
print("\n" + "=" * 60)
print("✅ Setup complete!")
print("=" * 60)
print(f" Drive folder: https://drive.google.com/drive/folders/{folder_id}")
print(f" Sheets: https://docs.google.com/spreadsheets/d/{sheets_id}")
print()
print("Next steps:")
print(" garc bootstrap --agent main")
print(" garc status")
print(" garc memory pull")
if __name__ == "__main__":
main()

430
scripts/garc-sheets-helper.py Executable file
View file

@ -0,0 +1,430 @@
#!/usr/bin/env python3
"""
GARC Sheets Helper Full Google Sheets operations
read / write / append / search / clear / format
+ memory/agent/queue/heartbeat/approval operations
"""
import argparse
import json
import sys
from pathlib import Path
from datetime import datetime, timezone
sys.path.insert(0, str(Path(__file__).parent))
from garc_core import build_service, utc_now, with_retry
SHEET_MEMORY = "memory"
SHEET_AGENTS = "agents"
SHEET_QUEUE = "queue"
SHEET_HEARTBEAT = "heartbeat"
SHEET_APPROVAL = "approval"
SHEET_TASKS_LOG = "tasks_log"
SHEET_EMAIL_LOG = "email_log"
SHEET_CALENDAR_LOG = "calendar_log"
def get_svc():
return build_service("sheets", "v4")
# ─── Generic operations ───────────────────────────────────────────────────────
@with_retry()
def read_range(sheets_id: str, range_: str, output_format: str = "table"):
"""Read data from a Sheets range."""
svc = get_svc()
result = svc.spreadsheets().values().get(
spreadsheetId=sheets_id, range=range_,
valueRenderOption="FORMATTED_VALUE"
).execute()
rows = result.get("values", [])
if not rows:
print(f"(empty: {range_})")
return []
if output_format == "json":
if len(rows) > 1:
headers = rows[0]
data = [dict(zip(headers, row + [""] * (len(headers) - len(row)))) for row in rows[1:]]
print(json.dumps(data, ensure_ascii=False, indent=2))
else:
print(json.dumps(rows, ensure_ascii=False, indent=2))
else:
# Table format
widths = [max(len(str(rows[r][c])) if c < len(rows[r]) else 0
for r in range(len(rows))) for c in range(len(rows[0]))]
widths = [min(w, 40) for w in widths]
for i, row in enumerate(rows):
line = " ".join(str(row[c] if c < len(row) else "").ljust(widths[c])[:widths[c]]
for c in range(len(rows[0])))
print(line)
if i == 0:
print(" ".join("" * widths[c] for c in range(len(rows[0]))))
return rows
@with_retry()
def write_range(sheets_id: str, range_: str, values: list):
"""Write data to a Sheets range (overwrites)."""
svc = get_svc()
result = svc.spreadsheets().values().update(
spreadsheetId=sheets_id,
range=range_,
valueInputOption="USER_ENTERED",
body={"values": values}
).execute()
print(f"✅ Written {result.get('updatedCells', 0)} cells to {range_}")
return result
@with_retry()
def append_row(sheets_id: str, sheet_name: str, values: list):
"""Append a row to a sheet."""
svc = get_svc()
result = svc.spreadsheets().values().append(
spreadsheetId=sheets_id,
range=f"{sheet_name}!A:Z",
valueInputOption="USER_ENTERED",
insertDataOption="INSERT_ROWS",
body={"values": [values]}
).execute()
print(f"✅ Row appended to {sheet_name}")
return result
@with_retry()
def search_sheet(sheets_id: str, sheet_name: str, query: str,
column: int = -1, output_format: str = "table"):
"""Search rows in a sheet by keyword."""
svc = get_svc()
result = svc.spreadsheets().values().get(
spreadsheetId=sheets_id, range=f"{sheet_name}!A:Z"
).execute()
rows = result.get("values", [])
if not rows:
print(f"(empty: {sheet_name})")
return []
query_lower = query.lower()
matches = []
headers = rows[0] if rows else []
for row in rows[1:]:
if column >= 0:
check = str(row[column] if column < len(row) else "").lower()
else:
check = " ".join(str(cell) for cell in row).lower()
if query_lower in check:
matches.append(row)
if not matches:
print(f"No results for '{query}' in {sheet_name}")
return []
print(f"Found {len(matches)} rows in {sheet_name}:")
if headers and output_format == "table":
widths = [min(max(len(str(h)), max((len(str(r[i] if i < len(r) else "")) for r in matches), default=0)), 30)
for i, h in enumerate(headers)]
print(" ".join(h.ljust(widths[i]) for i, h in enumerate(headers)))
print(" ".join("" * widths[i] for i in range(len(headers))))
for row in matches:
print(" ".join(str(row[i] if i < len(row) else "").ljust(widths[i])[:widths[i]]
for i in range(len(headers))))
elif output_format == "json":
if headers:
data = [dict(zip(headers, r + [""] * (len(headers) - len(r)))) for r in matches]
print(json.dumps(data, ensure_ascii=False, indent=2))
return matches
@with_retry()
def get_sheet_info(sheets_id: str):
"""Get spreadsheet metadata."""
svc = get_svc()
meta = svc.spreadsheets().get(spreadsheetId=sheets_id).execute()
print(f"Title: {meta['properties']['title']}")
print(f"ID: {sheets_id}")
print(f"URL: https://docs.google.com/spreadsheets/d/{sheets_id}")
print()
print(f"Sheets ({len(meta.get('sheets', []))}):")
for s in meta.get("sheets", []):
props = s["properties"]
gp = props.get("gridProperties", {})
print(f" [{props['sheetId']:<6}] {props['title']:<20} {gp.get('rowCount', 0):>6} rows × {gp.get('columnCount', 0):>3} cols")
return meta
@with_retry()
def clear_range(sheets_id: str, range_: str):
"""Clear a range (but keep headers)."""
svc = get_svc()
svc.spreadsheets().values().clear(spreadsheetId=sheets_id, range=range_).execute()
print(f"✅ Cleared: {range_}")
# ─── GARC-specific operations ─────────────────────────────────────────────────
@with_retry()
def memory_pull(sheets_id: str, agent_id: str, output: str):
svc = get_svc()
result = svc.spreadsheets().values().get(
spreadsheetId=sheets_id, range=f"{SHEET_MEMORY}!A:E"
).execute()
rows = result.get("values", [])
headers = rows[0] if rows else []
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
out_path = Path(output)
out_path.parent.mkdir(parents=True, exist_ok=True)
with open(out_path, "w") as f:
f.write(f"# Memory — {agent_id}{today}\n\n")
for row in rows[1:]:
if not row:
continue
row_agent = row[0] if len(row) > 0 else ""
if row_agent and row_agent != agent_id:
continue
ts = row[1] if len(row) > 1 else ""
entry = row[2] if len(row) > 2 else ""
tags = row[4] if len(row) > 4 else ""
tag_str = f" `{tags}`" if tags else ""
f.write(f"## {ts[:10] if ts else 'N/A'}{tag_str}\n{entry}\n\n")
count = sum(1 for r in rows[1:] if r and (not r[0] or r[0] == agent_id))
print(f"✅ Memory pulled: {count} entries → {output}")
@with_retry()
def memory_push(sheets_id: str, agent_id: str, entry: str, timestamp: str, tags: str = ""):
append_row(sheets_id, SHEET_MEMORY, [agent_id, timestamp, entry, "manual", tags])
@with_retry()
def memory_search(sheets_id: str, query: str):
search_sheet(sheets_id, SHEET_MEMORY, query)
@with_retry()
def agent_list(sheets_id: str):
read_range(sheets_id, f"{SHEET_AGENTS}!A:H")
@with_retry()
def agent_register(sheets_id: str, yaml_file: str):
try:
import yaml
with open(yaml_file) as f:
config = yaml.safe_load(f)
except ImportError:
print("⚠️ PyYAML not installed. Install: pip install pyyaml")
return
agents = config.get("agents", [])
ts = utc_now()
for agent in agents:
scopes = ",".join(agent.get("scopes", []))
append_row(sheets_id, SHEET_AGENTS, [
agent.get("id", ""),
agent.get("model", ""),
scopes,
agent.get("description", ""),
agent.get("profile", ""),
"active",
agent.get("drive_folder", ""),
ts
])
print(f"✅ Registered {len(agents)} agents")
@with_retry()
def agent_show(sheets_id: str, agent_id: str):
search_sheet(sheets_id, SHEET_AGENTS, agent_id, column=0)
@with_retry()
def heartbeat(sheets_id: str, agent_id: str, status: str, notes: str, timestamp: str, context_file: str = ""):
append_row(sheets_id, SHEET_HEARTBEAT, [agent_id, timestamp, status, notes, "google-workspace", context_file])
@with_retry()
def approval_list(sheets_id: str):
svc = get_svc()
result = svc.spreadsheets().values().get(
spreadsheetId=sheets_id, range=f"{SHEET_APPROVAL}!A:H"
).execute()
rows = result.get("values", [])
headers = rows[0] if rows else []
pending = [r for r in rows[1:] if len(r) > 3 and r[3] == "pending"]
if not pending:
print("No pending approvals.")
return
print(f"Pending approvals ({len(pending)}):")
for row in pending:
print(f" 🔒 [{row[0][:12]}] {row[2] if len(row) > 2 else ''}")
print(f" Agent: {row[1] if len(row) > 1 else ''} Created: {(row[4] if len(row) > 4 else '')[:16]}")
@with_retry()
def approval_create(sheets_id: str, approval_id: str, task: str, agent_id: str, timestamp: str):
append_row(sheets_id, SHEET_APPROVAL, [approval_id, agent_id, task, "pending", timestamp, "", "", ""])
print(f"✅ Approval created: {approval_id}")
@with_retry()
def approval_act(sheets_id: str, approval_id: str, action: str, timestamp: str):
svc = get_svc()
result = svc.spreadsheets().values().get(
spreadsheetId=sheets_id, range=f"{SHEET_APPROVAL}!A:H"
).execute()
rows = result.get("values", [])
for i, row in enumerate(rows):
if row and row[0] == approval_id:
row_num = i + 1
svc.spreadsheets().values().update(
spreadsheetId=sheets_id,
range=f"{SHEET_APPROVAL}!D{row_num}:F{row_num}",
valueInputOption="RAW",
body={"values": [[action, timestamp, ""]]}
).execute()
print(f"✅ Approval {approval_id[:12]}{action}")
return
print(f"Approval not found: {approval_id}")
def main():
parser = argparse.ArgumentParser(description="GARC Sheets Helper")
sub = parser.add_subparsers(dest="command")
# Generic
rp = sub.add_parser("read", help="Read range")
rp.add_argument("--sheets-id", required=True)
rp.add_argument("--range", required=True, dest="range_")
rp.add_argument("--format", default="table", choices=["table", "json"])
wp = sub.add_parser("write", help="Write range")
wp.add_argument("--sheets-id", required=True)
wp.add_argument("--range", required=True, dest="range_")
wp.add_argument("--values", required=True, help="JSON array of arrays")
ap = sub.add_parser("append", help="Append row")
ap.add_argument("--sheets-id", required=True)
ap.add_argument("--sheet", required=True)
ap.add_argument("--values", required=True, help="JSON array")
sep = sub.add_parser("search", help="Search rows")
sep.add_argument("--sheets-id", required=True)
sep.add_argument("--sheet", required=True)
sep.add_argument("--query", required=True)
sep.add_argument("--column", type=int, default=-1)
sep.add_argument("--format", default="table", choices=["table", "json"])
infop = sub.add_parser("info", help="Get spreadsheet info")
infop.add_argument("--sheets-id", required=True)
clp = sub.add_parser("clear", help="Clear range")
clp.add_argument("--sheets-id", required=True)
clp.add_argument("--range", required=True, dest="range_")
# GARC-specific
mpl = sub.add_parser("memory-pull")
mpl.add_argument("--sheets-id", required=True)
mpl.add_argument("--agent-id", required=True)
mpl.add_argument("--output", required=True)
mpu = sub.add_parser("memory-push")
mpu.add_argument("--sheets-id", required=True)
mpu.add_argument("--agent-id", required=True)
mpu.add_argument("--entry", required=True)
mpu.add_argument("--timestamp", required=True)
mpu.add_argument("--tags", default="")
ms = sub.add_parser("memory-search")
ms.add_argument("--sheets-id", required=True)
ms.add_argument("--query", required=True)
al = sub.add_parser("agent-list")
al.add_argument("--sheets-id", required=True)
ar = sub.add_parser("agent-register")
ar.add_argument("--sheets-id", required=True)
ar.add_argument("--yaml-file", required=True)
ash = sub.add_parser("agent-show")
ash.add_argument("--sheets-id", required=True)
ash.add_argument("--agent-id", required=True)
hb = sub.add_parser("heartbeat")
hb.add_argument("--sheets-id", required=True)
hb.add_argument("--agent-id", required=True)
hb.add_argument("--status", required=True)
hb.add_argument("--notes", default="")
hb.add_argument("--timestamp", required=True)
hb.add_argument("--context-file", default="")
apl = sub.add_parser("approval-list")
apl.add_argument("--sheets-id", required=True)
apc = sub.add_parser("approval-create")
apc.add_argument("--sheets-id", required=True)
apc.add_argument("--approval-id", required=True)
apc.add_argument("--task", required=True)
apc.add_argument("--agent-id", required=True)
apc.add_argument("--timestamp", required=True)
apa = sub.add_parser("approval-act")
apa.add_argument("--sheets-id", required=True)
apa.add_argument("--approval-id", required=True)
apa.add_argument("--action", required=True)
apa.add_argument("--timestamp", required=True)
args = parser.parse_args()
if args.command == "read":
read_range(args.sheets_id, args.range_, args.format)
elif args.command == "write":
write_range(args.sheets_id, args.range_, json.loads(args.values))
elif args.command == "append":
append_row(args.sheets_id, args.sheet, json.loads(args.values))
elif args.command == "search":
search_sheet(args.sheets_id, args.sheet, args.query, args.column, args.format)
elif args.command == "info":
get_sheet_info(args.sheets_id)
elif args.command == "clear":
clear_range(args.sheets_id, args.range_)
elif args.command == "memory-pull":
memory_pull(args.sheets_id, args.agent_id, args.output)
elif args.command == "memory-push":
memory_push(args.sheets_id, args.agent_id, args.entry, args.timestamp, args.tags)
elif args.command == "memory-search":
memory_search(args.sheets_id, args.query)
elif args.command == "agent-list":
agent_list(args.sheets_id)
elif args.command == "agent-register":
agent_register(args.sheets_id, args.yaml_file)
elif args.command == "agent-show":
agent_show(args.sheets_id, args.agent_id)
elif args.command == "heartbeat":
heartbeat(args.sheets_id, args.agent_id, args.status, args.notes,
args.timestamp, args.context_file)
elif args.command == "approval-list":
approval_list(args.sheets_id)
elif args.command == "approval-create":
approval_create(args.sheets_id, args.approval_id, args.task, args.agent_id, args.timestamp)
elif args.command == "approval-act":
approval_act(args.sheets_id, args.approval_id, args.action, args.timestamp)
else:
parser.print_help()
if __name__ == "__main__":
main()

317
scripts/garc-tasks-helper.py Executable file
View file

@ -0,0 +1,317 @@
#!/usr/bin/env python3
"""
GARC Tasks Helper Google Tasks operations
Supports multiple task lists, create/read/update/complete/delete
"""
import argparse
import json
import sys
from pathlib import Path
from datetime import datetime, timezone
sys.path.insert(0, str(Path(__file__).parent))
from garc_core import build_service, utc_now, with_retry
def get_service():
scopes = ["https://www.googleapis.com/auth/tasks"]
return build_service("tasks", "v1", scopes=scopes)
# ─────────────────────────────────────────────
# Task Lists
# ─────────────────────────────────────────────
@with_retry()
def list_tasklists():
"""List all task lists."""
service = get_service()
result = service.tasklists().list(maxResults=100).execute()
lists = result.get("items", [])
if not lists:
print("No task lists found.")
return
print(f"Task Lists ({len(lists)}):")
for tl in lists:
print(f" [{tl['id']}] {tl['title']}")
def _resolve_tasklist(service, tasklist_ref: str) -> str:
"""Resolve a task list name or partial ID to a full ID."""
if tasklist_ref == "@default":
return "@default"
result = service.tasklists().list(maxResults=100).execute()
for tl in result.get("items", []):
if tl["id"] == tasklist_ref or tl["title"].lower() == tasklist_ref.lower():
return tl["id"]
if tl["id"].startswith(tasklist_ref):
return tl["id"]
return tasklist_ref # fallback: use as-is
# ─────────────────────────────────────────────
# Task CRUD
# ─────────────────────────────────────────────
@with_retry()
def list_tasks(tasklist: str = "@default", show_completed: bool = False, format_: str = "table"):
"""List tasks in a task list."""
service = get_service()
tasklist = _resolve_tasklist(service, tasklist)
result = service.tasks().list(
tasklist=tasklist,
showCompleted=show_completed,
showHidden=show_completed,
maxResults=100,
).execute()
tasks = result.get("items", [])
if not tasks:
print("No tasks found.")
return
if format_ == "json":
output = []
for t in tasks:
output.append({
"id": t["id"],
"title": t.get("title", ""),
"status": t.get("status", ""),
"due": t.get("due", "")[:10] if t.get("due") else "",
"notes": t.get("notes", ""),
"updated": t.get("updated", "")[:10] if t.get("updated") else "",
})
print(json.dumps(output, ensure_ascii=False, indent=2))
return
print(f"Tasks ({len(tasks)}):")
for t in tasks:
status_icon = "" if t.get("status") == "completed" else ""
due = t.get("due", "")[:10] if t.get("due") else ""
due_str = f" [due: {due}]" if due else ""
short_id = t["id"][:12]
print(f" {status_icon} [{short_id}] {t.get('title', '')}{due_str}")
if t.get("notes"):
for line in t["notes"].splitlines():
print(f" {line}")
@with_retry()
def show_task(task_id: str, tasklist: str = "@default"):
"""Show full details of a single task."""
service = get_service()
tasklist = _resolve_tasklist(service, tasklist)
# Find full ID via list if partial
full_id = _find_task_id(service, task_id, tasklist)
if not full_id:
print(f"Task not found: {task_id}", file=sys.stderr)
sys.exit(1)
task = service.tasks().get(tasklist=tasklist, task=full_id).execute()
print(f"ID: {task['id']}")
print(f"Title: {task.get('title', '')}")
print(f"Status: {task.get('status', '')}")
if task.get("due"):
print(f"Due: {task['due'][:10]}")
if task.get("notes"):
print(f"Notes: {task['notes']}")
print(f"Updated: {task.get('updated', '')[:19]}")
@with_retry()
def create_task(
title: str,
tasklist: str = "@default",
due: str = None,
notes: str = None,
parent: str = None,
):
"""Create a new Google Task."""
service = get_service()
tasklist = _resolve_tasklist(service, tasklist)
body: dict = {"title": title, "status": "needsAction"}
if due:
# Normalize due date to RFC3339
if "T" not in due:
due = f"{due}T00:00:00.000Z"
body["due"] = due
if notes:
body["notes"] = notes
kwargs: dict = {"tasklist": tasklist, "body": body}
if parent:
kwargs["parent"] = parent
result = service.tasks().insert(**kwargs).execute()
print(f"✅ Task created: [{result['id'][:12]}] {title}")
if due:
print(f" Due: {due[:10]}")
@with_retry()
def update_task(
task_id: str,
tasklist: str = "@default",
title: str = None,
due: str = None,
notes: str = None,
):
"""Update an existing task."""
service = get_service()
tasklist = _resolve_tasklist(service, tasklist)
full_id = _find_task_id(service, task_id, tasklist)
if not full_id:
print(f"Task not found: {task_id}", file=sys.stderr)
sys.exit(1)
# Fetch current
task = service.tasks().get(tasklist=tasklist, task=full_id).execute()
if title:
task["title"] = title
if due:
if "T" not in due:
due = f"{due}T00:00:00.000Z"
task["due"] = due
if notes is not None:
task["notes"] = notes
result = service.tasks().update(tasklist=tasklist, task=full_id, body=task).execute()
print(f"✅ Task updated: [{result['id'][:12]}] {result.get('title', '')}")
@with_retry()
def complete_task(task_id: str, tasklist: str = "@default"):
"""Mark a task as completed."""
service = get_service()
tasklist = _resolve_tasklist(service, tasklist)
full_id = _find_task_id(service, task_id, tasklist)
if not full_id:
print(f"Task not found: {task_id}", file=sys.stderr)
sys.exit(1)
service.tasks().patch(
tasklist=tasklist,
task=full_id,
body={"status": "completed"},
).execute()
print(f"✅ Task {task_id[:12]} marked as completed")
@with_retry()
def delete_task(task_id: str, tasklist: str = "@default"):
"""Delete a task."""
service = get_service()
tasklist = _resolve_tasklist(service, tasklist)
full_id = _find_task_id(service, task_id, tasklist)
if not full_id:
print(f"Task not found: {task_id}", file=sys.stderr)
sys.exit(1)
service.tasks().delete(tasklist=tasklist, task=full_id).execute()
print(f"🗑️ Task {task_id[:12]} deleted")
@with_retry()
def clear_completed(tasklist: str = "@default"):
"""Clear all completed tasks from a task list."""
service = get_service()
tasklist = _resolve_tasklist(service, tasklist)
service.tasks().clear(tasklist=tasklist).execute()
print("✅ Cleared all completed tasks")
# ─────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────
def _find_task_id(service, task_id_or_partial: str, tasklist: str) -> str | None:
"""Find full task ID from a partial ID or exact match."""
result = service.tasks().list(
tasklist=tasklist, showCompleted=True, showHidden=True, maxResults=200
).execute()
for t in result.get("items", []):
if t["id"] == task_id_or_partial:
return t["id"]
if t["id"].startswith(task_id_or_partial):
return t["id"]
return None
# ─────────────────────────────────────────────
# CLI
# ─────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="GARC Tasks Helper — Google Tasks operations")
parser.add_argument("--tasklist", "-l", default="@default", help="Task list ID or name (default: @default)")
parser.add_argument("--format", "-f", dest="format_", default="table", choices=["table", "json"])
subparsers = parser.add_subparsers(dest="command", required=True)
# list-tasklists
subparsers.add_parser("list-tasklists", help="Show all task lists")
# list
lp = subparsers.add_parser("list", help="List tasks")
lp.add_argument("--completed", action="store_true", help="Include completed tasks")
# show
sp = subparsers.add_parser("show", help="Show a single task")
sp.add_argument("--task-id", required=True)
# create
cp = subparsers.add_parser("create", help="Create a task")
cp.add_argument("--title", required=True)
cp.add_argument("--due", help="Due date (YYYY-MM-DD)")
cp.add_argument("--notes", help="Task notes")
cp.add_argument("--parent", help="Parent task ID (for subtasks)")
# update
up = subparsers.add_parser("update", help="Update a task")
up.add_argument("--task-id", required=True)
up.add_argument("--title")
up.add_argument("--due")
up.add_argument("--notes")
# complete
comp = subparsers.add_parser("complete", help="Mark task as completed")
comp.add_argument("--task-id", required=True)
# delete
dp = subparsers.add_parser("delete", help="Delete a task")
dp.add_argument("--task-id", required=True)
# clear-completed
subparsers.add_parser("clear-completed", help="Remove all completed tasks")
args = parser.parse_args()
try:
if args.command == "list-tasklists":
list_tasklists()
elif args.command == "list":
list_tasks(args.tasklist, args.completed, args.format_)
elif args.command == "show":
show_task(args.task_id, args.tasklist)
elif args.command == "create":
create_task(args.title, args.tasklist, args.due, args.notes, args.parent)
elif args.command == "update":
update_task(args.task_id, args.tasklist, args.title, args.due, args.notes)
elif args.command == "complete":
complete_task(args.task_id, args.tasklist)
elif args.command == "delete":
delete_task(args.task_id, args.tasklist)
elif args.command == "clear-completed":
clear_completed(args.tasklist)
except KeyboardInterrupt:
sys.exit(0)
if __name__ == "__main__":
main()

111
scripts/setup-workspace.sh Executable file
View file

@ -0,0 +1,111 @@
#!/usr/bin/env bash
# GARC setup-workspace.sh — One-shot workspace provisioning
# Sets up ~/.garc directory and Google Workspace resources
set -euo pipefail
GARC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONFIG_DIR="${HOME}/.garc"
echo "GARC Workspace Setup"
echo "===================="
# Step 1: Create local directories
echo ""
echo "Step 1: Creating local directories..."
mkdir -p "${CONFIG_DIR}/cache/workspace/main/memory"
mkdir -p "${CONFIG_DIR}/cache/queue"
echo "✅ Directories created: ${CONFIG_DIR}"
# Step 2: Copy config template
echo ""
echo "Step 2: Setting up config..."
if [[ -f "${CONFIG_DIR}/config.env" ]]; then
echo " Config already exists: ${CONFIG_DIR}/config.env"
else
cp "${GARC_DIR}/config/config.env.example" "${CONFIG_DIR}/config.env"
echo "✅ Config template created: ${CONFIG_DIR}/config.env"
fi
# Step 3: Install Python dependencies
echo ""
echo "Step 3: Python dependencies..."
if command -v pip3 &>/dev/null; then
pip3 install -q google-api-python-client google-auth-oauthlib google-auth-httplib2 pyyaml 2>/dev/null || true
echo "✅ Python packages installed"
else
echo "⚠️ pip3 not found. Install manually:"
echo " pip3 install google-api-python-client google-auth-oauthlib google-auth-httplib2 pyyaml"
fi
# Step 4: Add garc to PATH
echo ""
echo "Step 4: PATH configuration..."
GARC_BIN="${GARC_DIR}/bin"
chmod +x "${GARC_BIN}/garc"
if echo "${PATH}" | grep -q "${GARC_BIN}"; then
echo " ${GARC_BIN} already in PATH"
else
echo " Add this to your ~/.zshrc or ~/.bashrc:"
echo ""
echo " export PATH=\"${GARC_BIN}:\${PATH}\""
echo ""
echo " Or create a symlink:"
echo " ln -s ${GARC_BIN}/garc /usr/local/bin/garc"
fi
# Step 5: Google Cloud setup instructions
echo ""
echo "Step 5: Google Cloud Console setup"
echo "────────────────────────────────────"
echo ""
echo "1. Go to: https://console.cloud.google.com/"
echo "2. Create or select a project"
echo "3. Enable these APIs:"
echo " - Google Drive API"
echo " - Google Sheets API"
echo " - Gmail API"
echo " - Google Calendar API"
echo " - Google Tasks API"
echo " - Google Chat API (optional)"
echo ""
echo "4. Create OAuth 2.0 credentials:"
echo " APIs & Services → Credentials → Create Credentials → OAuth 2.0 Client IDs"
echo " Application type: Desktop app"
echo " Download JSON → save as ~/.garc/credentials.json"
echo ""
echo "5. Create a Google Drive folder for agent workspace"
echo " Note the folder ID from the URL"
echo " Example: https://drive.google.com/drive/folders/1xxxxxxxxx"
echo " ^^^^^^^^^^^^ this is the folder ID"
echo ""
echo "6. Create a Google Sheets for data storage:"
echo " Create a new spreadsheet with these tabs:"
echo " - memory"
echo " - agents"
echo " - queue"
echo " - heartbeat"
echo " - approval"
echo " Note the spreadsheet ID from the URL"
echo ""
echo "7. Edit ~/.garc/config.env with your IDs:"
echo " GARC_DRIVE_FOLDER_ID=<your folder ID>"
echo " GARC_SHEETS_ID=<your spreadsheet ID>"
echo " GARC_GMAIL_DEFAULT_TO=<your email>"
# Step 6: Complete
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Setup complete!"
echo ""
echo "Next steps:"
echo " 1. Complete Google Cloud Console setup above"
echo " 2. Edit ~/.garc/config.env"
echo " 3. Run: garc auth login --profile backoffice_agent"
echo " 4. Run: garc init"
echo " 5. Run: garc bootstrap --agent main"
echo " 6. Run: garc status"
echo ""
echo "Try it:"
echo ' garc auth suggest "send weekly report to manager"'