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
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 <command> [subcommand] [options]
ingress verify --queue-id <id>
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 start [--interval <sec>] [--agent <id>]
daemon stop
@ -143,8 +172,18 @@ Usage: garc <command> [subcommand] [options]
kg query "<concept>" Search knowledge graph
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 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
heartbeat Log system state to Sheets
audit list [--agent <id>] [--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
;;

View file

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

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 "$@" ;;
login) garc_auth_login "$@" ;;
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
;;
esac
@ -57,18 +69,25 @@ garc_auth_check() {
python3 "${AUTH_HELPER}" check --profile "${profile}"
}
# garc auth login [--profile <profile>]
# Launches OAuth2 authorization flow
# garc auth login [--profile <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 <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_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",
garc_bin = os.path.join(garc_dir, "bin", "garc")
cmd = [garc_bin, "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
"--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}" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
@ -437,3 +466,80 @@ EOF
echo "To unload:"
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 "$@" ;;
create-folder) garc_drive_create_folder "$@" ;;
create-doc) garc_drive_create_doc "$@" ;;
append-doc) garc_drive_append_doc "$@" ;;
share) garc_drive_share "$@" ;;
move) garc_drive_move "$@" ;;
delete) garc_drive_delete "$@" ;;
@ -31,6 +32,7 @@ Subcommands:
upload <local_path> [--folder-id <id>] [--name <name>] [--convert]
create-folder <name> [--parent-id <id>]
create-doc <name> [--folder-id <id>] [--content <text>]
append-doc <doc_id> --content <text>
share <file_id> --email <email> [--role reader|writer|commenter]
move <file_id> --to <folder_id>
delete <file_id> [--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 <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() {
local file_id="${1:-}"
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 "$@" ;;
verify) _ingress_verify "$@" ;;
stats) _ingress_stats "$@" ;;
stale-reset) _ingress_stale_reset "$@" ;;
*)
cat <<EOF
Usage: garc ingress <subcommand> [options]
@ -48,6 +49,7 @@ Subcommands:
fail --queue-id <id> [--note <text>]
verify --queue-id <id> Verify expected output was produced
stats Queue statistics
stale-reset [--timeout <minutes>] 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 <<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
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
# ─────────────────────────────────────────────────────────────────

108
lib/kg.sh
View file

@ -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 <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
;;
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 "<concept>"
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 \"<concept>\""
echo "Usage: garc kg query \"<concept>\" [--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 <doc_id>
@ -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}"
}

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
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 \"<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
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 \"<message>\" [--space <id>]"; 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 <space_id>" >&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}"}
}

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

View file

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

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"]
# 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":

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_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":

View file

@ -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:
# 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)