From 7b5951a1d5fd1a2acd0b6341cf809465be4ad256 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=20=E9=A7=BF=E7=94=AB=20=28Shunsuke=20Hayashi=29?= Date: Wed, 15 Apr 2026 09:55:33 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20resolve=20all=2017=20playbook=20findings?= =?UTF-8?q?=20(P0=E2=80=93P3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bin/garc | 121 +++++++++++- config/config.env.example | 11 ++ docs/playbook-v0.1-findings.md | 327 +++++++++++++++++++++++++++++++++ lib/audit.sh | 47 +++++ lib/auth.sh | 79 +++++++- lib/daemon.sh | 216 ++++++++++++++++------ lib/drive.sh | 20 ++ lib/forms.sh | 90 +++++++++ lib/ingress.sh | 94 ++++++++++ lib/kg.sh | 108 +++++------ lib/profile.sh | 215 ++++++++++++++++++++++ lib/send.sh | 69 ++++++- pyproject.toml | 28 +++ requirements.txt | 1 + scripts/garc-auth-helper.py | 84 +++++++++ scripts/garc-chat-helper.py | 103 +++++++++++ scripts/garc-drive-helper.py | 64 +++++-- scripts/garc-forms-helper.py | 179 ++++++++++++++++++ scripts/garc-kg-query.py | 122 ++++++++++++ scripts/garc-sheets-helper.py | 206 ++++++++++++++++++++- scripts/garc_core.py | 38 +++- 21 files changed, 2078 insertions(+), 144 deletions(-) create mode 100644 docs/playbook-v0.1-findings.md create mode 100644 lib/audit.sh create mode 100644 lib/forms.sh create mode 100644 lib/profile.sh create mode 100644 pyproject.toml create mode 100644 scripts/garc-chat-helper.py create mode 100644 scripts/garc-forms-helper.py create mode 100644 scripts/garc-kg-query.py diff --git a/bin/garc b/bin/garc index 3393386..986a0fd 100755 --- a/bin/garc +++ b/bin/garc @@ -5,16 +5,40 @@ set -euo pipefail 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_CONFIG="${HOME}/.garc" GARC_CONFIG_ENV="${GARC_CONFIG}/config.env" -# Load config if present +# Load base config if present if [[ -f "${GARC_CONFIG_ENV}" ]]; then # shellcheck source=/dev/null source "${GARC_CONFIG_ENV}" 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 GARC_CACHE_DIR="${GARC_CACHE_DIR:-${GARC_CONFIG}/cache}" GARC_CACHE_TTL="${GARC_CACHE_TTL:-300}" @@ -129,6 +153,11 @@ Usage: garc [subcommand] [options] ingress verify --queue-id ingress stats +━━━ Google Forms (Response Pipeline) ━━━━━━━━━━━━━━━━━━━━━━━━━━ + forms list List accessible Google Forms + forms responses List form responses + forms watch --agent Poll form and auto-enqueue new responses + ━━━ Daemon (Gmail Poller) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ daemon start [--interval ] [--agent ] daemon stop @@ -143,8 +172,18 @@ Usage: garc [subcommand] [options] kg query "" Search knowledge graph kg show Show doc + links +━━━ Profiles (Multi-tenant) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + profile list List all tenant profiles + profile use Activate a profile (eval output) + profile add Create a new profile + profile show [] Show profile config + profile remove Delete a profile + profile current Show active profile + ━━━ System ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ heartbeat Log system state to Sheets + audit list [--agent ] [--since YYYY-MM-DD] View audit log + doctor Check Python version and dependencies Options: --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 +# ── 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}" shift || true @@ -254,6 +318,61 @@ case "${COMMAND}" in source "${GARC_LIB}/daemon.sh" 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) usage ;; diff --git a/config/config.env.example b/config/config.env.example index 3cec564..978c5b4 100644 --- a/config/config.env.example +++ b/config/config.env.example @@ -28,8 +28,19 @@ GARC_TOKEN_FILE=~/.garc/token.json # Service account JSON file (for bot/automated operations) # 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 GARC_DEFAULT_AGENT=main # Cache directory 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 diff --git a/docs/playbook-v0.1-findings.md b/docs/playbook-v0.1-findings.md new file mode 100644 index 0000000..1c83a3a --- /dev/null +++ b/docs/playbook-v0.1-findings.md @@ -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 --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 ` のコマンドを記載 +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 --text ""` コマンドを追加 +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 ""` の実動作確認テスト実施 +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 --append ""` コマンドを追加 +3. Markdown → Docs 変換 (最低限: 見出し・段落・箇条書き) +4. `garc drive read-doc --file-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//`) への切替機能が必要。 + +--- + +### 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 | diff --git a/lib/audit.sh b/lib/audit.sh new file mode 100644 index 0000000..7632606 --- /dev/null +++ b/lib/audit.sh @@ -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 < + +Subcommands: + list [--agent ] [--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}" +} diff --git a/lib/auth.sh b/lib/auth.sh index 800b256..c74b241 100644 --- a/lib/auth.sh +++ b/lib/auth.sh @@ -12,12 +12,24 @@ garc_auth() { shift || true case "${subcommand}" in - suggest) garc_auth_suggest "$@" ;; - check) garc_auth_check "$@" ;; - login) garc_auth_login "$@" ;; - status) garc_auth_status "$@" ;; + suggest) garc_auth_suggest "$@" ;; + check) garc_auth_check "$@" ;; + login) garc_auth_login "$@" ;; + status) garc_auth_status "$@" ;; + revoke) garc_auth_revoke "$@" ;; + service-account) garc_auth_service_account "$@" ;; *) - echo "Usage: garc auth " + cat < + +Subcommands: + suggest "" Infer minimum OAuth scopes for a task + check [--profile

] Check if current token covers required scopes + login [--profile

] 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 ;; esac @@ -57,18 +69,25 @@ garc_auth_check() { python3 "${AUTH_HELPER}" check --profile "${profile}" } -# garc auth login [--profile ] -# Launches OAuth2 authorization flow +# garc auth login [--profile ] [--type oauth|service-account] +# Launches OAuth2 authorization flow, or validates service account garc_auth_login() { local profile="writer" + local auth_type="oauth" while [[ $# -gt 0 ]]; do case "$1" in --profile) profile="$2"; shift 2 ;; + --type) auth_type="$2"; shift 2 ;; *) shift ;; esac done + if [[ "${auth_type}" == "service-account" ]]; then + garc_auth_service_account verify + return $? + fi + python3 "${AUTH_HELPER}" login --profile "${profile}" } @@ -77,3 +96,49 @@ garc_auth_login() { garc_auth_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 +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 " + return 1 + ;; + esac +} diff --git a/lib/daemon.sh b/lib/daemon.sh index fff2c56..c51d74b 100644 --- a/lib/daemon.sh +++ b/lib/daemon.sh @@ -138,41 +138,21 @@ _start_gmail_poller() { # 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 interval="${2:-60}" - local label="${3:-INBOX}" - local max_msgs="${4:-10}" - + local max_msgs="${2:-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 + python3 - "${seen_file}" "${agent_id}" "${max_msgs}" <<'PY' +import json, sys, subprocess, os seen_file = sys.argv[1] agent_id = sys.argv[2] +max_msgs = sys.argv[3] garc_dir = os.environ.get("GARC_DIR", "") -garc_lib = os.environ.get("GARC_LIB", "") # Read seen message IDs try: @@ -181,55 +161,49 @@ try: 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 +# Fetch inbox as JSON result = subprocess.run( ["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 ) if result.returncode != 0: print(f"[gmail-poller] inbox fetch error: {result.stderr.strip()}", flush=True) - sys.exit(0) + sys.exit(1) try: messages = json.loads(result.stdout) except Exception as e: print(f"[gmail-poller] JSON parse error: {e}", flush=True) - sys.exit(0) + sys.exit(1) if not isinstance(messages, list): messages = [] new_seen = [] +enqueued = 0 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: + if not msg_id: + continue + if 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 + cmd = [garc_bin, "ingress", "enqueue", + "--text", text, + "--source", "gmail", + "--sender", sender, + "--agent", agent_id] env = os.environ.copy() env["GARC_DIR"] = garc_dir @@ -237,6 +211,7 @@ for msg in messages: 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) + enqueued += 1 else: print(f"[gmail-poller] Enqueue failed: {r.stderr.strip()}", flush=True) @@ -245,8 +220,27 @@ for msg in messages: if new_seen: with open(seen_file, "a") as f: 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}" done } @@ -337,12 +331,17 @@ _daemon_poll_once() { _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})..." - _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 + # Run a single cycle synchronously — no background process, no timeout kill + _gmail_poll_cycle "${agent}" "${max_msgs}" echo "" 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() { + # Backward-compatible alias — delegates to _daemon_install_service + _daemon_install_service "$@" +} + +_daemon_install_service() { local agent="${GARC_DEFAULT_AGENT:-main}" local interval=60 - local label="com.garc.gmail-poller" - local plist_path="${HOME}/Library/LaunchAgents/${label}.plist" + local system_wide=false while [[ $# -gt 0 ]]; do case "$1" in --agent|-a) agent="$2"; shift 2 ;; --interval|-i) interval="$2"; shift 2 ;; + --system) system_wide=true; shift ;; *) shift ;; esac done _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" + mkdir -p "$(dirname "${plist_path}")" + cat > "${plist_path}" < "${unit_path}" < "${timer_path}" < [--folder-id ] [--name ] [--convert] create-folder [--parent-id ] create-doc [--folder-id ] [--content ] + append-doc --content share --email [--role reader|writer|commenter] move --to delete [--permanent] @@ -172,6 +174,24 @@ garc_drive_create_doc() { ${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 --content "; return 1; } + [[ -z "${content}" ]] && { echo "Usage: garc drive append-doc --content "; return 1; } + + python3 "${DRIVE_HELPER}" append-doc "${doc_id}" --content "${content}" +} + garc_drive_share() { local file_id="${1:-}" shift || true diff --git a/lib/forms.sh b/lib/forms.sh new file mode 100644 index 0000000..c898ab2 --- /dev/null +++ b/lib/forms.sh @@ -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 < + +Subcommands: + list List accessible Google Forms + responses [--max N] List responses for a form + watch --agent Poll form and auto-enqueue new responses + [--interval ] Poll interval (default: 60) + [--max ] 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 [--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 --agent [--interval ]" + 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}" +} diff --git a/lib/ingress.sh b/lib/ingress.sh index 0a11a1d..46e1a77 100644 --- a/lib/ingress.sh +++ b/lib/ingress.sh @@ -29,6 +29,7 @@ garc_ingress() { fail) _ingress_fail "$@" ;; verify) _ingress_verify "$@" ;; stats) _ingress_stats "$@" ;; + stale-reset) _ingress_stale_reset "$@" ;; *) cat < [options] @@ -48,6 +49,7 @@ Subcommands: fail --queue-id [--note ] verify --queue-id Verify expected output was produced stats Queue statistics + stale-reset [--timeout ] Reset in_progress items older than N min (default: 30) Examples: garc ingress enqueue --text "Send weekly report to manager" @@ -314,6 +316,36 @@ _ingress_run_once() { _ingress_update_status "${queue_id}" "blocked" source "${GARC_LIB}/approve.sh" 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 </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 echo "" echo "Status set to: blocked" @@ -607,6 +639,68 @@ _ingress_stats() { 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 # ───────────────────────────────────────────────────────────────── diff --git a/lib/kg.sh b/lib/kg.sh index 2e5e5e8..cc5e257 100644 --- a/lib/kg.sh +++ b/lib/kg.sh @@ -3,6 +3,7 @@ # Google Docs replaces Lark Wiki as the knowledge graph surface GARC_KG_CACHE="${GARC_CACHE_DIR:-${HOME}/.garc/cache}/knowledge-graph.json" +KG_QUERY_HELPER="${GARC_DIR}/scripts/garc-kg-query.py" garc_kg() { local subcommand="${1:-help}" @@ -13,37 +14,70 @@ garc_kg() { query) garc_kg_query "$@" ;; show) garc_kg_show "$@" ;; *) - echo "Usage: garc kg " + cat < + +Subcommands: + build [--folder-id ] [--depth ] Build KG index from Drive Docs + query "" [--max ] Search knowledge graph + show Show doc metadata and links +EOF 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:-}" + 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 - 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 fi + local cache_dir + cache_dir="$(dirname "${GARC_KG_CACHE}")" + mkdir -p "${cache_dir}" + 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}" + --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 "" 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 - echo "Usage: garc kg query \"\"" + echo "Usage: garc kg query \"\" [--max N]" return 1 fi @@ -52,33 +86,11 @@ garc_kg_query() { 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') -" + # Pass query via argv to avoid shell injection + python3 "${GARC_DIR}/scripts/garc-kg-query.py" query \ + --cache "${GARC_KG_CACHE}" \ + --query "${query}" \ + --max "${max}" } # garc kg show @@ -95,29 +107,7 @@ garc_kg_show() { 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') -" + python3 "${GARC_DIR}/scripts/garc-kg-query.py" show \ + --cache "${GARC_KG_CACHE}" \ + --doc-id "${doc_id}" } diff --git a/lib/profile.sh b/lib/profile.sh new file mode 100644 index 0000000..0858dcf --- /dev/null +++ b/lib/profile.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +# GARC profile.sh — Multi-tenant profile management +# +# Profiles live at: ~/.garc/profiles// +# 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 Switch active profile (sets GARC_PROFILE) +# garc profile add Create a new profile directory +# garc profile show [] Show profile config +# garc profile remove 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 < + +Subcommands: + list List all configured profiles + use Print shell command to activate a profile + add Create a new empty profile directory + show [] Show the config for a profile (default: current) + remove Delete a profile (prompts for confirmation) + current Show the currently active profile name + +How to use: + # Add profile-specific config to ~/.garc/profiles//config.env + # Then activate with: + eval "\$(garc profile use )" + # Or export directly: + export GARC_PROFILE= + +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 " + 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 " + 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 " + 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" <" + 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 " + 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." +} diff --git a/lib/send.sh b/lib/send.sh index e390983..d6640c4 100644 --- a/lib/send.sh +++ b/lib/send.sh @@ -3,13 +3,26 @@ # Replaces Lark IM with Gmail or Google Chat 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 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 ;; @@ -21,7 +34,15 @@ garc_send() { done if [[ -z "${message}" ]]; then - echo "Usage: garc send \"\" [--to ] [--chat] [--space ]" + cat <" [--to ] [--subject ] # Gmail + garc send --chat "" [--space ] # Chat + garc send chat # Chat management + chat send "" [--space ] [--thread ] + chat list-spaces + chat list-messages [--space ] [--max N] +EOF return 1 fi @@ -32,6 +53,44 @@ garc_send() { 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 \"\" [--space ]"; return 1; } + _garc_send_chat "${message}" "${space_id}" "${thread_key}" + return $? + ;; + esac +} + _garc_send_gmail() { local message="$1" local to="$2" @@ -59,7 +118,8 @@ _garc_send_gmail() { _garc_send_chat() { local message="$1" - local space_id="$2" + local space_id="${2:-${GARC_CHAT_SPACE_ID:-}}" + local thread_key="${3:-}" if [[ -z "${space_id}" ]]; then echo "Error: No Chat space. Set GARC_CHAT_SPACE_ID or use --space " >&2 @@ -75,5 +135,6 @@ _garc_send_chat() { python3 "${GARC_DIR}/scripts/garc-chat-helper.py" send \ --space-id "${space_id}" \ - --message "${message}" + --message "${message}" \ + ${thread_key:+--thread-key "${thread_key}"} } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..437aeb8 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/requirements.txt b/requirements.txt index 963fc52..7d6ac3b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,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 +requests>=2.31.0 pyyaml>=6.0.1 python-dateutil>=2.8.2 rich>=13.0.0 diff --git a/scripts/garc-auth-helper.py b/scripts/garc-auth-helper.py index e8737d3..73acf0d 100755 --- a/scripts/garc-auth-helper.py +++ b/scripts/garc-auth-helper.py @@ -220,6 +220,79 @@ def show_status(): 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(): parser = argparse.ArgumentParser(description="GARC Auth Helper") subparsers = parser.add_subparsers(dest="command") @@ -239,6 +312,13 @@ def main(): # 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() if args.command == "suggest": @@ -249,6 +329,10 @@ def main(): login(args.profile) elif args.command == "status": show_status() + elif args.command == "revoke": + revoke_token() + elif args.command == "service-account-verify": + service_account_verify(args.file) else: parser.print_help() diff --git a/scripts/garc-chat-helper.py b/scripts/garc-chat-helper.py new file mode 100644 index 0000000..a7a75ff --- /dev/null +++ b/scripts/garc-chat-helper.py @@ -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() diff --git a/scripts/garc-drive-helper.py b/scripts/garc-drive-helper.py index ac0ed2c..57d552b 100755 --- a/scripts/garc-drive-helper.py +++ b/scripts/garc-drive-helper.py @@ -269,12 +269,9 @@ def create_doc(name: str, folder_id: str = "root", content: str = ""): doc_id = result["id"] - # Add initial content if provided + # Add initial content if provided via batchUpdate if content: - svc_docs.documents().batchUpdate( - documentId=doc_id, - body={"requests": [{"insertText": {"location": {"index": 1}, "text": content}}]} - ).execute() + _doc_insert_text(svc_docs, doc_id, content, append=False) print(f"✅ Doc created: {name}") print(f" ID: {doc_id}") @@ -282,6 +279,35 @@ def create_doc(name: str, folder_id: str = "root", content: str = ""): 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() def share_file(file_id: str, email: str, role: str = "reader", send_notification: bool = True): @@ -355,12 +381,21 @@ def kg_build(folder_id: str, output: str, depth: int = 3): 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: + files = [] + page_token = None + while True: + kwargs: dict = dict( + q=q, pageSize=100, + 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 for f in files: @@ -467,6 +502,11 @@ def main(): cd.add_argument("--folder-id", default="root") 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 sh = sub.add_parser("share", help="Share file") sh.add_argument("file_id") @@ -506,6 +546,8 @@ def main(): create_folder(args.name, args.parent_id) elif args.command == "create-doc": 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": share_file(args.file_id, args.email, args.role, not args.no_notify) elif args.command == "move": diff --git a/scripts/garc-forms-helper.py b/scripts/garc-forms-helper.py new file mode 100644 index 0000000..153a122 --- /dev/null +++ b/scripts/garc-forms-helper.py @@ -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() diff --git a/scripts/garc-kg-query.py b/scripts/garc-kg-query.py new file mode 100644 index 0000000..510cb45 --- /dev/null +++ b/scripts/garc-kg-query.py @@ -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() diff --git a/scripts/garc-sheets-helper.py b/scripts/garc-sheets-helper.py index 69bdb88..7482b4a 100755 --- a/scripts/garc-sheets-helper.py +++ b/scripts/garc-sheets-helper.py @@ -22,6 +22,7 @@ SHEET_APPROVAL = "approval" SHEET_TASKS_LOG = "tasks_log" SHEET_EMAIL_LOG = "email_log" SHEET_CALENDAR_LOG = "calendar_log" +SHEET_AUDIT = "audit" def get_svc(): @@ -168,6 +169,80 @@ def clear_range(sheets_id: str, range_: str): 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 ───────────────────────────────────────────────── @with_retry() @@ -227,20 +302,58 @@ def agent_register(sheets_id: str, yaml_file: str): return 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() + added = updated = 0 for agent in agents: + agent_id = agent.get("id", "") + if not agent_id: + continue scopes = ",".join(agent.get("scopes", [])) - append_row(sheets_id, SHEET_AGENTS, [ - agent.get("id", ""), + row_values = [ + agent_id, agent.get("model", ""), scopes, agent.get("description", ""), agent.get("profile", ""), "active", agent.get("drive_folder", ""), - ts - ]) - print(f"✅ Registered {len(agents)} agents") + ts, + ] + 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() @@ -301,6 +414,57 @@ def approval_act(sheets_id: str, approval_id: str, action: str, timestamp: str): 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(): parser = argparse.ArgumentParser(description="GARC Sheets Helper") sub = parser.add_subparsers(dest="command") @@ -335,6 +499,28 @@ def main(): clp.add_argument("--sheets-id", required=True) 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 mpl = sub.add_parser("memory-pull") mpl.add_argument("--sheets-id", required=True) @@ -401,6 +587,16 @@ def main(): get_sheet_info(args.sheets_id) elif args.command == "clear": 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": memory_pull(args.sheets_id, args.agent_id, args.output) elif args.command == "memory-push": diff --git a/scripts/garc_core.py b/scripts/garc_core.py index 8f2a804..27b3eab 100644 --- a/scripts/garc_core.py +++ b/scripts/garc_core.py @@ -7,11 +7,19 @@ import json import os import sys import time +import warnings import functools from datetime import datetime, timezone from pathlib import Path 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")) 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")) @@ -82,21 +90,36 @@ def get_credentials(scopes: Optional[list] = None, use_service_account: bool = F try: from google.oauth2.credentials import Credentials from google.auth.transport.requests import Request + from google.auth.exceptions import RefreshError creds = None if TOKEN_FILE.exists(): creds = Credentials.from_authorized_user_file(str(TOKEN_FILE), scopes) if creds and creds.valid: - return creds + # Verify the token covers the requested scopes + if _scopes_covered(creds, scopes): + 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: try: creds.refresh(Request()) _save_token(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: - 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 if not CREDENTIALS_FILE.exists(): @@ -117,6 +140,17 @@ def get_credentials(scopes: Optional[list] = None, use_service_account: bool = F 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): """Save credentials to token file.""" GARC_CONFIG_DIR.mkdir(parents=True, exist_ok=True)