fix: resolve all 17 playbook findings (P0–P3)

P0 fixes:
- agent register: upsert by agent_id (no duplicate rows)
- daemon poll-once: extract _gmail_poll_cycle, run synchronously
- garc_core.py: suppress urllib3/googleapiclient DeprecationWarnings

P1 fixes:
- OAuth: detect RefreshError → delete stale token → re-auth flow
- OAuth: scope coverage check before returning valid creds
- ingress: add stale-reset subcommand (reset in_progress > N min)
- sheets: trim-sheet / clean-all — deleteDimension for empty rows
- approval gate: send Gmail notification to GARC_APPROVAL_EMAIL

P2 additions:
- Google Chat: garc-chat-helper.py + garc send chat subcommands
- Service Account: garc auth service-account verify + DWD support
- Audit log: Sheets audit tab + garc audit list + bin/garc async hook
- garc auth revoke: POST /revoke + delete token file
- kg: pagination fix, shell injection fix, garc-kg-query.py
- docs: _doc_insert_text / append_doc / garc drive append-doc

P3 additions:
- Multi-tenant: lib/profile.sh (list/use/add/show/remove/current)
  bin/garc: auto-load profile config.env and token.json
- Google Forms pipeline: garc-forms-helper.py + lib/forms.sh
  garc forms list/responses/watch
- systemd: _daemon_install_service OS-detect → launchd or systemd units
- Python version gate (>=3.10) in bin/garc + pyproject.toml
- garc doctor command for environment diagnostics

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
林 駿甫 (Shunsuke Hayashi) 2026-04-15 09:55:33 +09:00
parent 680bd433f4
commit 7b5951a1d5
21 changed files with 2078 additions and 144 deletions

121
bin/garc
View file

@ -5,16 +5,40 @@
set -euo pipefail set -euo pipefail
GARC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" GARC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# ── Python version gate ────────────────────────────────────────────────────
if ! python3 -c "import sys; assert sys.version_info >= (3,10), f'Python 3.10+ required, got {sys.version}'" 2>/dev/null; then
_PY_VER=$(python3 --version 2>&1)
echo "❌ GARC requires Python 3.10 or higher." >&2
echo " Detected: ${_PY_VER}" >&2
echo " Install Python 3.10+: https://www.python.org/downloads/" >&2
exit 1
fi
GARC_LIB="${GARC_DIR}/lib" GARC_LIB="${GARC_DIR}/lib"
GARC_CONFIG="${HOME}/.garc" GARC_CONFIG="${HOME}/.garc"
GARC_CONFIG_ENV="${GARC_CONFIG}/config.env" GARC_CONFIG_ENV="${GARC_CONFIG}/config.env"
# Load config if present # Load base config if present
if [[ -f "${GARC_CONFIG_ENV}" ]]; then if [[ -f "${GARC_CONFIG_ENV}" ]]; then
# shellcheck source=/dev/null # shellcheck source=/dev/null
source "${GARC_CONFIG_ENV}" source "${GARC_CONFIG_ENV}"
fi fi
# Load profile-specific config (overrides base config)
GARC_PROFILE="${GARC_PROFILE:-}"
if [[ -n "${GARC_PROFILE}" ]]; then
_PROFILE_ENV="${GARC_CONFIG}/profiles/${GARC_PROFILE}/config.env"
if [[ -f "${_PROFILE_ENV}" ]]; then
# shellcheck source=/dev/null
source "${_PROFILE_ENV}"
fi
# Use profile-specific token if not already overridden
_PROFILE_TOKEN="${GARC_CONFIG}/profiles/${GARC_PROFILE}/token.json"
if [[ -f "${_PROFILE_TOKEN}" && -z "${GARC_TOKEN_FILE:-}" ]]; then
export GARC_TOKEN_FILE="${_PROFILE_TOKEN}"
fi
fi
# Defaults # Defaults
GARC_CACHE_DIR="${GARC_CACHE_DIR:-${GARC_CONFIG}/cache}" GARC_CACHE_DIR="${GARC_CACHE_DIR:-${GARC_CONFIG}/cache}"
GARC_CACHE_TTL="${GARC_CACHE_TTL:-300}" GARC_CACHE_TTL="${GARC_CACHE_TTL:-300}"
@ -129,6 +153,11 @@ Usage: garc <command> [subcommand] [options]
ingress verify --queue-id <id> ingress verify --queue-id <id>
ingress stats ingress stats
━━━ Google Forms (Response Pipeline) ━━━━━━━━━━━━━━━━━━━━━━━━━━
forms list List accessible Google Forms
forms responses <id> List form responses
forms watch <id> --agent Poll form and auto-enqueue new responses
━━━ Daemon (Gmail Poller) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━ Daemon (Gmail Poller) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
daemon start [--interval <sec>] [--agent <id>] daemon start [--interval <sec>] [--agent <id>]
daemon stop daemon stop
@ -143,8 +172,18 @@ Usage: garc <command> [subcommand] [options]
kg query "<concept>" Search knowledge graph kg query "<concept>" Search knowledge graph
kg show <doc_id> Show doc + links kg show <doc_id> Show doc + links
━━━ Profiles (Multi-tenant) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
profile list List all tenant profiles
profile use <name> Activate a profile (eval output)
profile add <name> Create a new profile
profile show [<name>] Show profile config
profile remove <name> Delete a profile
profile current Show active profile
━━━ System ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━ System ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
heartbeat Log system state to Sheets heartbeat Log system state to Sheets
audit list [--agent <id>] [--since YYYY-MM-DD] View audit log
doctor Check Python version and dependencies
Options: Options:
--help, -h Show this help --help, -h Show this help
@ -174,6 +213,31 @@ done
export DEBUG DRY_RUN GARC_DIR GARC_LIB GARC_CONFIG GARC_CACHE_DIR GARC_CACHE_TTL export DEBUG DRY_RUN GARC_DIR GARC_LIB GARC_CONFIG GARC_CACHE_DIR GARC_CACHE_TTL
# ── Non-blocking audit log ─────────────────────────────────────────────────
# Fires in background so it never blocks or fails the main command.
_garc_audit_log() {
local cmd="$1"
local args_str="$2"
local result="${3:-ok}"
local sheets_id="${GARC_SHEETS_ID:-}"
local agent_id="${GARC_DEFAULT_AGENT:-}"
[[ -z "${sheets_id}" ]] && return 0
[[ "${cmd}" == "audit" ]] && return 0 # don't audit audit itself
(
python3 "${GARC_DIR}/scripts/garc-sheets-helper.py" audit-append \
--sheets-id "${sheets_id}" \
--agent-id "${agent_id}" \
--cmd "${cmd}" \
--args "${args_str}" \
--result "${result}" \
--user "${USER:-}" \
--timestamp "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
2>/dev/null
) &
}
COMMAND="${1:-help}" COMMAND="${1:-help}"
shift || true shift || true
@ -254,6 +318,61 @@ case "${COMMAND}" in
source "${GARC_LIB}/daemon.sh" source "${GARC_LIB}/daemon.sh"
garc_daemon "$@" garc_daemon "$@"
;; ;;
audit)
source "${GARC_LIB}/audit.sh"
_garc_audit_log "audit" "$*"
garc_audit "$@"
;;
profile)
source "${GARC_LIB}/profile.sh"
garc_profile "$@"
;;
forms)
source "${GARC_LIB}/forms.sh"
garc_forms "$@"
;;
doctor)
python3 - <<'PY'
import sys, importlib, subprocess
print("GARC Doctor — Environment Check")
print("─" * 40)
# Python version
pv = sys.version_info
status = "✅" if pv >= (3, 10) else "❌"
print(f"{status} Python {pv.major}.{pv.minor}.{pv.micro} (required: >=3.10,<3.13)")
# Required packages
required = [
("googleapiclient", "google-api-python-client"),
("google.auth", "google-auth"),
("google_auth_oauthlib", "google-auth-oauthlib"),
("httplib2", "google-auth-httplib2"),
("requests", "requests"),
("yaml", "pyyaml"),
("dateutil", "python-dateutil"),
("rich", "rich"),
]
print()
print("Dependencies:")
all_ok = True
for module, pkg in required:
try:
importlib.import_module(module)
print(f" ✅ {pkg}")
except ImportError:
print(f" ❌ {pkg} → pip install {pkg}")
all_ok = False
print()
if all_ok:
print("✅ All checks passed.")
else:
print("⚠️ Some packages missing. Run: pip install -r requirements.txt")
sys.exit(1)
PY
;;
help|--help|-h) help|--help|-h)
usage usage
;; ;;

View file

@ -28,8 +28,19 @@ GARC_TOKEN_FILE=~/.garc/token.json
# Service account JSON file (for bot/automated operations) # Service account JSON file (for bot/automated operations)
# GARC_SERVICE_ACCOUNT_FILE=~/.garc/service_account.json # GARC_SERVICE_ACCOUNT_FILE=~/.garc/service_account.json
# Domain-wide Delegation: impersonate this user when using a service account
# Requires DWD to be enabled in Google Workspace Admin Console
# GARC_IMPERSONATE_EMAIL=user@yourdomain.com
# Default agent ID # Default agent ID
GARC_DEFAULT_AGENT=main GARC_DEFAULT_AGENT=main
# Cache directory # Cache directory
GARC_CACHE_DIR=~/.garc/cache GARC_CACHE_DIR=~/.garc/cache
# Approval gate: email address to notify when an item requires human approval
# If unset, approval notifications are skipped (item is still blocked in queue)
GARC_APPROVAL_EMAIL=approver@example.com
# Auto-confirm preview gate in non-interactive mode (daemon/CI use only)
GARC_AUTO_CONFIRM=false

View file

@ -0,0 +1,327 @@
# GARC v0.1.0 — ファインディング対応プレイブック
> 作成: 2026-04-15
> 対象バージョン: v0.1.0 (commit 680bd43)
> 実稼働テスト + コードレビューによるファインディングをもとに作成。
---
## 優先度定義
| レベル | 基準 |
|--------|------|
| **P0** | 即座に運用を妨げる。リリース前に必須対応 |
| **P1** | 継続運用で詰まる。初回本番投入前に対応 |
| **P2** | 機能欠如。SMB〜エンタープライズ展開前に対応 |
| **P3** | 将来の拡張。ロードマップに計上 |
---
## P0 — リリースブロッカー (3件)
---
### P0-1: `garc agent register` が重複登録する
**症状**
`garc agent register` を複数回実行すると `agents.yaml` の全エージェントが毎回 Sheets に追記される。一意性チェックがないため、台帳が汚染される。
**対応方策**
1. `garc-setup.py``register_agents()` で、登録前に `agents` タブを読み取り `id` カラムで既存チェック
2. 既存エントリは `skip`、変更がある場合は `update`(行を上書き)、新規のみ `append`
3. 出力に `Registered N / Skipped N / Updated N` を表示
**実装ファイル**
- `scripts/garc-setup.py``register_agents()` 関数
- `lib/agent.sh``_agent_register()` でも同様のチェックが必要
**受入条件**
- 同一 `id` のエージェントを2回登録しても Sheets 行数が増えない
- `garc agent list` で重複行が表示されない
---
### P0-2: `garc daemon poll-once` が Gmail 取得後にエンキューしない
**症状**
`garc daemon poll-once` でポーリングループを5秒で強制終了するため、Gmail JSON fetch の完了前にプロセスが kill される。未読メールが取得されてもキューに入らない。
**対応方策**
1. `_daemon_poll_once()` の実装を「ループ起動 + 5秒後 kill」から「1回だけ前景実行」に変更
2. `_gmail_poller_loop()``--once` フラグを追加。`once=true` の場合は1サイクル実行後に `break`
3. `poll-once` はループを spawn せず直接 `_gmail_poller_loop_once()` を呼ぶ
**実装ファイル**
- `lib/daemon.sh``_daemon_poll_once()` および `_gmail_poller_loop()`
**受入条件**
- `garc daemon poll-once` 実行後に `garc ingress list` で Gmail 由来のキューアイテムが確認できる
---
### P0-3: 全コマンドに `requests` ライブラリ警告が出力される
**症状**
Python 3.14 + urllib3/chardet バージョン不一致により、全 Python ヘルパー実行時に警告が stderr に出力される。自動化スクリプトや JSON パースが壊れる。
```
RequestsDependencyWarning: urllib3 (2.6.3) or chardet (7.4.0.post1)/charset_normalizer (3.4.4) doesn't match a supported version!
```
**対応方策**
1. `garc_core.py` の先頭に `warnings.filterwarnings("ignore", category=Warning, module="requests")` を追加
2. あわせて `requirements.txt``requests``urllib3` と互換バージョンに固定 (`urllib3>=2.0,<3`)
3. 根本解決: `google-api-python-client` が依存する `httplib2` + `requests` のバージョンを揃える
**実装ファイル**
- `scripts/garc_core.py` — 先頭に警告抑制を追加
- `requirements.txt` — バージョン固定
**受入条件**
- `garc gmail profile` の出力に警告行がゼロ
---
## P1 — 本番投入前必須 (4件)
---
### P1-1: OAuth トークン自動リフレッシュが未検証
**症状**
`token.json` の有効期限は約1時間。期限切れ後の自動リフレッシュが実際に動作するか未検証。長時間運用の daemon や ingress 処理でトークン切れが起きると全 API 呼出しが失敗する。
**対応方策**
1. `garc_core.py``get_credentials()``creds.refresh_token` を使った自動更新を行っていることを確認
2. `garc auth status` に有効期限 + 残り時間を表示するオプション追加
3. 期限 5 分前に daemon ログに警告を出す仕組みを `_gmail_poller_loop()` に追加
4. 統合テスト: トークンを意図的に期限切れさせてリフレッシュが動くことを確認
**実装ファイル**
- `scripts/garc_core.py``get_credentials()`
- `scripts/garc-auth-helper.py``status` サブコマンド
- `lib/daemon.sh` — ループ内でトークン期限チェック
**受入条件**
- 1時間以上連続稼働した daemon が API 失敗なしで動作する
---
### P1-2: `ingress run-once` 後にキューが `in_progress` のまま詰まる
**症状**
`garc ingress run-once` で Claude Code へプロンプトを渡した後、Claude が作業を完了しても `garc ingress done` を手動で呼ばないとステータスが `in_progress` のままになる。キューが消化されずに詰まる。
**対応方策**
1. `garc ingress run-once` の出力末尾に必ず以下を含める:
```
完了後に必ず実行: garc ingress done --queue-id <id> --note "<完了内容>"
```
2. Claude Code スキル (`SKILL.md`) に「作業完了後 `garc ingress done` を実行する」ルールを明記
3. `none` ゲートのアイテムは自動で `done` にする `-auto-close` フラグを検討
4. `garc ingress list``in_progress` が一定時間 (例: 30分) 以上のアイテムに警告表示
**実装ファイル**
- `lib/ingress.sh``_ingress_run_once()` 出力末尾
- `.claude/skills/garc-runtime/SKILL.md` — 完了ルールの明記
**受入条件**
- `run-once` の出力末尾に `done` コマンドのガイダンスが表示される
---
### P1-3: Google Sheets の初期空行 1000 行問題
**症状**
`garc setup all` でプロビジョニングした Sheets の各タブが 1000〜1004 行の空行を持つ。`sheets read``memory search` で空行がヒットし、結果が汚染される。
**対応方策**
1. `garc-setup.py` の Sheets 作成時に空行を事前に挿入しない (`batchUpdate` で行数指定)
2. 既存 Sheets の空行クリーンアップコマンド `garc setup cleanup-sheets` を追加
3. `sheets read` / `memory search` で空行 (全カラムが空) を結果から除外するフィルタを追加
**実装ファイル**
- `scripts/garc-setup.py` — Sheets provisioning
- `scripts/garc-sheets-helper.py` — 空行フィルタ
- `scripts/garc_core.py` — Sheets 読取ユーティリティ
**受入条件**
- `garc sheets read --range "memory!A:E"` が空行ゼロで返る
---
### P1-4: approval gate のブロック後に承認者への通知手段がない
**症状**
`approval` ゲートでキューが `blocked` 状態になっても、承認者 (人間) への通知手段がない。Google Chat 未実装のため、承認リクエストが放置される。
**対応方策**
1. `approval` ゲート発動時に承認依頼メールを Gmail で自動送信 (短期対応)
- `GARC_APPROVAL_EMAIL` 環境変数で承認者メールを指定
- `_ingress_run_once()` の approval 分岐で `garc gmail send` を内部呼出し
2. 承認メールに `garc ingress approve --queue-id <id>` のコマンドを記載
3. (中期) Google Chat 実装後は Chat にも通知
**実装ファイル**
- `lib/ingress.sh``_ingress_run_once()` approval 分岐
- `config/config.env.example``GARC_APPROVAL_EMAIL` の追加
**受入条件**
- `approval` ゲートのタスクをエンキューすると承認者メールが届く
---
## P2 — エンタープライズ展開前必須 (6件)
---
### P2-1: Google Chat 通知未実装
**症状**
承認通知・完了通知・エラー通知をリアルタイムで受け取る手段がない。Gmail メールは遅延があり、Chat スペースへの投稿ができない。
**対応方策**
1. `lib/chat.sh` + `scripts/garc-chat-helper.py` を新規作成
2. Google Chat API (`chat.googleapis.com`) の `spaces.messages.create` を実装
3. `garc chat send --space <id> --text "<msg>"` コマンドを追加
4. `GARC_CHAT_SPACE_ID` 環境変数でデフォルトスペースを設定
5. 承認通知・ingress done 通知・heartbeat アラートを Chat に自動送信
**実装ファイル**
- `lib/chat.sh` (新規)
- `scripts/garc-chat-helper.py` (新規)
- `bin/garc``chat` コマンドのディスパッチ追加
---
### P2-2: Service Account / Domain-wide Delegation 未対応
**症状**
現状はユーザー OAuth トークンのみ対応。ヘッドレス・ボット運用 (daemon、CI/CD) では Service Account が必要。企業のポリシーでユーザー OAuth を使えないケースに対応不可。
**対応方策**
1. `garc_core.py``get_credentials()` に Service Account フローを追加
```python
if GARC_SERVICE_ACCOUNT_FILE:
creds = service_account.Credentials.from_service_account_file(...)
```
2. `garc auth login --service-account` サブコマンドを追加
3. `GARC_USE_SERVICE_ACCOUNT=true` 環境変数でフロー切替
4. Domain-wide Delegation 用の subject 設定 (`GARC_SA_SUBJECT`) を追加
**実装ファイル**
- `scripts/garc_core.py`
- `scripts/garc-auth-helper.py`
- `config/config.env.example`
---
### P2-3: 監査ログ未実装
**症状**
「誰がいつ何のコマンドを実行したか」の記録がない。法人コンプライアンス要件 (SOC2, ISO27001等) に非対応。
**対応方策**
1. `garc_core.py``audit_log(action, resource, result)` 関数を追加
2. 全 write 系操作 (gmail send, calendar create, sheets append, ingress done 等) で自動ログ記録
3. ログ先: Google Sheets の `audit_log` タブ + ローカル `~/.garc/cache/logs/audit.jsonl`
4. `garc audit log` コマンドでログ参照
5. 将来: GCP Cloud Logging 連携
**実装ファイル**
- `scripts/garc_core.py``audit_log()` デコレータ
- `scripts/garc-setup.py``audit_log` タブ作成
- `lib/audit.sh` (新規)
---
### P2-4: `garc auth revoke` 未実装
**症状**
トークン失効の方法が `rm ~/.garc/token.json` の手動作業のみ。セキュリティインシデント時やオフボーディング時に正式な revoke フローがない。
**対応方策**
1. `garc-auth-helper.py``revoke` サブコマンドを追加
```python
requests.post("https://oauth2.googleapis.com/revoke", params={"token": creds.token})
```
2. revoke 後に `token.json` を削除
3. `garc auth revoke --all` で全プロファイルのトークンを一括失効
4. `lib/auth.sh``_auth_revoke()` を追加
**実装ファイル**
- `scripts/garc-auth-helper.py`
- `lib/auth.sh`
---
### P2-5: `garc kg query` の E2E 動作未確認
**症状**
`garc kg build` は動作確認済みだが `garc kg query` の実動作が未検証。ナレッジグラフ検索が Claude Code の実行コンテキストに正しく渡されるか不明。
**対応方策**
1. `garc kg query "<keyword>"` の実動作確認テスト実施
2. `lib/kg.sh` + `scripts/garc-drive-helper.py``kg-query` サブコマンドを確認
3. 検索結果が `ingress context` の Claude プロンプトに含まれることを確認
4. `garc bootstrap` 時に KG を自動取込するフローの確認
**実装ファイル**
- `lib/kg.sh`
- `scripts/garc-drive-helper.py`
---
### P2-6: Google Docs 本文編集未実装
**症状**
`garc drive create-doc` で新規ドキュメントは作成できるが、既存 Docs の本文への書込み・編集ができない。週次レポートの自動更新など主要ユースケースがブロックされる。
**対応方策**
1. Google Docs API の `documents.batchUpdate` を使った本文編集を実装
2. `garc drive edit-doc --file-id <id> --append "<text>"` コマンドを追加
3. Markdown → Docs 変換 (最低限: 見出し・段落・箇条書き)
4. `garc drive read-doc --file-id <id>` で本文取得も実装
**実装ファイル**
- `scripts/garc-drive-helper.py``edit-doc`, `read-doc` サブコマンド
- `lib/drive.sh``edit-doc`, `read-doc` ディスパッチ
---
## P3 — ロードマップ計上 (4件)
---
### P3-1: マルチテナント (複数 Google Workspace 組織) 未対応
単一 OAuth クライアント + 単一 `config.env` のため、複数組織の同時管理が不可。将来的には組織ごとのプロファイルディレクトリ (`~/.garc/profiles/<org>/`) への切替機能が必要。
---
### P3-2: Google Forms → 自動エンキューパイプライン未実装
Google Forms の回答を Sheets に集約し、新規回答を ingress キューに自動投入するパイプライン。Apps Script または Pub/Sub トリガーとの連携が必要。
---
### P3-3: Linux systemd 対応 (daemon install)
現状は macOS launchd のみ。`garc daemon install --systemd``/etc/systemd/system/garc-gmail-poller.service` を生成するオプションが必要。
---
### P3-4: Python 3.10〜3.12 推奨環境への固定
Python 3.14 では `requests` 依存ライブラリとの互換性警告が発生。`setup.py` / `pyproject.toml``python_requires=">=3.10,<3.14"` を明示し、推奨バージョンをドキュメント化する。
---
## 対応スケジュール案
| フェーズ | 対象 | 目標バージョン |
|---|---|---|
| Sprint 1 (即時) | P0-1, P0-2, P0-3 | v0.1.1 |
| Sprint 2 (1週間) | P1-1, P1-2, P1-3, P1-4 | v0.1.2 |
| Sprint 3 (2〜3週間) | P2-1 (Chat), P2-2 (SA), P2-3 (Audit), P2-4 (Revoke) | v0.2.0 |
| Sprint 4 (1ヶ月+) | P2-5, P2-6, P3-1〜P3-4 | v0.3.0 |

47
lib/audit.sh Normal file
View file

@ -0,0 +1,47 @@
#!/usr/bin/env bash
# GARC audit.sh — Audit log viewer
# Events are appended to the 'audit' tab in Google Sheets by bin/garc.
garc_audit() {
local subcommand="${1:-list}"
shift || true
case "${subcommand}" in
list) _audit_list "$@" ;;
*)
cat <<EOF
Usage: garc audit <subcommand>
Subcommands:
list [--agent <id>] [--since YYYY-MM-DD] [--format table|json]
Show audit log from Google Sheets
EOF
return 1
;;
esac
}
_audit_list() {
local sheets_id="${GARC_SHEETS_ID:-}"
local agent_id="" since="" fmt="table"
while [[ $# -gt 0 ]]; do
case "$1" in
--agent|-a) agent_id="$2"; shift 2 ;;
--since|-s) since="$2"; shift 2 ;;
--format|-f) fmt="$2"; shift 2 ;;
*) shift ;;
esac
done
if [[ -z "${sheets_id}" ]]; then
echo "Error: GARC_SHEETS_ID not set" >&2
return 1
fi
python3 "${GARC_DIR}/scripts/garc-sheets-helper.py" audit-list \
--sheets-id "${sheets_id}" \
${agent_id:+--agent-id "${agent_id}"} \
${since:+--since "${since}"} \
--format "${fmt}"
}

View file

@ -16,8 +16,20 @@ garc_auth() {
check) garc_auth_check "$@" ;; check) garc_auth_check "$@" ;;
login) garc_auth_login "$@" ;; login) garc_auth_login "$@" ;;
status) garc_auth_status "$@" ;; status) garc_auth_status "$@" ;;
revoke) garc_auth_revoke "$@" ;;
service-account) garc_auth_service_account "$@" ;;
*) *)
echo "Usage: garc auth <suggest|check|login|status>" cat <<EOF
Usage: garc auth <subcommand>
Subcommands:
suggest "<task>" Infer minimum OAuth scopes for a task
check [--profile <p>] Check if current token covers required scopes
login [--profile <p>] Launch OAuth2 authorization flow
status Show current token info and scopes
revoke Revoke and delete the stored OAuth token
service-account verify Verify service account credentials and scopes
EOF
return 1 return 1
;; ;;
esac esac
@ -57,18 +69,25 @@ garc_auth_check() {
python3 "${AUTH_HELPER}" check --profile "${profile}" python3 "${AUTH_HELPER}" check --profile "${profile}"
} }
# garc auth login [--profile <profile>] # garc auth login [--profile <profile>] [--type oauth|service-account]
# Launches OAuth2 authorization flow # Launches OAuth2 authorization flow, or validates service account
garc_auth_login() { garc_auth_login() {
local profile="writer" local profile="writer"
local auth_type="oauth"
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--profile) profile="$2"; shift 2 ;; --profile) profile="$2"; shift 2 ;;
--type) auth_type="$2"; shift 2 ;;
*) shift ;; *) shift ;;
esac esac
done done
if [[ "${auth_type}" == "service-account" ]]; then
garc_auth_service_account verify
return $?
fi
python3 "${AUTH_HELPER}" login --profile "${profile}" python3 "${AUTH_HELPER}" login --profile "${profile}"
} }
@ -77,3 +96,49 @@ garc_auth_login() {
garc_auth_status() { garc_auth_status() {
python3 "${AUTH_HELPER}" status python3 "${AUTH_HELPER}" status
} }
# garc auth revoke
# Revoke and delete the stored OAuth token
garc_auth_revoke() {
python3 "${AUTH_HELPER}" revoke
}
# garc auth service-account <verify|info>
garc_auth_service_account() {
local sub="${1:-verify}"
shift || true
local sa_file="${GARC_SERVICE_ACCOUNT_FILE:-${HOME}/.garc/service_account.json}"
case "${sub}" in
verify)
if [[ ! -f "${sa_file}" ]]; then
echo "❌ Service account file not found: ${sa_file}"
echo " Set GARC_SERVICE_ACCOUNT_FILE in ~/.garc/config.env"
echo " Or download from Google Cloud Console → IAM & Admin → Service Accounts"
return 1
fi
echo "Verifying service account credentials..."
python3 "${AUTH_HELPER}" service-account-verify --file "${sa_file}"
;;
info)
if [[ ! -f "${sa_file}" ]]; then
echo "❌ Service account file not found: ${sa_file}"
return 1
fi
python3 -c "
import json
with open('${sa_file}') as f:
sa = json.load(f)
print('Service Account:')
print(f\" Email : {sa.get('client_email', 'N/A')}\")
print(f\" Project: {sa.get('project_id', 'N/A')}\")
print(f\" Type : {sa.get('type', 'N/A')}\")
"
;;
*)
echo "Usage: garc auth service-account <verify|info>"
return 1
;;
esac
}

View file

@ -138,41 +138,21 @@ _start_gmail_poller() {
# Gmail polling loop — the core ingress driver # Gmail polling loop — the core ingress driver
# ───────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────
_gmail_poller_loop() { # ── Single poll cycle (shared by loop and poll-once) ─────────────────────────
_gmail_poll_cycle() {
local agent_id="${1:-main}" local agent_id="${1:-main}"
local interval="${2:-60}" local max_msgs="${2:-10}"
local label="${3:-INBOX}"
local max_msgs="${4:-10}"
local seen_file="${DAEMON_SEEN_DIR}/seen-${agent_id}.txt" local seen_file="${DAEMON_SEEN_DIR}/seen-${agent_id}.txt"
touch "${seen_file}" touch "${seen_file}"
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] [gmail-poller] Starting (agent=${agent_id}, interval=${interval}s, label=${label})" python3 - "${seen_file}" "${agent_id}" "${max_msgs}" <<'PY'
import json, sys, subprocess, os
# 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] seen_file = sys.argv[1]
agent_id = sys.argv[2] agent_id = sys.argv[2]
max_msgs = sys.argv[3]
garc_dir = os.environ.get("GARC_DIR", "") garc_dir = os.environ.get("GARC_DIR", "")
garc_lib = os.environ.get("GARC_LIB", "")
# Read seen message IDs # Read seen message IDs
try: try:
@ -181,55 +161,49 @@ try:
except Exception: except Exception:
seen = set() seen = set()
# Parse inbox output (table format from gmail helper) # Fetch inbox as JSON
# 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( result = subprocess.run(
["python3", os.path.join(garc_dir, "scripts", "garc-gmail-helper.py"), ["python3", os.path.join(garc_dir, "scripts", "garc-gmail-helper.py"),
"inbox", "--max", "10", "--unread", "--format", "json"], "inbox", "--max", max_msgs, "--unread", "--format", "json"],
capture_output=True, text=True capture_output=True, text=True
) )
if result.returncode != 0: if result.returncode != 0:
print(f"[gmail-poller] inbox fetch error: {result.stderr.strip()}", flush=True) print(f"[gmail-poller] inbox fetch error: {result.stderr.strip()}", flush=True)
sys.exit(0) sys.exit(1)
try: try:
messages = json.loads(result.stdout) messages = json.loads(result.stdout)
except Exception as e: except Exception as e:
print(f"[gmail-poller] JSON parse error: {e}", flush=True) print(f"[gmail-poller] JSON parse error: {e}", flush=True)
sys.exit(0) sys.exit(1)
if not isinstance(messages, list): if not isinstance(messages, list):
messages = [] messages = []
new_seen = [] new_seen = []
enqueued = 0
for msg in messages: for msg in messages:
msg_id = msg.get("id", "") msg_id = msg.get("id", "")
sender = msg.get("from", "") sender = msg.get("from", "")
subject = msg.get("subject", "(no subject)") subject = msg.get("subject", "(no subject)")
snippet = msg.get("snippet", "")[:120] snippet = msg.get("snippet", "")[:120]
if not msg_id or msg_id in seen: if not msg_id:
continue
if msg_id in seen:
new_seen.append(msg_id) new_seen.append(msg_id)
continue continue
# Build a human-readable task description
text = f"Email from {sender}: {subject}" text = f"Email from {sender}: {subject}"
if snippet: if snippet:
text += f" — {snippet}" text += f" — {snippet}"
cmd = [ garc_bin = os.path.join(garc_dir, "bin", "garc")
"garc", "ingress", "enqueue", cmd = [garc_bin, "ingress", "enqueue",
"--text", text, "--text", text,
"--source", "gmail", "--source", "gmail",
"--sender", sender, "--sender", sender,
"--agent", agent_id, "--agent", agent_id]
]
# Use larc path
garc_bin = os.path.join(garc_dir, "bin", "garc")
cmd[0] = garc_bin
env = os.environ.copy() env = os.environ.copy()
env["GARC_DIR"] = garc_dir env["GARC_DIR"] = garc_dir
@ -237,6 +211,7 @@ for msg in messages:
r = subprocess.run(cmd, capture_output=True, text=True, env=env) r = subprocess.run(cmd, capture_output=True, text=True, env=env)
if r.returncode == 0: if r.returncode == 0:
print(f"[gmail-poller] Enqueued: {msg_id[:16]} from {sender[:30]}", flush=True) print(f"[gmail-poller] Enqueued: {msg_id[:16]} from {sender[:30]}", flush=True)
enqueued += 1
else: else:
print(f"[gmail-poller] Enqueue failed: {r.stderr.strip()}", flush=True) print(f"[gmail-poller] Enqueue failed: {r.stderr.strip()}", flush=True)
@ -245,8 +220,27 @@ for msg in messages:
if new_seen: if new_seen:
with open(seen_file, "a") as f: with open(seen_file, "a") as f:
f.write("\n".join(new_seen) + "\n") f.write("\n".join(new_seen) + "\n")
PY
print(f"[gmail-poller] Cycle done — {enqueued} enqueued, {len(messages) - enqueued} skipped", flush=True)
PY
}
_gmail_poller_loop() {
local agent_id="${1:-main}"
local interval="${2:-60}"
local label="${3:-INBOX}"
local max_msgs="${4:-10}"
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
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] [gmail-poller] Polling..."
_gmail_poll_cycle "${agent_id}" "${max_msgs}" || \
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] [gmail-poller] cycle error, will retry"
sleep "${interval}" sleep "${interval}"
done done
} }
@ -337,12 +331,17 @@ _daemon_poll_once() {
_daemon_ensure_dirs _daemon_ensure_dirs
# Reload config so GARC_DIR etc. are available in subprocesses
if [[ -f "${GARC_CONFIG:-${HOME}/.garc}/config.env" ]]; then
set -a
# shellcheck disable=SC1090
source "${GARC_CONFIG:-${HOME}/.garc}/config.env" 2>/dev/null || true
set +a
fi
echo "🔍 Polling Gmail inbox (agent=${agent}, max=${max_msgs})..." echo "🔍 Polling Gmail inbox (agent=${agent}, max=${max_msgs})..."
_gmail_poller_loop "${agent}" "0" "INBOX" "${max_msgs}" & # Run a single cycle synchronously — no background process, no timeout kill
local pid=$! _gmail_poll_cycle "${agent}" "${max_msgs}"
# Wait a moment for one cycle to complete then stop
sleep 5
kill "${pid}" 2>/dev/null || true
echo "" echo ""
echo "Poll cycle complete. Check: garc ingress list" echo "Poll cycle complete. Check: garc ingress list"
} }
@ -373,27 +372,57 @@ _daemon_logs() {
} }
# ───────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────
# install — macOS launchd plist for auto-start on login # install — OS-aware service installation (macOS launchd or Linux systemd)
# ───────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────
_daemon_install_launchd() { _daemon_install_launchd() {
# Backward-compatible alias — delegates to _daemon_install_service
_daemon_install_service "$@"
}
_daemon_install_service() {
local agent="${GARC_DEFAULT_AGENT:-main}" local agent="${GARC_DEFAULT_AGENT:-main}"
local interval=60 local interval=60
local label="com.garc.gmail-poller" local system_wide=false
local plist_path="${HOME}/Library/LaunchAgents/${label}.plist"
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--agent|-a) agent="$2"; shift 2 ;; --agent|-a) agent="$2"; shift 2 ;;
--interval|-i) interval="$2"; shift 2 ;; --interval|-i) interval="$2"; shift 2 ;;
--system) system_wide=true; shift ;;
*) shift ;; *) shift ;;
esac esac
done done
_daemon_ensure_dirs _daemon_ensure_dirs
local os_type
os_type="$(uname -s)"
case "${os_type}" in
Darwin)
_daemon_install_launchd_plist "${agent}" "${interval}"
;;
Linux)
_daemon_install_systemd_unit "${agent}" "${interval}" "${system_wide}"
;;
*)
echo "⚠️ Unsupported OS: ${os_type}"
echo " Manual setup: run 'garc daemon start' from your init system."
return 1
;;
esac
}
_daemon_install_launchd_plist() {
local agent="$1"
local interval="$2"
local label="com.garc.gmail-poller"
local plist_path="${HOME}/Library/LaunchAgents/${label}.plist"
local garc_bin="${GARC_DIR}/bin/garc" local garc_bin="${GARC_DIR}/bin/garc"
mkdir -p "$(dirname "${plist_path}")"
cat > "${plist_path}" <<EOF cat > "${plist_path}" <<EOF
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
@ -437,3 +466,80 @@ EOF
echo "To unload:" echo "To unload:"
echo " launchctl unload ${plist_path}" echo " launchctl unload ${plist_path}"
} }
_daemon_install_systemd_unit() {
local agent="$1"
local interval="$2"
local system_wide="$3"
local garc_bin="${GARC_DIR}/bin/garc"
local unit_name="garc-gmail-poller"
local unit_dir
if [[ "${system_wide}" == "true" ]]; then
unit_dir="/etc/systemd/system"
else
unit_dir="${HOME}/.config/systemd/user"
fi
mkdir -p "${unit_dir}"
local unit_path="${unit_dir}/${unit_name}.service"
local timer_path="${unit_dir}/${unit_name}.timer"
# Service unit — runs one poll cycle
cat > "${unit_path}" <<EOF
[Unit]
Description=GARC Gmail Poller (single cycle)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=${garc_bin} daemon poll-once --agent ${agent}
Environment=GARC_DIR=${GARC_DIR}
Environment=PATH=/usr/local/bin:/usr/bin:/bin
StandardOutput=append:${GMAIL_POLLER_LOG}
StandardError=append:${GMAIL_POLLER_LOG}
[Install]
WantedBy=default.target
EOF
# Timer unit — triggers service every N seconds
cat > "${timer_path}" <<EOF
[Unit]
Description=GARC Gmail Poller Timer
[Timer]
OnBootSec=30
OnUnitActiveSec=${interval}
Unit=${unit_name}.service
[Install]
WantedBy=timers.target
EOF
echo "✅ Installed systemd units:"
echo " ${unit_path}"
echo " ${timer_path}"
echo ""
if [[ "${system_wide}" == "true" ]]; then
echo "To activate (system-wide, requires sudo):"
echo " sudo systemctl daemon-reload"
echo " sudo systemctl enable --now ${unit_name}.timer"
echo ""
echo "To disable:"
echo " sudo systemctl disable --now ${unit_name}.timer"
else
echo "To activate (user-level):"
echo " systemctl --user daemon-reload"
echo " systemctl --user enable --now ${unit_name}.timer"
echo " systemctl --user status ${unit_name}.timer"
echo ""
echo "To disable:"
echo " systemctl --user disable --now ${unit_name}.timer"
echo ""
echo "To view logs:"
echo " journalctl --user -u ${unit_name}.service -f"
fi
}

View file

@ -16,6 +16,7 @@ garc_drive() {
upload) garc_drive_upload "$@" ;; upload) garc_drive_upload "$@" ;;
create-folder) garc_drive_create_folder "$@" ;; create-folder) garc_drive_create_folder "$@" ;;
create-doc) garc_drive_create_doc "$@" ;; create-doc) garc_drive_create_doc "$@" ;;
append-doc) garc_drive_append_doc "$@" ;;
share) garc_drive_share "$@" ;; share) garc_drive_share "$@" ;;
move) garc_drive_move "$@" ;; move) garc_drive_move "$@" ;;
delete) garc_drive_delete "$@" ;; delete) garc_drive_delete "$@" ;;
@ -31,6 +32,7 @@ Subcommands:
upload <local_path> [--folder-id <id>] [--name <name>] [--convert] upload <local_path> [--folder-id <id>] [--name <name>] [--convert]
create-folder <name> [--parent-id <id>] create-folder <name> [--parent-id <id>]
create-doc <name> [--folder-id <id>] [--content <text>] create-doc <name> [--folder-id <id>] [--content <text>]
append-doc <doc_id> --content <text>
share <file_id> --email <email> [--role reader|writer|commenter] share <file_id> --email <email> [--role reader|writer|commenter]
move <file_id> --to <folder_id> move <file_id> --to <folder_id>
delete <file_id> [--permanent] delete <file_id> [--permanent]
@ -172,6 +174,24 @@ garc_drive_create_doc() {
${content:+--content "${content}"} ${content:+--content "${content}"}
} }
garc_drive_append_doc() {
local doc_id="${1:-}"
shift || true
local content=""
while [[ $# -gt 0 ]]; do
case "$1" in
--content|-c) content="$2"; shift 2 ;;
*) doc_id="${doc_id:-$1}"; shift ;;
esac
done
[[ -z "${doc_id}" ]] && { echo "Usage: garc drive append-doc <doc_id> --content <text>"; return 1; }
[[ -z "${content}" ]] && { echo "Usage: garc drive append-doc <doc_id> --content <text>"; return 1; }
python3 "${DRIVE_HELPER}" append-doc "${doc_id}" --content "${content}"
}
garc_drive_share() { garc_drive_share() {
local file_id="${1:-}" local file_id="${1:-}"
shift || true shift || true

90
lib/forms.sh Normal file
View file

@ -0,0 +1,90 @@
#!/usr/bin/env bash
# GARC forms.sh — Google Forms response pipeline
# Polls Forms for new responses and auto-enqueues them via garc ingress.
FORMS_HELPER="${GARC_DIR}/scripts/garc-forms-helper.py"
garc_forms() {
local subcommand="${1:-help}"
shift || true
case "${subcommand}" in
list) garc_forms_list "$@" ;;
responses) garc_forms_responses "$@" ;;
watch) garc_forms_watch "$@" ;;
*)
cat <<EOF
Usage: garc forms <subcommand>
Subcommands:
list List accessible Google Forms
responses <form_id> [--max N] List responses for a form
watch <form_id> --agent <id> Poll form and auto-enqueue new responses
[--interval <sec>] Poll interval (default: 60)
[--max <N>] Max responses per cycle (default: 10)
Examples:
garc forms list
garc forms responses 1xxxxxxxxxxxxxxxx
garc forms watch 1xxxxxxxxxxxxxxxx --agent main --interval 30
EOF
return 1
;;
esac
}
garc_forms_list() {
python3 "${FORMS_HELPER}" list-forms
}
garc_forms_responses() {
local form_id="${1:-}"
shift || true
local max=50 since="" fmt="table"
while [[ $# -gt 0 ]]; do
case "$1" in
--max|-n) max="$2"; shift 2 ;;
--since) since="$2"; shift 2 ;;
--format) fmt="$2"; shift 2 ;;
*) shift ;;
esac
done
[[ -z "${form_id}" ]] && { echo "Usage: garc forms responses <form_id> [--max N]"; return 1; }
python3 "${FORMS_HELPER}" list-responses "${form_id}" \
--max "${max}" \
${since:+--since "${since}"} \
--format "${fmt}"
}
garc_forms_watch() {
local form_id="${1:-}"
shift || true
local agent="${GARC_DEFAULT_AGENT:-main}" interval=60 max=10
while [[ $# -gt 0 ]]; do
case "$1" in
--agent|-a) agent="$2"; shift 2 ;;
--interval|-i) interval="$2"; shift 2 ;;
--max|-n) max="$2"; shift 2 ;;
*) form_id="${form_id:-$1}"; shift ;;
esac
done
[[ -z "${form_id}" ]] && {
echo "Usage: garc forms watch <form_id> --agent <id> [--interval <sec>]"
return 1
}
echo "👁 Watching form: ${form_id}"
echo " Agent: ${agent} Interval: ${interval}s"
echo " Press Ctrl+C to stop."
echo ""
python3 "${FORMS_HELPER}" watch "${form_id}" \
--agent "${agent}" \
--interval "${interval}" \
--max "${max}"
}

View file

@ -29,6 +29,7 @@ garc_ingress() {
fail) _ingress_fail "$@" ;; fail) _ingress_fail "$@" ;;
verify) _ingress_verify "$@" ;; verify) _ingress_verify "$@" ;;
stats) _ingress_stats "$@" ;; stats) _ingress_stats "$@" ;;
stale-reset) _ingress_stale_reset "$@" ;;
*) *)
cat <<EOF cat <<EOF
Usage: garc ingress <subcommand> [options] Usage: garc ingress <subcommand> [options]
@ -48,6 +49,7 @@ Subcommands:
fail --queue-id <id> [--note <text>] fail --queue-id <id> [--note <text>]
verify --queue-id <id> Verify expected output was produced verify --queue-id <id> Verify expected output was produced
stats Queue statistics stats Queue statistics
stale-reset [--timeout <minutes>] Reset in_progress items older than N min (default: 30)
Examples: Examples:
garc ingress enqueue --text "Send weekly report to manager" garc ingress enqueue --text "Send weekly report to manager"
@ -314,6 +316,36 @@ _ingress_run_once() {
_ingress_update_status "${queue_id}" "blocked" _ingress_update_status "${queue_id}" "blocked"
source "${GARC_LIB}/approve.sh" source "${GARC_LIB}/approve.sh"
garc_approve_create "${message}" garc_approve_create "${message}"
# ── Notify approver via Gmail ─────────────────────────────
local approval_email="${GARC_APPROVAL_EMAIL:-}"
if [[ -n "${approval_email}" ]]; then
local subject="[GARC Approval Required] ${message:0:60}"
local body
body="$(cat <<BODY
A task requires your approval before execution.
Queue ID : ${queue_id}
Agent : ${agent}
Task : ${message}
To approve:
garc ingress approve --queue-id ${queue_id}
garc ingress resume --queue-id ${queue_id}
To reject:
garc ingress fail --queue-id ${queue_id} --note "rejected by approver"
BODY
)"
python3 "${GARC_DIR}/scripts/garc-gmail-helper.py" send \
--to "${approval_email}" \
--subject "${subject}" \
--body "${body}" 2>/dev/null \
&& echo "📧 Approval notification sent to: ${approval_email}" \
|| echo "⚠️ Could not send approval email (check GARC_APPROVAL_EMAIL and Gmail auth)"
else
echo " Set GARC_APPROVAL_EMAIL in ~/.garc/config.env to enable email notifications."
fi
fi fi
echo "" echo ""
echo "Status set to: blocked" echo "Status set to: blocked"
@ -607,6 +639,68 @@ _ingress_stats() {
python3 "${INGRESS_HELPER}" stats --queue-dir "${GARC_QUEUE_DIR}" python3 "${INGRESS_HELPER}" stats --queue-dir "${GARC_QUEUE_DIR}"
} }
# ─────────────────────────────────────────────────────────────────
# stale-reset — reset in_progress items that have been stuck too long
# ─────────────────────────────────────────────────────────────────
_ingress_stale_reset() {
local timeout_min=30
while [[ $# -gt 0 ]]; do
case "$1" in
--timeout|-t) timeout_min="$2"; shift 2 ;;
*) shift ;;
esac
done
echo "Scanning for in_progress items older than ${timeout_min} minutes..."
python3 - "${GARC_QUEUE_DIR}" "${timeout_min}" <<'PY'
import json, sys, os
from pathlib import Path
from datetime import datetime, timezone, timedelta
queue_dir = Path(sys.argv[1])
timeout_min = int(sys.argv[2])
cutoff = datetime.now(timezone.utc) - timedelta(minutes=timeout_min)
reset_count = 0
if not queue_dir.exists():
print("Queue directory not found.")
sys.exit(0)
for f in queue_dir.glob("*.jsonl"):
try:
line = f.read_text().strip().splitlines()[0]
item = json.loads(line)
except Exception:
continue
if item.get("status") != "in_progress":
continue
updated_at_str = item.get("updated_at") or item.get("created_at", "")
try:
updated_at = datetime.fromisoformat(updated_at_str.replace("Z", "+00:00"))
except Exception:
continue
if updated_at < cutoff:
age_min = int((datetime.now(timezone.utc) - updated_at).total_seconds() / 60)
item["status"] = "pending"
item["updated_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
item["note"] = f"[stale-reset] was in_progress for {age_min}min"
f.write_text(json.dumps(item) + "\n")
print(f" ↻ Reset: {item.get('queue_id','')[:16]} (stuck {age_min}min)")
reset_count += 1
if reset_count == 0:
print(f" No stale items found (threshold: {timeout_min}min).")
else:
print(f"\n✅ Reset {reset_count} stale item(s) → pending")
PY
}
# ───────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────
# Internal helpers # Internal helpers
# ───────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────

108
lib/kg.sh
View file

@ -3,6 +3,7 @@
# Google Docs replaces Lark Wiki as the knowledge graph surface # Google Docs replaces Lark Wiki as the knowledge graph surface
GARC_KG_CACHE="${GARC_CACHE_DIR:-${HOME}/.garc/cache}/knowledge-graph.json" GARC_KG_CACHE="${GARC_CACHE_DIR:-${HOME}/.garc/cache}/knowledge-graph.json"
KG_QUERY_HELPER="${GARC_DIR}/scripts/garc-kg-query.py"
garc_kg() { garc_kg() {
local subcommand="${1:-help}" local subcommand="${1:-help}"
@ -13,37 +14,70 @@ garc_kg() {
query) garc_kg_query "$@" ;; query) garc_kg_query "$@" ;;
show) garc_kg_show "$@" ;; show) garc_kg_show "$@" ;;
*) *)
echo "Usage: garc kg <build|query|show>" cat <<EOF
Usage: garc kg <subcommand>
Subcommands:
build [--folder-id <id>] [--depth <N>] Build KG index from Drive Docs
query "<keyword>" [--max <N>] Search knowledge graph
show <doc_id> Show doc metadata and links
EOF
return 1 return 1
;; ;;
esac esac
} }
# garc kg build # garc kg build
# Crawls Google Drive folder and builds knowledge graph from Docs
garc_kg_build() { garc_kg_build() {
local folder_id="${GARC_DRIVE_FOLDER_ID:-}" local folder_id="${GARC_DRIVE_FOLDER_ID:-}"
local depth=3
while [[ $# -gt 0 ]]; do
case "$1" in
--folder-id|-f) folder_id="$2"; shift 2 ;;
--depth|-d) depth="$2"; shift 2 ;;
*) shift ;;
esac
done
if [[ -z "${folder_id}" ]]; then if [[ -z "${folder_id}" ]]; then
echo "Error: GARC_DRIVE_FOLDER_ID not set" >&2 echo "Error: GARC_DRIVE_FOLDER_ID not set. Use --folder-id or run 'garc setup all'." >&2
return 1 return 1
fi fi
local cache_dir
cache_dir="$(dirname "${GARC_KG_CACHE}")"
mkdir -p "${cache_dir}"
echo "Building knowledge graph from Google Drive folder: ${folder_id}" echo "Building knowledge graph from Google Drive folder: ${folder_id}"
python3 "${GARC_DIR}/scripts/garc-drive-helper.py" kg-build \ python3 "${GARC_DIR}/scripts/garc-drive-helper.py" kg-build \
--folder-id "${folder_id}" \ --folder-id "${folder_id}" \
--output "${GARC_KG_CACHE}" --output "${GARC_KG_CACHE}" \
--depth "${depth}"
echo "✅ Knowledge graph built: ${GARC_KG_CACHE}" if [[ -f "${GARC_KG_CACHE}" ]]; then
local count
count=$(python3 -c "import json; d=json.load(open('${GARC_KG_CACHE}')); print(d.get('node_count', 0))" 2>/dev/null || echo "?")
echo "✅ Knowledge graph built: ${count} docs → ${GARC_KG_CACHE}"
fi
} }
# garc kg query "<concept>" # garc kg query "<concept>"
garc_kg_query() { garc_kg_query() {
local query="$*" local max=10
local terms=()
while [[ $# -gt 0 ]]; do
case "$1" in
--max|-n) max="$2"; shift 2 ;;
*) terms+=("$1"); shift ;;
esac
done
local query="${terms[*]:-}"
if [[ -z "${query}" ]]; then if [[ -z "${query}" ]]; then
echo "Usage: garc kg query \"<concept>\"" echo "Usage: garc kg query \"<concept>\" [--max N]"
return 1 return 1
fi fi
@ -52,33 +86,11 @@ garc_kg_query() {
return 1 return 1
fi fi
python3 -c " # Pass query via argv to avoid shell injection
import json, sys python3 "${GARC_DIR}/scripts/garc-kg-query.py" query \
query = '${query}'.lower() --cache "${GARC_KG_CACHE}" \
with open('${GARC_KG_CACHE}') as f: --query "${query}" \
kg = json.load(f) --max "${max}"
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 <doc_id>
@ -95,29 +107,7 @@ garc_kg_show() {
return 1 return 1
fi fi
python3 -c " python3 "${GARC_DIR}/scripts/garc-kg-query.py" show \
import json --cache "${GARC_KG_CACHE}" \
doc_id = '${doc_id}' --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')
"
} }

215
lib/profile.sh Normal file
View file

@ -0,0 +1,215 @@
#!/usr/bin/env bash
# GARC profile.sh — Multi-tenant profile management
#
# Profiles live at: ~/.garc/profiles/<name>/
# config.env — tenant-specific env vars (GARC_DRIVE_FOLDER_ID, GARC_SHEETS_ID, ...)
# token.json — OAuth token for this tenant
# credentials.json — OAuth client credentials (optional; falls back to ~/.garc/credentials.json)
#
# Usage:
# garc profile list List all profiles
# garc profile use <name> Switch active profile (sets GARC_PROFILE)
# garc profile add <name> Create a new profile directory
# garc profile show [<name>] Show profile config
# garc profile remove <name> Delete a profile
# garc profile current Show current active profile
GARC_PROFILES_DIR="${GARC_CONFIG:-${HOME}/.garc}/profiles"
garc_profile() {
local subcommand="${1:-list}"
shift || true
case "${subcommand}" in
list) _profile_list ;;
use) _profile_use "$@" ;;
add) _profile_add "$@" ;;
show) _profile_show "$@" ;;
remove) _profile_remove "$@" ;;
current) _profile_current ;;
*)
cat <<EOF
Usage: garc profile <subcommand>
Subcommands:
list List all configured profiles
use <name> Print shell command to activate a profile
add <name> Create a new empty profile directory
show [<name>] Show the config for a profile (default: current)
remove <name> Delete a profile (prompts for confirmation)
current Show the currently active profile name
How to use:
# Add profile-specific config to ~/.garc/profiles/<name>/config.env
# Then activate with:
eval "\$(garc profile use <name>)"
# Or export directly:
export GARC_PROFILE=<name>
Profile config.env example:
GARC_DRIVE_FOLDER_ID=1xxxxxx
GARC_SHEETS_ID=1xxxxxx
GARC_GMAIL_DEFAULT_TO=user@example.com
GARC_DEFAULT_AGENT=main
EOF
return 1
;;
esac
}
_profile_list() {
if [[ ! -d "${GARC_PROFILES_DIR}" ]]; then
echo "No profiles configured. Create one with: garc profile add <name>"
return 0
fi
local current="${GARC_PROFILE:-}"
echo "Profiles (dir: ${GARC_PROFILES_DIR}):"
echo ""
local found=0
for dir in "${GARC_PROFILES_DIR}"/*/; do
[[ -d "${dir}" ]] || continue
local name
name="$(basename "${dir}")"
local marker=""
[[ "${name}" == "${current}" ]] && marker=" ◀ active"
local has_token=""
[[ -f "${dir}/token.json" ]] && has_token=" [authenticated]"
local has_config=""
[[ -f "${dir}/config.env" ]] && has_config=" [configured]"
echo " ${name}${marker}${has_token}${has_config}"
found=1
done
[[ ${found} -eq 0 ]] && echo " (none)"
}
_profile_current() {
if [[ -n "${GARC_PROFILE:-}" ]]; then
echo "${GARC_PROFILE}"
else
echo "(no profile active — using ~/.garc/config.env)"
fi
}
_profile_use() {
local name="${1:-}"
if [[ -z "${name}" ]]; then
echo "Usage: garc profile use <name>"
return 1
fi
local profile_dir="${GARC_PROFILES_DIR}/${name}"
if [[ ! -d "${profile_dir}" ]]; then
echo "Profile '${name}' not found. Create it with: garc profile add ${name}"
return 1
fi
# Output shell export commands so caller can eval them
echo "export GARC_PROFILE=${name}"
if [[ -f "${profile_dir}/config.env" ]]; then
# Read each line and export
while IFS= read -r line || [[ -n "${line}" ]]; do
# Skip comments and empty lines
[[ "${line}" =~ ^[[:space:]]*# ]] && continue
[[ -z "${line// }" ]] && continue
echo "export ${line}"
done < "${profile_dir}/config.env"
fi
if [[ -f "${profile_dir}/token.json" ]]; then
echo "export GARC_TOKEN_FILE=${profile_dir}/token.json"
fi
if [[ -f "${profile_dir}/credentials.json" ]]; then
echo "export GARC_CREDENTIALS_FILE=${profile_dir}/credentials.json"
fi
# Print hint to stderr so it doesn't get eval'd
echo "# Activated profile: ${name}" >&2
echo "# Run: eval \"\$(garc profile use ${name})\"" >&2
}
_profile_add() {
local name="${1:-}"
if [[ -z "${name}" ]]; then
echo "Usage: garc profile add <name>"
return 1
fi
local profile_dir="${GARC_PROFILES_DIR}/${name}"
if [[ -d "${profile_dir}" ]]; then
echo "Profile '${name}' already exists: ${profile_dir}"
return 0
fi
mkdir -p "${profile_dir}"
cat > "${profile_dir}/config.env" <<EOF
# Profile: ${name}
# Fill in your tenant-specific values
GARC_DRIVE_FOLDER_ID=1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
GARC_SHEETS_ID=1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
GARC_GMAIL_DEFAULT_TO=you@example.com
GARC_DEFAULT_AGENT=main
# GARC_CHAT_SPACE_ID=spaces/xxxxxxxx
# GARC_APPROVAL_EMAIL=approver@example.com
EOF
echo "✅ Profile created: ${profile_dir}"
echo ""
echo "Next steps:"
echo " 1. Edit: ${profile_dir}/config.env"
echo " 2. Login: GARC_TOKEN_FILE=${profile_dir}/token.json garc auth login"
echo " 3. Activate: eval \"\$(garc profile use ${name})\""
}
_profile_show() {
local name="${1:-${GARC_PROFILE:-}}"
if [[ -z "${name}" ]]; then
echo "Usage: garc profile show <name>"
echo " (or set GARC_PROFILE to use current profile)"
return 1
fi
local profile_dir="${GARC_PROFILES_DIR}/${name}"
if [[ ! -d "${profile_dir}" ]]; then
echo "Profile '${name}' not found."
return 1
fi
echo "Profile: ${name}"
echo "Path: ${profile_dir}"
echo ""
if [[ -f "${profile_dir}/config.env" ]]; then
echo "config.env:"
grep -v '^#' "${profile_dir}/config.env" | grep -v '^[[:space:]]*$' \
| sed 's/^/ /'
else
echo " (no config.env)"
fi
echo ""
echo "Files:"
[[ -f "${profile_dir}/token.json" ]] && echo " ✅ token.json (authenticated)" || echo " ⬜ token.json (not authenticated)"
[[ -f "${profile_dir}/credentials.json" ]] && echo " ✅ credentials.json" || echo " ⬜ credentials.json (using default)"
[[ -f "${profile_dir}/service_account.json" ]] && echo " ✅ service_account.json"
}
_profile_remove() {
local name="${1:-}"
if [[ -z "${name}" ]]; then
echo "Usage: garc profile remove <name>"
return 1
fi
local profile_dir="${GARC_PROFILES_DIR}/${name}"
if [[ ! -d "${profile_dir}" ]]; then
echo "Profile '${name}' not found."
return 1
fi
echo "Remove profile '${name}'? This deletes: ${profile_dir} [y/N]"
read -r confirm
[[ "${confirm}" != "y" ]] && echo "Cancelled." && return 0
rm -rf "${profile_dir}"
echo "✅ Profile '${name}' removed."
}

View file

@ -3,13 +3,26 @@
# Replaces Lark IM with Gmail or Google Chat # Replaces Lark IM with Gmail or Google Chat
garc_send() { garc_send() {
local subcommand="${1:-}"
# Support 'garc send chat ...' / 'garc send email ...' sub-dispatching
case "${subcommand}" in
chat)
shift
_garc_send_chat_cmd "$@"
return $?
;;
email)
shift
;; # fall through to default Gmail path
esac
local message="" local message=""
local to="${GARC_GMAIL_DEFAULT_TO:-}" local to="${GARC_GMAIL_DEFAULT_TO:-}"
local subject="GARC Agent Notification" local subject="GARC Agent Notification"
local use_chat=false local use_chat=false
local space_id="${GARC_CHAT_SPACE_ID:-}" local space_id="${GARC_CHAT_SPACE_ID:-}"
# Parse message (first non-flag argument)
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--to) to="$2"; shift 2 ;; --to) to="$2"; shift 2 ;;
@ -21,7 +34,15 @@ garc_send() {
done done
if [[ -z "${message}" ]]; then if [[ -z "${message}" ]]; then
echo "Usage: garc send \"<message>\" [--to <email>] [--chat] [--space <space_id>]" cat <<EOF
Usage:
garc send "<message>" [--to <email>] [--subject <s>] # Gmail
garc send --chat "<message>" [--space <space_id>] # Chat
garc send chat <subcommand> # Chat management
chat send "<message>" [--space <id>] [--thread <key>]
chat list-spaces
chat list-messages [--space <id>] [--max N]
EOF
return 1 return 1
fi fi
@ -32,6 +53,44 @@ garc_send() {
fi fi
} }
_garc_send_chat_cmd() {
local sub="${1:-send}"
shift || true
local message="" space_id="${GARC_CHAT_SPACE_ID:-}" thread_key="" max=25
case "${sub}" in
list-spaces)
python3 "${GARC_DIR}/scripts/garc-chat-helper.py" list-spaces
return $?
;;
list-messages)
while [[ $# -gt 0 ]]; do
case "$1" in
--space) space_id="$2"; shift 2 ;;
--max) max="$2"; shift 2 ;;
*) shift ;;
esac
done
[[ -z "${space_id}" ]] && { echo "Error: --space required"; return 1; }
python3 "${GARC_DIR}/scripts/garc-chat-helper.py" list-messages \
--space-id "${space_id}" --max "${max}"
return $?
;;
send|*)
while [[ $# -gt 0 ]]; do
case "$1" in
--space) space_id="$2"; shift 2 ;;
--thread) thread_key="$2"; shift 2 ;;
*) message="${message:+${message} }$1"; shift ;;
esac
done
[[ -z "${message}" ]] && { echo "Usage: garc send chat send \"<message>\" [--space <id>]"; return 1; }
_garc_send_chat "${message}" "${space_id}" "${thread_key}"
return $?
;;
esac
}
_garc_send_gmail() { _garc_send_gmail() {
local message="$1" local message="$1"
local to="$2" local to="$2"
@ -59,7 +118,8 @@ _garc_send_gmail() {
_garc_send_chat() { _garc_send_chat() {
local message="$1" local message="$1"
local space_id="$2" local space_id="${2:-${GARC_CHAT_SPACE_ID:-}}"
local thread_key="${3:-}"
if [[ -z "${space_id}" ]]; then if [[ -z "${space_id}" ]]; then
echo "Error: No Chat space. Set GARC_CHAT_SPACE_ID or use --space <space_id>" >&2 echo "Error: No Chat space. Set GARC_CHAT_SPACE_ID or use --space <space_id>" >&2
@ -75,5 +135,6 @@ _garc_send_chat() {
python3 "${GARC_DIR}/scripts/garc-chat-helper.py" send \ python3 "${GARC_DIR}/scripts/garc-chat-helper.py" send \
--space-id "${space_id}" \ --space-id "${space_id}" \
--message "${message}" --message "${message}" \
${thread_key:+--thread-key "${thread_key}"}
} }

28
pyproject.toml Normal file
View file

@ -0,0 +1,28 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.backends.legacy:build"
[project]
name = "garc-gws-agent-runtime"
version = "0.1.0"
description = "Google Workspace Agent Runtime CLI"
requires-python = ">=3.10,<3.13"
dependencies = [
"google-api-python-client>=2.100.0",
"google-auth>=2.23.0",
"google-auth-oauthlib>=1.1.0",
"google-auth-httplib2>=0.1.1",
"requests>=2.31.0",
"pyyaml>=6.0.1",
"python-dateutil>=2.8.2",
"rich>=13.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.4.0",
"pytest-mock>=3.11.0",
]
[tool.setuptools.packages.find]
where = ["scripts"]

View file

@ -2,6 +2,7 @@ google-api-python-client>=2.100.0
google-auth>=2.23.0 google-auth>=2.23.0
google-auth-oauthlib>=1.1.0 google-auth-oauthlib>=1.1.0
google-auth-httplib2>=0.1.1 google-auth-httplib2>=0.1.1
requests>=2.31.0
pyyaml>=6.0.1 pyyaml>=6.0.1
python-dateutil>=2.8.2 python-dateutil>=2.8.2
rich>=13.0.0 rich>=13.0.0

View file

@ -220,6 +220,79 @@ def show_status():
print(f"Error reading token: {e}") print(f"Error reading token: {e}")
def revoke_token():
"""Revoke the stored OAuth token and delete the token file."""
if not TOKEN_FILE.exists():
print(f"No token file found at {TOKEN_FILE}")
return
try:
import requests as req_lib
with open(TOKEN_FILE) as f:
token_data = json.load(f)
token = token_data.get("token") or token_data.get("access_token", "")
if token:
resp = req_lib.post(
"https://oauth2.googleapis.com/revoke",
params={"token": token},
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=10,
)
if resp.status_code == 200:
print("✅ Token revoked at Google.")
else:
print(f"⚠️ Revocation response: {resp.status_code}{resp.text[:80]}")
else:
print("⚠️ No access token in token file — skipping remote revocation.")
except Exception as e:
print(f"⚠️ Could not revoke token remotely: {e}")
TOKEN_FILE.unlink(missing_ok=True)
print(f"✅ Deleted: {TOKEN_FILE}")
print(" Run 'garc auth login' to re-authenticate.")
def service_account_verify(sa_file: str):
"""Verify service account credentials by listing Drive files."""
sa_path = Path(sa_file)
if not sa_path.exists():
print(f"❌ Service account file not found: {sa_path}")
sys.exit(1)
try:
from google.oauth2 import service_account
from googleapiclient.discovery import build
with open(sa_path) as f:
sa_data = json.load(f)
print(f"Service Account: {sa_data.get('client_email', 'N/A')}")
print(f"Project: {sa_data.get('project_id', 'N/A')}")
scopes = ["https://www.googleapis.com/auth/drive.readonly"]
creds = service_account.Credentials.from_service_account_file(str(sa_path), scopes=scopes)
# Check if GARC_IMPERSONATE_EMAIL is set for DWD
impersonate = os.environ.get("GARC_IMPERSONATE_EMAIL", "")
if impersonate:
creds = creds.with_subject(impersonate)
print(f"Impersonating: {impersonate}")
svc = build("drive", "v3", credentials=creds, cache_discovery=False)
resp = svc.files().list(pageSize=1, fields="files(id,name)").execute()
files = resp.get("files", [])
print(f"\n✅ Service account is valid. Drive access confirmed ({len(files)} file(s) visible).")
if not impersonate:
print("\n💡 Tip: For Domain-wide Delegation, set GARC_IMPERSONATE_EMAIL=user@yourdomain.com")
print(" Then re-run 'garc auth service-account verify'.")
except Exception as e:
print(f"❌ Service account verification failed: {e}")
sys.exit(1)
def main(): def main():
parser = argparse.ArgumentParser(description="GARC Auth Helper") parser = argparse.ArgumentParser(description="GARC Auth Helper")
subparsers = parser.add_subparsers(dest="command") subparsers = parser.add_subparsers(dest="command")
@ -239,6 +312,13 @@ def main():
# status # status
subparsers.add_parser("status", help="Show token status") subparsers.add_parser("status", help="Show token status")
# revoke
subparsers.add_parser("revoke", help="Revoke and delete stored token")
# service-account-verify
sav_parser = subparsers.add_parser("service-account-verify", help="Verify service account credentials")
sav_parser.add_argument("--file", required=True, help="Path to service account JSON file")
args = parser.parse_args() args = parser.parse_args()
if args.command == "suggest": if args.command == "suggest":
@ -249,6 +329,10 @@ def main():
login(args.profile) login(args.profile)
elif args.command == "status": elif args.command == "status":
show_status() show_status()
elif args.command == "revoke":
revoke_token()
elif args.command == "service-account-verify":
service_account_verify(args.file)
else: else:
parser.print_help() parser.print_help()

103
scripts/garc-chat-helper.py Normal file
View file

@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""
GARC Chat Helper Google Chat Space message operations
send / list-spaces / list-messages
"""
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from garc_core import build_service
def get_svc():
"""Build Google Chat service."""
return build_service("chat", "v1")
def send_message(space_id: str, message: str, thread_key: str = "") -> dict:
"""Send a plain-text message to a Chat space."""
svc = get_svc()
body: dict = {"text": message}
params: dict = {"parent": space_id}
if thread_key:
body["thread"] = {"threadKey": thread_key}
params["messageReplyOption"] = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"
result = svc.spaces().messages().create(**params, body=body).execute()
msg_name = result.get("name", "")
print(f"✅ Message sent: {msg_name}")
return result
def list_spaces() -> list:
"""List all Chat spaces the bot has access to."""
svc = get_svc()
result = svc.spaces().list().execute()
spaces = result.get("spaces", [])
if not spaces:
print("No spaces found.")
return []
print(f"Spaces ({len(spaces)}):")
for s in spaces:
display = s.get("displayName", "(no name)")
name = s.get("name", "")
stype = s.get("spaceType", "")
print(f" {name:<35} {display:<30} {stype}")
return spaces
def list_messages(space_id: str, max_results: int = 25) -> list:
"""List recent messages in a Chat space."""
svc = get_svc()
result = svc.spaces().messages().list(
parent=space_id,
pageSize=max_results,
).execute()
messages = result.get("messages", [])
if not messages:
print(f"No messages in {space_id}")
return []
print(f"Messages ({len(messages)}):")
for m in messages:
sender = (m.get("sender") or {}).get("displayName", "?")
text = m.get("text", "")[:80]
create_time = m.get("createTime", "")[:19]
print(f" [{create_time}] {sender}: {text}")
return messages
def main():
parser = argparse.ArgumentParser(description="GARC Chat Helper")
sub = parser.add_subparsers(dest="command")
sp = sub.add_parser("send", help="Send a message to a Chat space")
sp.add_argument("--space-id", required=True, help="Chat space ID (e.g. spaces/AAABBB)")
sp.add_argument("--message", required=True, help="Message text")
sp.add_argument("--thread-key", default="", help="Thread key for threaded replies")
sub.add_parser("list-spaces", help="List accessible Chat spaces")
lmp = sub.add_parser("list-messages", help="List messages in a space")
lmp.add_argument("--space-id", required=True)
lmp.add_argument("--max", type=int, default=25)
args = parser.parse_args()
if args.command == "send":
send_message(args.space_id, args.message, args.thread_key)
elif args.command == "list-spaces":
list_spaces()
elif args.command == "list-messages":
list_messages(args.space_id, args.max)
else:
parser.print_help()
if __name__ == "__main__":
main()

View file

@ -269,12 +269,9 @@ def create_doc(name: str, folder_id: str = "root", content: str = ""):
doc_id = result["id"] doc_id = result["id"]
# Add initial content if provided # Add initial content if provided via batchUpdate
if content: if content:
svc_docs.documents().batchUpdate( _doc_insert_text(svc_docs, doc_id, content, append=False)
documentId=doc_id,
body={"requests": [{"insertText": {"location": {"index": 1}, "text": content}}]}
).execute()
print(f"✅ Doc created: {name}") print(f"✅ Doc created: {name}")
print(f" ID: {doc_id}") print(f" ID: {doc_id}")
@ -282,6 +279,35 @@ def create_doc(name: str, folder_id: str = "root", content: str = ""):
return result return result
def _doc_insert_text(svc_docs, doc_id: str, text: str, append: bool = True):
"""Insert or append text to an existing Google Doc."""
if append:
# Get current end index
doc = svc_docs.documents().get(documentId=doc_id).execute()
body = doc.get("body", {})
content_items = body.get("content", [])
# End index of doc body is the last structural element's endIndex minus 1
end_index = content_items[-1]["endIndex"] - 1 if content_items else 1
insert_index = max(end_index, 1)
else:
insert_index = 1 # beginning of new doc
svc_docs.documents().batchUpdate(
documentId=doc_id,
body={"requests": [
{"insertText": {"location": {"index": insert_index}, "text": text}}
]}
).execute()
@with_retry()
def append_doc(doc_id: str, content: str):
"""Append text to an existing Google Doc."""
svc_docs = build_service("docs", "v1")
_doc_insert_text(svc_docs, doc_id, content, append=True)
print(f"✅ Content appended to doc: {doc_id}")
@with_retry() @with_retry()
def share_file(file_id: str, email: str, role: str = "reader", def share_file(file_id: str, email: str, role: str = "reader",
send_notification: bool = True): send_notification: bool = True):
@ -355,12 +381,21 @@ def kg_build(folder_id: str, output: str, depth: int = 3):
try: try:
q = f"'{fid}' in parents and trashed = false" q = f"'{fid}' in parents and trashed = false"
results = svc.files().list( files = []
q=q, pageSize=50, page_token = None
fields="files(id,name,mimeType,modifiedTime,webViewLink)" while True:
).execute() kwargs: dict = dict(
files = results.get("files", []) q=q, pageSize=100,
except Exception as e: fields="nextPageToken,files(id,name,mimeType,modifiedTime,webViewLink)"
)
if page_token:
kwargs["pageToken"] = page_token
results = svc.files().list(**kwargs).execute()
files.extend(results.get("files", []))
page_token = results.get("nextPageToken")
if not page_token:
break
except Exception:
return return
for f in files: for f in files:
@ -467,6 +502,11 @@ def main():
cd.add_argument("--folder-id", default="root") cd.add_argument("--folder-id", default="root")
cd.add_argument("--content", default="") cd.add_argument("--content", default="")
# append-doc
adp = sub.add_parser("append-doc", help="Append text to an existing Google Doc")
adp.add_argument("doc_id", help="Document ID")
adp.add_argument("--content", required=True, help="Text to append")
# share # share
sh = sub.add_parser("share", help="Share file") sh = sub.add_parser("share", help="Share file")
sh.add_argument("file_id") sh.add_argument("file_id")
@ -506,6 +546,8 @@ def main():
create_folder(args.name, args.parent_id) create_folder(args.name, args.parent_id)
elif args.command == "create-doc": elif args.command == "create-doc":
create_doc(args.name, args.folder_id, args.content) create_doc(args.name, args.folder_id, args.content)
elif args.command == "append-doc":
append_doc(args.doc_id, args.content)
elif args.command == "share": elif args.command == "share":
share_file(args.file_id, args.email, args.role, not args.no_notify) share_file(args.file_id, args.email, args.role, not args.no_notify)
elif args.command == "move": elif args.command == "move":

View file

@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""
GARC Forms Helper Google Forms response ingestion
list-forms / list-responses / watch (polling loop)
"""
import argparse
import json
import sys
import time
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
def get_svc():
return build_service("forms", "v1")
def list_forms() -> list:
"""List Google Forms accessible to this user (via Drive)."""
svc_drive = build_service("drive", "v3")
results = svc_drive.files().list(
q="mimeType='application/vnd.google-apps.form' and trashed=false",
pageSize=50,
fields="files(id,name,webViewLink,modifiedTime)"
).execute()
forms = results.get("files", [])
if not forms:
print("No Forms found.")
return []
print(f"Forms ({len(forms)}):")
for f in forms:
modified = f.get("modifiedTime", "")[:10]
print(f" {f['id']:<44} {f['name']:<40} {modified}")
return forms
def list_responses(form_id: str, max_results: int = 50,
since: str = "", output_format: str = "table") -> list:
"""List responses to a specific Form."""
svc = get_svc()
kwargs: dict = {"formId": form_id, "pageSize": max_results}
if since:
kwargs["filter"] = f"timestamp > {since}"
result = svc.forms().responses().list(**kwargs).execute()
responses = result.get("responses", [])
if not responses:
print(f"No responses for form: {form_id}")
return []
if output_format == "json":
print(json.dumps(responses, ensure_ascii=False, indent=2))
else:
print(f"Responses ({len(responses)}):")
for r in responses:
resp_id = r.get("responseId", "?")[:16]
create_time = r.get("createTime", "")[:19]
answers = r.get("answers", {})
answer_count = len(answers)
print(f" [{resp_id}] {create_time} ({answer_count} answers)")
return responses
def watch_form(form_id: str, agent_id: str, interval: int = 60,
max_msgs: int = 10, seen_file_path: str = ""):
"""Poll a Form for new responses and enqueue them via garc ingress."""
import subprocess
import os
garc_dir = os.environ.get("GARC_DIR", "")
garc_bin = Path(garc_dir) / "bin" / "garc"
seen_path = Path(seen_file_path) if seen_file_path else \
Path.home() / ".garc" / "cache" / "seen" / f"forms-{form_id[:16]}.txt"
seen_path.parent.mkdir(parents=True, exist_ok=True)
seen_path.touch()
try:
seen = set(seen_path.read_text().splitlines())
except Exception:
seen = set()
svc = get_svc()
print(f"[forms-poller] Watching form {form_id} (agent={agent_id}, interval={interval}s)")
while True:
try:
result = svc.forms().responses().list(
formId=form_id, pageSize=max_msgs
).execute()
responses = result.get("responses", [])
except Exception as e:
print(f"[forms-poller] fetch error: {e}", flush=True)
time.sleep(interval)
continue
new_seen = []
for resp in responses:
resp_id = resp.get("responseId", "")
if not resp_id or resp_id in seen:
new_seen.append(resp_id)
continue
# Build a summary of the answers
answers = resp.get("answers", {})
answer_lines = []
for q_id, ans in list(answers.items())[:5]: # first 5 questions
text_answers = ans.get("textAnswers", {}).get("answers", [])
for ta in text_answers:
answer_lines.append(ta.get("value", ""))
create_time = resp.get("createTime", "")[:10]
summary = "; ".join(answer_lines[:3]) if answer_lines else "(no answers)"
text = f"New Google Form response ({create_time}): {summary[:120]}"
cmd = [str(garc_bin), "ingress", "enqueue",
"--text", text,
"--source", "google_forms",
"--sender", form_id,
"--agent", agent_id]
env = dict(os.environ, GARC_DIR=garc_dir)
r = subprocess.run(cmd, capture_output=True, text=True, env=env)
if r.returncode == 0:
print(f"[forms-poller] Enqueued response: {resp_id[:16]}", flush=True)
else:
print(f"[forms-poller] Enqueue failed: {r.stderr.strip()}", flush=True)
new_seen.append(resp_id)
if new_seen:
with open(seen_path, "a") as f:
f.write("\n".join(new_seen) + "\n")
if interval == 0:
break # single-shot mode
time.sleep(interval)
def main():
parser = argparse.ArgumentParser(description="GARC Forms Helper")
sub = parser.add_subparsers(dest="command")
sub.add_parser("list-forms", help="List accessible Google Forms")
lrp = sub.add_parser("list-responses", help="List responses for a form")
lrp.add_argument("form_id", help="Form ID")
lrp.add_argument("--max", type=int, default=50)
lrp.add_argument("--since", default="", help="ISO timestamp filter")
lrp.add_argument("--format", default="table", choices=["table", "json"])
wp = sub.add_parser("watch", help="Poll form for new responses and enqueue")
wp.add_argument("form_id", help="Form ID to watch")
wp.add_argument("--agent", required=True, help="GARC agent ID")
wp.add_argument("--interval", type=int, default=60, help="Poll interval in seconds")
wp.add_argument("--max", type=int, default=10)
wp.add_argument("--seen-file", default="", help="Path to seen-IDs file")
args = parser.parse_args()
if args.command == "list-forms":
list_forms()
elif args.command == "list-responses":
list_responses(args.form_id, args.max, args.since, args.format)
elif args.command == "watch":
watch_form(args.form_id, args.agent, args.interval, args.max, args.seen_file)
else:
parser.print_help()
if __name__ == "__main__":
main()

122
scripts/garc-kg-query.py Normal file
View file

@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""
GARC KG Query Safe query/show interface for the knowledge graph cache file.
Arguments are passed via argv (no shell interpolation).
"""
import argparse
import json
import sys
from pathlib import Path
def load_kg(cache_path: str) -> dict:
p = Path(cache_path)
if not p.exists():
print(f"❌ Knowledge graph cache not found: {cache_path}", file=sys.stderr)
print(" Run: garc kg build", file=sys.stderr)
sys.exit(1)
try:
with open(p) as f:
return json.load(f)
except Exception as e:
print(f"❌ Failed to load KG cache: {e}", file=sys.stderr)
sys.exit(1)
def kg_query(cache_path: str, query: str, max_results: int = 10):
"""Search nodes by keyword in title or content_preview."""
kg = load_kg(cache_path)
nodes = kg.get("nodes", [])
query_lower = query.lower()
matches = []
for node in nodes:
title = node.get("title", "").lower()
content = node.get("content_preview", "").lower()
if query_lower in title or query_lower in content:
matches.append(node)
built_at = kg.get("built_at", "?")[:10]
print(f"Knowledge graph — built: {built_at} nodes: {len(nodes)}")
print()
if not matches:
print(f"No results for: \"{query}\"")
return
limit = min(max_results, len(matches))
print(f"Results for \"{query}\" ({len(matches)} match{'es' if len(matches) != 1 else ''}, showing {limit}):")
print()
for m in matches[:limit]:
doc_id = m.get("doc_id", "")
title = m.get("title", "")
link = m.get("web_link", "")
links_count = len(m.get("links", []))
preview = m.get("content_preview", "")[:120].replace("\n", " ")
print(f" [{doc_id[:16]}] {title}")
if preview:
print(f" {preview}...")
if links_count:
print(f"{links_count} linked doc(s)")
if link:
print(f" 🔗 {link}")
print()
def kg_show(cache_path: str, doc_id: str):
"""Show full details for a specific doc."""
kg = load_kg(cache_path)
nodes = kg.get("nodes", [])
for node in nodes:
if node.get("doc_id") == doc_id:
print(f"Title : {node.get('title', '')}")
print(f"Doc ID : {doc_id}")
print(f"MIME : {node.get('mime_type', '')}")
print(f"Modified : {node.get('modified_time', '')[:19]}")
link = node.get("web_link", "")
if link:
print(f"URL : {link}")
print()
preview = node.get("content_preview", "")
if preview:
print("Content preview:")
print(preview[:800])
print()
links = node.get("links", [])
if links:
print(f"Linked documents ({len(links)}):")
for lnk in links:
print(f"{lnk}")
return
print(f"Document '{doc_id}' not found in knowledge graph.")
print(f"Run 'garc kg build' to refresh the index.")
def main():
parser = argparse.ArgumentParser(description="GARC KG Query")
sub = parser.add_subparsers(dest="command")
qp = sub.add_parser("query", help="Search the knowledge graph")
qp.add_argument("--cache", required=True, help="Path to knowledge-graph.json")
qp.add_argument("--query", required=True, help="Search keyword")
qp.add_argument("--max", type=int, default=10, help="Max results")
sp = sub.add_parser("show", help="Show a specific doc")
sp.add_argument("--cache", required=True, help="Path to knowledge-graph.json")
sp.add_argument("--doc-id", required=True, help="Document ID")
args = parser.parse_args()
if args.command == "query":
kg_query(args.cache, args.query, args.max)
elif args.command == "show":
kg_show(args.cache, args.doc_id)
else:
parser.print_help()
if __name__ == "__main__":
main()

View file

@ -22,6 +22,7 @@ SHEET_APPROVAL = "approval"
SHEET_TASKS_LOG = "tasks_log" SHEET_TASKS_LOG = "tasks_log"
SHEET_EMAIL_LOG = "email_log" SHEET_EMAIL_LOG = "email_log"
SHEET_CALENDAR_LOG = "calendar_log" SHEET_CALENDAR_LOG = "calendar_log"
SHEET_AUDIT = "audit"
def get_svc(): def get_svc():
@ -168,6 +169,80 @@ def clear_range(sheets_id: str, range_: str):
print(f"✅ Cleared: {range_}") print(f"✅ Cleared: {range_}")
def trim_sheet(sheets_id: str, sheet_name: str) -> int:
"""Delete trailing empty rows from a sheet. Returns number of rows deleted."""
svc = get_svc()
# Get all data to find last non-empty row
result = svc.spreadsheets().values().get(
spreadsheetId=sheets_id, range=f"{sheet_name}!A:Z",
valueRenderOption="UNFORMATTED_VALUE"
).execute()
rows = result.get("values", [])
# Find last row index with any data (0-indexed)
last_data_row = -1
for i, row in enumerate(rows):
if any(str(cell).strip() for cell in row):
last_data_row = i
# Get sheet metadata for sheetId and total row count
meta = svc.spreadsheets().get(spreadsheetId=sheets_id).execute()
sheet_meta = next(
(s for s in meta.get("sheets", []) if s["properties"]["title"] == sheet_name),
None
)
if not sheet_meta:
print(f"Sheet '{sheet_name}' not found.")
return 0
sheet_id = sheet_meta["properties"]["sheetId"]
total_rows = sheet_meta["properties"]["gridProperties"]["rowCount"]
first_empty = last_data_row + 1 # 0-indexed row after last data
# Keep at least 1 buffer row after data (avoid deleting to the bone)
delete_from = first_empty + 1
rows_to_delete = total_rows - delete_from
if rows_to_delete <= 0:
print(f" {sheet_name}: no trailing empty rows to remove.")
return 0
svc.spreadsheets().batchUpdate(
spreadsheetId=sheets_id,
body={
"requests": [{
"deleteDimension": {
"range": {
"sheetId": sheet_id,
"dimension": "ROWS",
"startIndex": delete_from,
"endIndex": total_rows,
}
}
}]
}
).execute()
print(f"{sheet_name}: deleted {rows_to_delete} empty rows "
f"(kept rows 1{delete_from})")
return rows_to_delete
def clean_all_sheets(sheets_id: str):
"""Trim trailing empty rows from all GARC-managed sheets."""
sheets = [SHEET_MEMORY, SHEET_AGENTS, SHEET_QUEUE, SHEET_HEARTBEAT,
SHEET_APPROVAL, SHEET_TASKS_LOG, SHEET_EMAIL_LOG, SHEET_CALENDAR_LOG]
total = 0
for name in sheets:
try:
deleted = trim_sheet(sheets_id, name)
total += deleted
except Exception as e:
print(f" ⚠️ {name}: {e}")
print(f"\nTotal rows removed: {total}")
# ─── GARC-specific operations ───────────────────────────────────────────────── # ─── GARC-specific operations ─────────────────────────────────────────────────
@with_retry() @with_retry()
@ -227,20 +302,58 @@ def agent_register(sheets_id: str, yaml_file: str):
return return
agents = config.get("agents", []) agents = config.get("agents", [])
if not agents:
print("No agents defined in YAML.")
return
# Fetch existing rows to detect duplicates
svc = get_svc()
result = svc.spreadsheets().values().get(
spreadsheetId=sheets_id, range=f"{SHEET_AGENTS}!A:H"
).execute()
existing_rows = result.get("values", [])
# Build map: agent_id -> row_number (1-indexed, row 1 = header)
existing_ids: dict[str, int] = {}
for i, row in enumerate(existing_rows):
if i == 0:
continue # skip header
if row and row[0]:
existing_ids[row[0]] = i + 1 # 1-indexed sheet row
ts = utc_now() ts = utc_now()
added = updated = 0
for agent in agents: for agent in agents:
agent_id = agent.get("id", "")
if not agent_id:
continue
scopes = ",".join(agent.get("scopes", [])) scopes = ",".join(agent.get("scopes", []))
append_row(sheets_id, SHEET_AGENTS, [ row_values = [
agent.get("id", ""), agent_id,
agent.get("model", ""), agent.get("model", ""),
scopes, scopes,
agent.get("description", ""), agent.get("description", ""),
agent.get("profile", ""), agent.get("profile", ""),
"active", "active",
agent.get("drive_folder", ""), agent.get("drive_folder", ""),
ts ts,
]) ]
print(f"✅ Registered {len(agents)} agents") if agent_id in existing_ids:
# Update existing row in-place
row_num = existing_ids[agent_id]
svc.spreadsheets().values().update(
spreadsheetId=sheets_id,
range=f"{SHEET_AGENTS}!A{row_num}:H{row_num}",
valueInputOption="USER_ENTERED",
body={"values": [row_values]},
).execute()
print(f" ↻ Updated: {agent_id} (row {row_num})")
updated += 1
else:
append_row(sheets_id, SHEET_AGENTS, row_values)
print(f" ✅ Registered: {agent_id}")
added += 1
print(f"\nDone — {added} added, {updated} updated (no duplicates created)")
@with_retry() @with_retry()
@ -301,6 +414,57 @@ def approval_act(sheets_id: str, approval_id: str, action: str, timestamp: str):
print(f"Approval not found: {approval_id}") print(f"Approval not found: {approval_id}")
def audit_append(sheets_id: str, agent_id: str, command: str, args_str: str,
result: str, user: str, timestamp: str):
"""Append an audit event row."""
append_row(sheets_id, SHEET_AUDIT, [
timestamp, agent_id, user, command, args_str, result
])
def audit_list(sheets_id: str, agent_id: str = "", since: str = "",
output_format: str = "table"):
"""List audit events, optionally filtered by agent or date."""
svc = get_svc()
result = svc.spreadsheets().values().get(
spreadsheetId=sheets_id, range=f"{SHEET_AUDIT}!A:F"
).execute()
rows = result.get("values", [])
if not rows:
print("(no audit events)")
return
headers = rows[0] if rows else []
data = rows[1:]
# Filter
if agent_id:
data = [r for r in data if len(r) > 1 and r[1] == agent_id]
if since:
data = [r for r in data if r and r[0] >= since]
if not data:
print("No audit events match the filter.")
return
if output_format == "json":
records = [dict(zip(headers, r + [""] * (len(headers) - len(r)))) for r in data]
print(json.dumps(records, ensure_ascii=False, indent=2))
else:
widths = [min(max(len(str(headers[i])) if i < len(headers) else 0,
max((len(str(r[i] if i < len(r) else "")) for r in data), default=0)), 30)
for i in range(len(headers))]
header_line = " ".join(str(headers[i] if i < len(headers) else "").ljust(widths[i])
for i in range(len(headers)))
print(header_line)
print(" ".join("" * w for w in widths))
for row in data[-50:]: # last 50 rows
print(" ".join(str(row[i] if i < len(row) else "").ljust(widths[i])[:widths[i]]
for i in range(len(headers))))
if len(data) > 50:
print(f" ... ({len(data) - 50} older events not shown)")
def main(): def main():
parser = argparse.ArgumentParser(description="GARC Sheets Helper") parser = argparse.ArgumentParser(description="GARC Sheets Helper")
sub = parser.add_subparsers(dest="command") sub = parser.add_subparsers(dest="command")
@ -335,6 +499,28 @@ def main():
clp.add_argument("--sheets-id", required=True) clp.add_argument("--sheets-id", required=True)
clp.add_argument("--range", required=True, dest="range_") clp.add_argument("--range", required=True, dest="range_")
trp = sub.add_parser("trim-sheet", help="Delete trailing empty rows from a sheet")
trp.add_argument("--sheets-id", required=True)
trp.add_argument("--sheet", required=True)
cap = sub.add_parser("clean-all", help="Trim all GARC-managed sheets")
cap.add_argument("--sheets-id", required=True)
aap = sub.add_parser("audit-append", help="Append an audit event")
aap.add_argument("--sheets-id", required=True)
aap.add_argument("--agent-id", default="")
aap.add_argument("--cmd", required=True, dest="cmd_name")
aap.add_argument("--args", default="", dest="args_str")
aap.add_argument("--result", default="ok")
aap.add_argument("--user", default="")
aap.add_argument("--timestamp", default="")
alp = sub.add_parser("audit-list", help="List audit events")
alp.add_argument("--sheets-id", required=True)
alp.add_argument("--agent-id", default="")
alp.add_argument("--since", default="", help="ISO date filter (YYYY-MM-DD)")
alp.add_argument("--format", default="table", choices=["table", "json"])
# GARC-specific # GARC-specific
mpl = sub.add_parser("memory-pull") mpl = sub.add_parser("memory-pull")
mpl.add_argument("--sheets-id", required=True) mpl.add_argument("--sheets-id", required=True)
@ -401,6 +587,16 @@ def main():
get_sheet_info(args.sheets_id) get_sheet_info(args.sheets_id)
elif args.command == "clear": elif args.command == "clear":
clear_range(args.sheets_id, args.range_) clear_range(args.sheets_id, args.range_)
elif args.command == "trim-sheet":
trim_sheet(args.sheets_id, args.sheet)
elif args.command == "clean-all":
clean_all_sheets(args.sheets_id)
elif args.command == "audit-append":
ts = args.timestamp or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
audit_append(args.sheets_id, args.agent_id, args.cmd_name,
args.args_str, args.result, args.user, ts)
elif args.command == "audit-list":
audit_list(args.sheets_id, args.agent_id, args.since, args.format)
elif args.command == "memory-pull": elif args.command == "memory-pull":
memory_pull(args.sheets_id, args.agent_id, args.output) memory_pull(args.sheets_id, args.agent_id, args.output)
elif args.command == "memory-push": elif args.command == "memory-push":

View file

@ -7,11 +7,19 @@ import json
import os import os
import sys import sys
import time import time
import warnings
import functools import functools
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Optional, Any from typing import Optional, Any
# Suppress noisy deprecation warnings from urllib3 / requests bundled
# inside google-auth and google-api-python-client on Python 3.12+
warnings.filterwarnings("ignore", message=".*urllib3.*", category=DeprecationWarning)
warnings.filterwarnings("ignore", message=".*ssl.wrap_socket.*", category=DeprecationWarning)
warnings.filterwarnings("ignore", message=".*imp module.*", category=DeprecationWarning)
warnings.filterwarnings("ignore", category=DeprecationWarning, module="googleapiclient")
GARC_CONFIG_DIR = Path(os.environ.get("GARC_CONFIG_DIR", Path.home() / ".garc")) 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")) 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")) CREDENTIALS_FILE = Path(os.environ.get("GARC_CREDENTIALS_FILE", GARC_CONFIG_DIR / "credentials.json"))
@ -82,21 +90,36 @@ def get_credentials(scopes: Optional[list] = None, use_service_account: bool = F
try: try:
from google.oauth2.credentials import Credentials from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request from google.auth.transport.requests import Request
from google.auth.exceptions import RefreshError
creds = None creds = None
if TOKEN_FILE.exists(): if TOKEN_FILE.exists():
creds = Credentials.from_authorized_user_file(str(TOKEN_FILE), scopes) creds = Credentials.from_authorized_user_file(str(TOKEN_FILE), scopes)
if creds and creds.valid: if creds and creds.valid:
# Verify the token covers the requested scopes
if _scopes_covered(creds, scopes):
return creds return creds
# Scope mismatch — need re-auth
print("⚠️ Token scopes insufficient for requested operation.", file=sys.stderr)
print(" Re-authenticating to add required scopes...", file=sys.stderr)
creds = None
if creds and creds.expired and creds.refresh_token: if creds and creds.expired and creds.refresh_token:
try: try:
creds.refresh(Request()) creds.refresh(Request())
_save_token(creds) _save_token(creds)
return creds return creds
except RefreshError as e:
# Token revoked or expired beyond refresh — delete and re-auth
print(f"⚠️ Token refresh failed (revoked or expired): {e}", file=sys.stderr)
print(" Deleting stale token. You will be prompted to log in again.", file=sys.stderr)
TOKEN_FILE.unlink(missing_ok=True)
creds = None
except Exception as e: except Exception as e:
print(f"⚠️ Token refresh failed: {e}", file=sys.stderr) print(f"⚠️ Token refresh error: {e}", file=sys.stderr)
TOKEN_FILE.unlink(missing_ok=True)
creds = None
# Need fresh OAuth flow # Need fresh OAuth flow
if not CREDENTIALS_FILE.exists(): if not CREDENTIALS_FILE.exists():
@ -117,6 +140,17 @@ def get_credentials(scopes: Optional[list] = None, use_service_account: bool = F
sys.exit(1) sys.exit(1)
def _scopes_covered(creds, requested_scopes: list) -> bool:
"""Return True if the credential's granted scopes cover all requested scopes."""
if not requested_scopes:
return True
granted = set(getattr(creds, "scopes", None) or [])
if not granted:
# Token file may not carry scope info — assume OK to avoid spurious re-auth
return True
return all(s in granted for s in requested_scopes)
def _save_token(creds): def _save_token(creds):
"""Save credentials to token file.""" """Save credentials to token file."""
GARC_CONFIG_DIR.mkdir(parents=True, exist_ok=True) GARC_CONFIG_DIR.mkdir(parents=True, exist_ok=True)