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:
parent
680bd433f4
commit
7b5951a1d5
21 changed files with 2078 additions and 144 deletions
121
bin/garc
121
bin/garc
|
|
@ -5,16 +5,40 @@
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
GARC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
GARC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
# ── Python version gate ────────────────────────────────────────────────────
|
||||||
|
if ! python3 -c "import sys; assert sys.version_info >= (3,10), f'Python 3.10+ required, got {sys.version}'" 2>/dev/null; then
|
||||||
|
_PY_VER=$(python3 --version 2>&1)
|
||||||
|
echo "❌ GARC requires Python 3.10 or higher." >&2
|
||||||
|
echo " Detected: ${_PY_VER}" >&2
|
||||||
|
echo " Install Python 3.10+: https://www.python.org/downloads/" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
GARC_LIB="${GARC_DIR}/lib"
|
GARC_LIB="${GARC_DIR}/lib"
|
||||||
GARC_CONFIG="${HOME}/.garc"
|
GARC_CONFIG="${HOME}/.garc"
|
||||||
GARC_CONFIG_ENV="${GARC_CONFIG}/config.env"
|
GARC_CONFIG_ENV="${GARC_CONFIG}/config.env"
|
||||||
|
|
||||||
# Load config if present
|
# Load base config if present
|
||||||
if [[ -f "${GARC_CONFIG_ENV}" ]]; then
|
if [[ -f "${GARC_CONFIG_ENV}" ]]; then
|
||||||
# shellcheck source=/dev/null
|
# shellcheck source=/dev/null
|
||||||
source "${GARC_CONFIG_ENV}"
|
source "${GARC_CONFIG_ENV}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Load profile-specific config (overrides base config)
|
||||||
|
GARC_PROFILE="${GARC_PROFILE:-}"
|
||||||
|
if [[ -n "${GARC_PROFILE}" ]]; then
|
||||||
|
_PROFILE_ENV="${GARC_CONFIG}/profiles/${GARC_PROFILE}/config.env"
|
||||||
|
if [[ -f "${_PROFILE_ENV}" ]]; then
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "${_PROFILE_ENV}"
|
||||||
|
fi
|
||||||
|
# Use profile-specific token if not already overridden
|
||||||
|
_PROFILE_TOKEN="${GARC_CONFIG}/profiles/${GARC_PROFILE}/token.json"
|
||||||
|
if [[ -f "${_PROFILE_TOKEN}" && -z "${GARC_TOKEN_FILE:-}" ]]; then
|
||||||
|
export GARC_TOKEN_FILE="${_PROFILE_TOKEN}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Defaults
|
# Defaults
|
||||||
GARC_CACHE_DIR="${GARC_CACHE_DIR:-${GARC_CONFIG}/cache}"
|
GARC_CACHE_DIR="${GARC_CACHE_DIR:-${GARC_CONFIG}/cache}"
|
||||||
GARC_CACHE_TTL="${GARC_CACHE_TTL:-300}"
|
GARC_CACHE_TTL="${GARC_CACHE_TTL:-300}"
|
||||||
|
|
@ -129,6 +153,11 @@ Usage: garc <command> [subcommand] [options]
|
||||||
ingress verify --queue-id <id>
|
ingress verify --queue-id <id>
|
||||||
ingress stats
|
ingress stats
|
||||||
|
|
||||||
|
━━━ Google Forms (Response Pipeline) ━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
forms list List accessible Google Forms
|
||||||
|
forms responses <id> List form responses
|
||||||
|
forms watch <id> --agent Poll form and auto-enqueue new responses
|
||||||
|
|
||||||
━━━ Daemon (Gmail Poller) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
━━━ Daemon (Gmail Poller) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
daemon start [--interval <sec>] [--agent <id>]
|
daemon start [--interval <sec>] [--agent <id>]
|
||||||
daemon stop
|
daemon stop
|
||||||
|
|
@ -143,8 +172,18 @@ Usage: garc <command> [subcommand] [options]
|
||||||
kg query "<concept>" Search knowledge graph
|
kg query "<concept>" Search knowledge graph
|
||||||
kg show <doc_id> Show doc + links
|
kg show <doc_id> Show doc + links
|
||||||
|
|
||||||
|
━━━ Profiles (Multi-tenant) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
profile list List all tenant profiles
|
||||||
|
profile use <name> Activate a profile (eval output)
|
||||||
|
profile add <name> Create a new profile
|
||||||
|
profile show [<name>] Show profile config
|
||||||
|
profile remove <name> Delete a profile
|
||||||
|
profile current Show active profile
|
||||||
|
|
||||||
━━━ System ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
━━━ System ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
heartbeat Log system state to Sheets
|
heartbeat Log system state to Sheets
|
||||||
|
audit list [--agent <id>] [--since YYYY-MM-DD] View audit log
|
||||||
|
doctor Check Python version and dependencies
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--help, -h Show this help
|
--help, -h Show this help
|
||||||
|
|
@ -174,6 +213,31 @@ done
|
||||||
|
|
||||||
export DEBUG DRY_RUN GARC_DIR GARC_LIB GARC_CONFIG GARC_CACHE_DIR GARC_CACHE_TTL
|
export DEBUG DRY_RUN GARC_DIR GARC_LIB GARC_CONFIG GARC_CACHE_DIR GARC_CACHE_TTL
|
||||||
|
|
||||||
|
# ── Non-blocking audit log ─────────────────────────────────────────────────
|
||||||
|
# Fires in background so it never blocks or fails the main command.
|
||||||
|
_garc_audit_log() {
|
||||||
|
local cmd="$1"
|
||||||
|
local args_str="$2"
|
||||||
|
local result="${3:-ok}"
|
||||||
|
local sheets_id="${GARC_SHEETS_ID:-}"
|
||||||
|
local agent_id="${GARC_DEFAULT_AGENT:-}"
|
||||||
|
|
||||||
|
[[ -z "${sheets_id}" ]] && return 0
|
||||||
|
[[ "${cmd}" == "audit" ]] && return 0 # don't audit audit itself
|
||||||
|
|
||||||
|
(
|
||||||
|
python3 "${GARC_DIR}/scripts/garc-sheets-helper.py" audit-append \
|
||||||
|
--sheets-id "${sheets_id}" \
|
||||||
|
--agent-id "${agent_id}" \
|
||||||
|
--cmd "${cmd}" \
|
||||||
|
--args "${args_str}" \
|
||||||
|
--result "${result}" \
|
||||||
|
--user "${USER:-}" \
|
||||||
|
--timestamp "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
|
||||||
|
2>/dev/null
|
||||||
|
) &
|
||||||
|
}
|
||||||
|
|
||||||
COMMAND="${1:-help}"
|
COMMAND="${1:-help}"
|
||||||
shift || true
|
shift || true
|
||||||
|
|
||||||
|
|
@ -254,6 +318,61 @@ case "${COMMAND}" in
|
||||||
source "${GARC_LIB}/daemon.sh"
|
source "${GARC_LIB}/daemon.sh"
|
||||||
garc_daemon "$@"
|
garc_daemon "$@"
|
||||||
;;
|
;;
|
||||||
|
audit)
|
||||||
|
source "${GARC_LIB}/audit.sh"
|
||||||
|
_garc_audit_log "audit" "$*"
|
||||||
|
garc_audit "$@"
|
||||||
|
;;
|
||||||
|
profile)
|
||||||
|
source "${GARC_LIB}/profile.sh"
|
||||||
|
garc_profile "$@"
|
||||||
|
;;
|
||||||
|
forms)
|
||||||
|
source "${GARC_LIB}/forms.sh"
|
||||||
|
garc_forms "$@"
|
||||||
|
;;
|
||||||
|
doctor)
|
||||||
|
python3 - <<'PY'
|
||||||
|
import sys, importlib, subprocess
|
||||||
|
|
||||||
|
print("GARC Doctor — Environment Check")
|
||||||
|
print("─" * 40)
|
||||||
|
|
||||||
|
# Python version
|
||||||
|
pv = sys.version_info
|
||||||
|
status = "✅" if pv >= (3, 10) else "❌"
|
||||||
|
print(f"{status} Python {pv.major}.{pv.minor}.{pv.micro} (required: >=3.10,<3.13)")
|
||||||
|
|
||||||
|
# Required packages
|
||||||
|
required = [
|
||||||
|
("googleapiclient", "google-api-python-client"),
|
||||||
|
("google.auth", "google-auth"),
|
||||||
|
("google_auth_oauthlib", "google-auth-oauthlib"),
|
||||||
|
("httplib2", "google-auth-httplib2"),
|
||||||
|
("requests", "requests"),
|
||||||
|
("yaml", "pyyaml"),
|
||||||
|
("dateutil", "python-dateutil"),
|
||||||
|
("rich", "rich"),
|
||||||
|
]
|
||||||
|
print()
|
||||||
|
print("Dependencies:")
|
||||||
|
all_ok = True
|
||||||
|
for module, pkg in required:
|
||||||
|
try:
|
||||||
|
importlib.import_module(module)
|
||||||
|
print(f" ✅ {pkg}")
|
||||||
|
except ImportError:
|
||||||
|
print(f" ❌ {pkg} → pip install {pkg}")
|
||||||
|
all_ok = False
|
||||||
|
|
||||||
|
print()
|
||||||
|
if all_ok:
|
||||||
|
print("✅ All checks passed.")
|
||||||
|
else:
|
||||||
|
print("⚠️ Some packages missing. Run: pip install -r requirements.txt")
|
||||||
|
sys.exit(1)
|
||||||
|
PY
|
||||||
|
;;
|
||||||
help|--help|-h)
|
help|--help|-h)
|
||||||
usage
|
usage
|
||||||
;;
|
;;
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,19 @@ GARC_TOKEN_FILE=~/.garc/token.json
|
||||||
# Service account JSON file (for bot/automated operations)
|
# Service account JSON file (for bot/automated operations)
|
||||||
# GARC_SERVICE_ACCOUNT_FILE=~/.garc/service_account.json
|
# GARC_SERVICE_ACCOUNT_FILE=~/.garc/service_account.json
|
||||||
|
|
||||||
|
# Domain-wide Delegation: impersonate this user when using a service account
|
||||||
|
# Requires DWD to be enabled in Google Workspace Admin Console
|
||||||
|
# GARC_IMPERSONATE_EMAIL=user@yourdomain.com
|
||||||
|
|
||||||
# Default agent ID
|
# Default agent ID
|
||||||
GARC_DEFAULT_AGENT=main
|
GARC_DEFAULT_AGENT=main
|
||||||
|
|
||||||
# Cache directory
|
# Cache directory
|
||||||
GARC_CACHE_DIR=~/.garc/cache
|
GARC_CACHE_DIR=~/.garc/cache
|
||||||
|
|
||||||
|
# Approval gate: email address to notify when an item requires human approval
|
||||||
|
# If unset, approval notifications are skipped (item is still blocked in queue)
|
||||||
|
GARC_APPROVAL_EMAIL=approver@example.com
|
||||||
|
|
||||||
|
# Auto-confirm preview gate in non-interactive mode (daemon/CI use only)
|
||||||
|
GARC_AUTO_CONFIRM=false
|
||||||
|
|
|
||||||
327
docs/playbook-v0.1-findings.md
Normal file
327
docs/playbook-v0.1-findings.md
Normal 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
47
lib/audit.sh
Normal 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}"
|
||||||
|
}
|
||||||
71
lib/auth.sh
71
lib/auth.sh
|
|
@ -16,8 +16,20 @@ garc_auth() {
|
||||||
check) garc_auth_check "$@" ;;
|
check) garc_auth_check "$@" ;;
|
||||||
login) garc_auth_login "$@" ;;
|
login) garc_auth_login "$@" ;;
|
||||||
status) garc_auth_status "$@" ;;
|
status) garc_auth_status "$@" ;;
|
||||||
|
revoke) garc_auth_revoke "$@" ;;
|
||||||
|
service-account) garc_auth_service_account "$@" ;;
|
||||||
*)
|
*)
|
||||||
echo "Usage: garc auth <suggest|check|login|status>"
|
cat <<EOF
|
||||||
|
Usage: garc auth <subcommand>
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
suggest "<task>" Infer minimum OAuth scopes for a task
|
||||||
|
check [--profile <p>] Check if current token covers required scopes
|
||||||
|
login [--profile <p>] Launch OAuth2 authorization flow
|
||||||
|
status Show current token info and scopes
|
||||||
|
revoke Revoke and delete the stored OAuth token
|
||||||
|
service-account verify Verify service account credentials and scopes
|
||||||
|
EOF
|
||||||
return 1
|
return 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
@ -57,18 +69,25 @@ garc_auth_check() {
|
||||||
python3 "${AUTH_HELPER}" check --profile "${profile}"
|
python3 "${AUTH_HELPER}" check --profile "${profile}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# garc auth login [--profile <profile>]
|
# garc auth login [--profile <profile>] [--type oauth|service-account]
|
||||||
# Launches OAuth2 authorization flow
|
# Launches OAuth2 authorization flow, or validates service account
|
||||||
garc_auth_login() {
|
garc_auth_login() {
|
||||||
local profile="writer"
|
local profile="writer"
|
||||||
|
local auth_type="oauth"
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--profile) profile="$2"; shift 2 ;;
|
--profile) profile="$2"; shift 2 ;;
|
||||||
|
--type) auth_type="$2"; shift 2 ;;
|
||||||
*) shift ;;
|
*) shift ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
if [[ "${auth_type}" == "service-account" ]]; then
|
||||||
|
garc_auth_service_account verify
|
||||||
|
return $?
|
||||||
|
fi
|
||||||
|
|
||||||
python3 "${AUTH_HELPER}" login --profile "${profile}"
|
python3 "${AUTH_HELPER}" login --profile "${profile}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,3 +96,49 @@ garc_auth_login() {
|
||||||
garc_auth_status() {
|
garc_auth_status() {
|
||||||
python3 "${AUTH_HELPER}" status
|
python3 "${AUTH_HELPER}" status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# garc auth revoke
|
||||||
|
# Revoke and delete the stored OAuth token
|
||||||
|
garc_auth_revoke() {
|
||||||
|
python3 "${AUTH_HELPER}" revoke
|
||||||
|
}
|
||||||
|
|
||||||
|
# garc auth service-account <verify|info>
|
||||||
|
garc_auth_service_account() {
|
||||||
|
local sub="${1:-verify}"
|
||||||
|
shift || true
|
||||||
|
|
||||||
|
local sa_file="${GARC_SERVICE_ACCOUNT_FILE:-${HOME}/.garc/service_account.json}"
|
||||||
|
|
||||||
|
case "${sub}" in
|
||||||
|
verify)
|
||||||
|
if [[ ! -f "${sa_file}" ]]; then
|
||||||
|
echo "❌ Service account file not found: ${sa_file}"
|
||||||
|
echo " Set GARC_SERVICE_ACCOUNT_FILE in ~/.garc/config.env"
|
||||||
|
echo " Or download from Google Cloud Console → IAM & Admin → Service Accounts"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "Verifying service account credentials..."
|
||||||
|
python3 "${AUTH_HELPER}" service-account-verify --file "${sa_file}"
|
||||||
|
;;
|
||||||
|
info)
|
||||||
|
if [[ ! -f "${sa_file}" ]]; then
|
||||||
|
echo "❌ Service account file not found: ${sa_file}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
python3 -c "
|
||||||
|
import json
|
||||||
|
with open('${sa_file}') as f:
|
||||||
|
sa = json.load(f)
|
||||||
|
print('Service Account:')
|
||||||
|
print(f\" Email : {sa.get('client_email', 'N/A')}\")
|
||||||
|
print(f\" Project: {sa.get('project_id', 'N/A')}\")
|
||||||
|
print(f\" Type : {sa.get('type', 'N/A')}\")
|
||||||
|
"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: garc auth service-account <verify|info>"
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
|
||||||
212
lib/daemon.sh
212
lib/daemon.sh
|
|
@ -138,41 +138,21 @@ _start_gmail_poller() {
|
||||||
# Gmail polling loop — the core ingress driver
|
# Gmail polling loop — the core ingress driver
|
||||||
# ─────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_gmail_poller_loop() {
|
# ── Single poll cycle (shared by loop and poll-once) ─────────────────────────
|
||||||
|
_gmail_poll_cycle() {
|
||||||
local agent_id="${1:-main}"
|
local agent_id="${1:-main}"
|
||||||
local interval="${2:-60}"
|
local max_msgs="${2:-10}"
|
||||||
local label="${3:-INBOX}"
|
|
||||||
local max_msgs="${4:-10}"
|
|
||||||
|
|
||||||
local seen_file="${DAEMON_SEEN_DIR}/seen-${agent_id}.txt"
|
local seen_file="${DAEMON_SEEN_DIR}/seen-${agent_id}.txt"
|
||||||
|
|
||||||
touch "${seen_file}"
|
touch "${seen_file}"
|
||||||
|
|
||||||
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] [gmail-poller] Starting (agent=${agent_id}, interval=${interval}s, label=${label})"
|
python3 - "${seen_file}" "${agent_id}" "${max_msgs}" <<'PY'
|
||||||
|
import json, sys, subprocess, os
|
||||||
# Reload config
|
|
||||||
[[ -f "${GARC_CONFIG:-${HOME}/.garc}/config.env" ]] && \
|
|
||||||
source "${GARC_CONFIG:-${HOME}/.garc}/config.env" 2>/dev/null || true
|
|
||||||
|
|
||||||
while true; do
|
|
||||||
# ── Fetch recent unread emails ───────────────────────────────
|
|
||||||
local raw_msgs fetch_ok
|
|
||||||
raw_msgs=$(python3 "${GARC_DIR}/scripts/garc-gmail-helper.py" inbox \
|
|
||||||
--max "${max_msgs}" --unread 2>/dev/null) && fetch_ok=1 || fetch_ok=0
|
|
||||||
|
|
||||||
if [[ "${fetch_ok}" -eq 0 ]] || [[ -z "${raw_msgs}" ]]; then
|
|
||||||
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] [gmail-poller] fetch failed, retrying in ${interval}s"
|
|
||||||
sleep "${interval}"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Parse and enqueue new messages ───────────────────────────
|
|
||||||
python3 - "${seen_file}" "${agent_id}" <<'PY'
|
|
||||||
import json, sys, subprocess, os, re
|
|
||||||
|
|
||||||
seen_file = sys.argv[1]
|
seen_file = sys.argv[1]
|
||||||
agent_id = sys.argv[2]
|
agent_id = sys.argv[2]
|
||||||
|
max_msgs = sys.argv[3]
|
||||||
garc_dir = os.environ.get("GARC_DIR", "")
|
garc_dir = os.environ.get("GARC_DIR", "")
|
||||||
garc_lib = os.environ.get("GARC_LIB", "")
|
|
||||||
|
|
||||||
# Read seen message IDs
|
# Read seen message IDs
|
||||||
try:
|
try:
|
||||||
|
|
@ -181,55 +161,49 @@ try:
|
||||||
except Exception:
|
except Exception:
|
||||||
seen = set()
|
seen = set()
|
||||||
|
|
||||||
# Parse inbox output (table format from gmail helper)
|
# Fetch inbox as JSON
|
||||||
# Format: ID | FROM | SUBJECT | DATE | SNIPPET
|
|
||||||
raw = sys.stdin.read() if not sys.stdin.isatty() else ""
|
|
||||||
|
|
||||||
# Actually re-fetch as JSON for reliable parsing
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["python3", os.path.join(garc_dir, "scripts", "garc-gmail-helper.py"),
|
["python3", os.path.join(garc_dir, "scripts", "garc-gmail-helper.py"),
|
||||||
"inbox", "--max", "10", "--unread", "--format", "json"],
|
"inbox", "--max", max_msgs, "--unread", "--format", "json"],
|
||||||
capture_output=True, text=True
|
capture_output=True, text=True
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print(f"[gmail-poller] inbox fetch error: {result.stderr.strip()}", flush=True)
|
print(f"[gmail-poller] inbox fetch error: {result.stderr.strip()}", flush=True)
|
||||||
sys.exit(0)
|
sys.exit(1)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
messages = json.loads(result.stdout)
|
messages = json.loads(result.stdout)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[gmail-poller] JSON parse error: {e}", flush=True)
|
print(f"[gmail-poller] JSON parse error: {e}", flush=True)
|
||||||
sys.exit(0)
|
sys.exit(1)
|
||||||
|
|
||||||
if not isinstance(messages, list):
|
if not isinstance(messages, list):
|
||||||
messages = []
|
messages = []
|
||||||
|
|
||||||
new_seen = []
|
new_seen = []
|
||||||
|
enqueued = 0
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
msg_id = msg.get("id", "")
|
msg_id = msg.get("id", "")
|
||||||
sender = msg.get("from", "")
|
sender = msg.get("from", "")
|
||||||
subject = msg.get("subject", "(no subject)")
|
subject = msg.get("subject", "(no subject)")
|
||||||
snippet = msg.get("snippet", "")[:120]
|
snippet = msg.get("snippet", "")[:120]
|
||||||
|
|
||||||
if not msg_id or msg_id in seen:
|
if not msg_id:
|
||||||
|
continue
|
||||||
|
if msg_id in seen:
|
||||||
new_seen.append(msg_id)
|
new_seen.append(msg_id)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Build a human-readable task description
|
|
||||||
text = f"Email from {sender}: {subject}"
|
text = f"Email from {sender}: {subject}"
|
||||||
if snippet:
|
if snippet:
|
||||||
text += f" — {snippet}"
|
text += f" — {snippet}"
|
||||||
|
|
||||||
cmd = [
|
garc_bin = os.path.join(garc_dir, "bin", "garc")
|
||||||
"garc", "ingress", "enqueue",
|
cmd = [garc_bin, "ingress", "enqueue",
|
||||||
"--text", text,
|
"--text", text,
|
||||||
"--source", "gmail",
|
"--source", "gmail",
|
||||||
"--sender", sender,
|
"--sender", sender,
|
||||||
"--agent", agent_id,
|
"--agent", agent_id]
|
||||||
]
|
|
||||||
# Use larc path
|
|
||||||
garc_bin = os.path.join(garc_dir, "bin", "garc")
|
|
||||||
cmd[0] = garc_bin
|
|
||||||
|
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env["GARC_DIR"] = garc_dir
|
env["GARC_DIR"] = garc_dir
|
||||||
|
|
@ -237,6 +211,7 @@ for msg in messages:
|
||||||
r = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
r = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
||||||
if r.returncode == 0:
|
if r.returncode == 0:
|
||||||
print(f"[gmail-poller] Enqueued: {msg_id[:16]} from {sender[:30]}", flush=True)
|
print(f"[gmail-poller] Enqueued: {msg_id[:16]} from {sender[:30]}", flush=True)
|
||||||
|
enqueued += 1
|
||||||
else:
|
else:
|
||||||
print(f"[gmail-poller] Enqueue failed: {r.stderr.strip()}", flush=True)
|
print(f"[gmail-poller] Enqueue failed: {r.stderr.strip()}", flush=True)
|
||||||
|
|
||||||
|
|
@ -245,8 +220,27 @@ for msg in messages:
|
||||||
if new_seen:
|
if new_seen:
|
||||||
with open(seen_file, "a") as f:
|
with open(seen_file, "a") as f:
|
||||||
f.write("\n".join(new_seen) + "\n")
|
f.write("\n".join(new_seen) + "\n")
|
||||||
PY
|
|
||||||
|
|
||||||
|
print(f"[gmail-poller] Cycle done — {enqueued} enqueued, {len(messages) - enqueued} skipped", flush=True)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
_gmail_poller_loop() {
|
||||||
|
local agent_id="${1:-main}"
|
||||||
|
local interval="${2:-60}"
|
||||||
|
local label="${3:-INBOX}"
|
||||||
|
local max_msgs="${4:-10}"
|
||||||
|
|
||||||
|
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] [gmail-poller] Starting (agent=${agent_id}, interval=${interval}s, label=${label})"
|
||||||
|
|
||||||
|
# Reload config
|
||||||
|
[[ -f "${GARC_CONFIG:-${HOME}/.garc}/config.env" ]] && \
|
||||||
|
source "${GARC_CONFIG:-${HOME}/.garc}/config.env" 2>/dev/null || true
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] [gmail-poller] Polling..."
|
||||||
|
_gmail_poll_cycle "${agent_id}" "${max_msgs}" || \
|
||||||
|
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] [gmail-poller] cycle error, will retry"
|
||||||
sleep "${interval}"
|
sleep "${interval}"
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
@ -337,12 +331,17 @@ _daemon_poll_once() {
|
||||||
|
|
||||||
_daemon_ensure_dirs
|
_daemon_ensure_dirs
|
||||||
|
|
||||||
|
# Reload config so GARC_DIR etc. are available in subprocesses
|
||||||
|
if [[ -f "${GARC_CONFIG:-${HOME}/.garc}/config.env" ]]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "${GARC_CONFIG:-${HOME}/.garc}/config.env" 2>/dev/null || true
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
echo "🔍 Polling Gmail inbox (agent=${agent}, max=${max_msgs})..."
|
echo "🔍 Polling Gmail inbox (agent=${agent}, max=${max_msgs})..."
|
||||||
_gmail_poller_loop "${agent}" "0" "INBOX" "${max_msgs}" &
|
# Run a single cycle synchronously — no background process, no timeout kill
|
||||||
local pid=$!
|
_gmail_poll_cycle "${agent}" "${max_msgs}"
|
||||||
# Wait a moment for one cycle to complete then stop
|
|
||||||
sleep 5
|
|
||||||
kill "${pid}" 2>/dev/null || true
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Poll cycle complete. Check: garc ingress list"
|
echo "Poll cycle complete. Check: garc ingress list"
|
||||||
}
|
}
|
||||||
|
|
@ -373,27 +372,57 @@ _daemon_logs() {
|
||||||
}
|
}
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────
|
||||||
# install — macOS launchd plist for auto-start on login
|
# install — OS-aware service installation (macOS launchd or Linux systemd)
|
||||||
# ─────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_daemon_install_launchd() {
|
_daemon_install_launchd() {
|
||||||
|
# Backward-compatible alias — delegates to _daemon_install_service
|
||||||
|
_daemon_install_service "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
_daemon_install_service() {
|
||||||
local agent="${GARC_DEFAULT_AGENT:-main}"
|
local agent="${GARC_DEFAULT_AGENT:-main}"
|
||||||
local interval=60
|
local interval=60
|
||||||
local label="com.garc.gmail-poller"
|
local system_wide=false
|
||||||
local plist_path="${HOME}/Library/LaunchAgents/${label}.plist"
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--agent|-a) agent="$2"; shift 2 ;;
|
--agent|-a) agent="$2"; shift 2 ;;
|
||||||
--interval|-i) interval="$2"; shift 2 ;;
|
--interval|-i) interval="$2"; shift 2 ;;
|
||||||
|
--system) system_wide=true; shift ;;
|
||||||
*) shift ;;
|
*) shift ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
_daemon_ensure_dirs
|
_daemon_ensure_dirs
|
||||||
|
|
||||||
|
local os_type
|
||||||
|
os_type="$(uname -s)"
|
||||||
|
|
||||||
|
case "${os_type}" in
|
||||||
|
Darwin)
|
||||||
|
_daemon_install_launchd_plist "${agent}" "${interval}"
|
||||||
|
;;
|
||||||
|
Linux)
|
||||||
|
_daemon_install_systemd_unit "${agent}" "${interval}" "${system_wide}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "⚠️ Unsupported OS: ${os_type}"
|
||||||
|
echo " Manual setup: run 'garc daemon start' from your init system."
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
_daemon_install_launchd_plist() {
|
||||||
|
local agent="$1"
|
||||||
|
local interval="$2"
|
||||||
|
local label="com.garc.gmail-poller"
|
||||||
|
local plist_path="${HOME}/Library/LaunchAgents/${label}.plist"
|
||||||
local garc_bin="${GARC_DIR}/bin/garc"
|
local garc_bin="${GARC_DIR}/bin/garc"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "${plist_path}")"
|
||||||
|
|
||||||
cat > "${plist_path}" <<EOF
|
cat > "${plist_path}" <<EOF
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||||
|
|
@ -437,3 +466,80 @@ EOF
|
||||||
echo "To unload:"
|
echo "To unload:"
|
||||||
echo " launchctl unload ${plist_path}"
|
echo " launchctl unload ${plist_path}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_daemon_install_systemd_unit() {
|
||||||
|
local agent="$1"
|
||||||
|
local interval="$2"
|
||||||
|
local system_wide="$3"
|
||||||
|
local garc_bin="${GARC_DIR}/bin/garc"
|
||||||
|
local unit_name="garc-gmail-poller"
|
||||||
|
|
||||||
|
local unit_dir
|
||||||
|
if [[ "${system_wide}" == "true" ]]; then
|
||||||
|
unit_dir="/etc/systemd/system"
|
||||||
|
else
|
||||||
|
unit_dir="${HOME}/.config/systemd/user"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${unit_dir}"
|
||||||
|
local unit_path="${unit_dir}/${unit_name}.service"
|
||||||
|
local timer_path="${unit_dir}/${unit_name}.timer"
|
||||||
|
|
||||||
|
# Service unit — runs one poll cycle
|
||||||
|
cat > "${unit_path}" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=GARC Gmail Poller (single cycle)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=${garc_bin} daemon poll-once --agent ${agent}
|
||||||
|
Environment=GARC_DIR=${GARC_DIR}
|
||||||
|
Environment=PATH=/usr/local/bin:/usr/bin:/bin
|
||||||
|
StandardOutput=append:${GMAIL_POLLER_LOG}
|
||||||
|
StandardError=append:${GMAIL_POLLER_LOG}
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Timer unit — triggers service every N seconds
|
||||||
|
cat > "${timer_path}" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=GARC Gmail Poller Timer
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=30
|
||||||
|
OnUnitActiveSec=${interval}
|
||||||
|
Unit=${unit_name}.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "✅ Installed systemd units:"
|
||||||
|
echo " ${unit_path}"
|
||||||
|
echo " ${timer_path}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ "${system_wide}" == "true" ]]; then
|
||||||
|
echo "To activate (system-wide, requires sudo):"
|
||||||
|
echo " sudo systemctl daemon-reload"
|
||||||
|
echo " sudo systemctl enable --now ${unit_name}.timer"
|
||||||
|
echo ""
|
||||||
|
echo "To disable:"
|
||||||
|
echo " sudo systemctl disable --now ${unit_name}.timer"
|
||||||
|
else
|
||||||
|
echo "To activate (user-level):"
|
||||||
|
echo " systemctl --user daemon-reload"
|
||||||
|
echo " systemctl --user enable --now ${unit_name}.timer"
|
||||||
|
echo " systemctl --user status ${unit_name}.timer"
|
||||||
|
echo ""
|
||||||
|
echo "To disable:"
|
||||||
|
echo " systemctl --user disable --now ${unit_name}.timer"
|
||||||
|
echo ""
|
||||||
|
echo "To view logs:"
|
||||||
|
echo " journalctl --user -u ${unit_name}.service -f"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
|
||||||
20
lib/drive.sh
20
lib/drive.sh
|
|
@ -16,6 +16,7 @@ garc_drive() {
|
||||||
upload) garc_drive_upload "$@" ;;
|
upload) garc_drive_upload "$@" ;;
|
||||||
create-folder) garc_drive_create_folder "$@" ;;
|
create-folder) garc_drive_create_folder "$@" ;;
|
||||||
create-doc) garc_drive_create_doc "$@" ;;
|
create-doc) garc_drive_create_doc "$@" ;;
|
||||||
|
append-doc) garc_drive_append_doc "$@" ;;
|
||||||
share) garc_drive_share "$@" ;;
|
share) garc_drive_share "$@" ;;
|
||||||
move) garc_drive_move "$@" ;;
|
move) garc_drive_move "$@" ;;
|
||||||
delete) garc_drive_delete "$@" ;;
|
delete) garc_drive_delete "$@" ;;
|
||||||
|
|
@ -31,6 +32,7 @@ Subcommands:
|
||||||
upload <local_path> [--folder-id <id>] [--name <name>] [--convert]
|
upload <local_path> [--folder-id <id>] [--name <name>] [--convert]
|
||||||
create-folder <name> [--parent-id <id>]
|
create-folder <name> [--parent-id <id>]
|
||||||
create-doc <name> [--folder-id <id>] [--content <text>]
|
create-doc <name> [--folder-id <id>] [--content <text>]
|
||||||
|
append-doc <doc_id> --content <text>
|
||||||
share <file_id> --email <email> [--role reader|writer|commenter]
|
share <file_id> --email <email> [--role reader|writer|commenter]
|
||||||
move <file_id> --to <folder_id>
|
move <file_id> --to <folder_id>
|
||||||
delete <file_id> [--permanent]
|
delete <file_id> [--permanent]
|
||||||
|
|
@ -172,6 +174,24 @@ garc_drive_create_doc() {
|
||||||
${content:+--content "${content}"}
|
${content:+--content "${content}"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
garc_drive_append_doc() {
|
||||||
|
local doc_id="${1:-}"
|
||||||
|
shift || true
|
||||||
|
local content=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--content|-c) content="$2"; shift 2 ;;
|
||||||
|
*) doc_id="${doc_id:-$1}"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -z "${doc_id}" ]] && { echo "Usage: garc drive append-doc <doc_id> --content <text>"; return 1; }
|
||||||
|
[[ -z "${content}" ]] && { echo "Usage: garc drive append-doc <doc_id> --content <text>"; return 1; }
|
||||||
|
|
||||||
|
python3 "${DRIVE_HELPER}" append-doc "${doc_id}" --content "${content}"
|
||||||
|
}
|
||||||
|
|
||||||
garc_drive_share() {
|
garc_drive_share() {
|
||||||
local file_id="${1:-}"
|
local file_id="${1:-}"
|
||||||
shift || true
|
shift || true
|
||||||
|
|
|
||||||
90
lib/forms.sh
Normal file
90
lib/forms.sh
Normal 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}"
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,7 @@ garc_ingress() {
|
||||||
fail) _ingress_fail "$@" ;;
|
fail) _ingress_fail "$@" ;;
|
||||||
verify) _ingress_verify "$@" ;;
|
verify) _ingress_verify "$@" ;;
|
||||||
stats) _ingress_stats "$@" ;;
|
stats) _ingress_stats "$@" ;;
|
||||||
|
stale-reset) _ingress_stale_reset "$@" ;;
|
||||||
*)
|
*)
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
Usage: garc ingress <subcommand> [options]
|
Usage: garc ingress <subcommand> [options]
|
||||||
|
|
@ -48,6 +49,7 @@ Subcommands:
|
||||||
fail --queue-id <id> [--note <text>]
|
fail --queue-id <id> [--note <text>]
|
||||||
verify --queue-id <id> Verify expected output was produced
|
verify --queue-id <id> Verify expected output was produced
|
||||||
stats Queue statistics
|
stats Queue statistics
|
||||||
|
stale-reset [--timeout <minutes>] Reset in_progress items older than N min (default: 30)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
garc ingress enqueue --text "Send weekly report to manager"
|
garc ingress enqueue --text "Send weekly report to manager"
|
||||||
|
|
@ -314,6 +316,36 @@ _ingress_run_once() {
|
||||||
_ingress_update_status "${queue_id}" "blocked"
|
_ingress_update_status "${queue_id}" "blocked"
|
||||||
source "${GARC_LIB}/approve.sh"
|
source "${GARC_LIB}/approve.sh"
|
||||||
garc_approve_create "${message}"
|
garc_approve_create "${message}"
|
||||||
|
|
||||||
|
# ── Notify approver via Gmail ─────────────────────────────
|
||||||
|
local approval_email="${GARC_APPROVAL_EMAIL:-}"
|
||||||
|
if [[ -n "${approval_email}" ]]; then
|
||||||
|
local subject="[GARC Approval Required] ${message:0:60}"
|
||||||
|
local body
|
||||||
|
body="$(cat <<BODY
|
||||||
|
A task requires your approval before execution.
|
||||||
|
|
||||||
|
Queue ID : ${queue_id}
|
||||||
|
Agent : ${agent}
|
||||||
|
Task : ${message}
|
||||||
|
|
||||||
|
To approve:
|
||||||
|
garc ingress approve --queue-id ${queue_id}
|
||||||
|
garc ingress resume --queue-id ${queue_id}
|
||||||
|
|
||||||
|
To reject:
|
||||||
|
garc ingress fail --queue-id ${queue_id} --note "rejected by approver"
|
||||||
|
BODY
|
||||||
|
)"
|
||||||
|
python3 "${GARC_DIR}/scripts/garc-gmail-helper.py" send \
|
||||||
|
--to "${approval_email}" \
|
||||||
|
--subject "${subject}" \
|
||||||
|
--body "${body}" 2>/dev/null \
|
||||||
|
&& echo "📧 Approval notification sent to: ${approval_email}" \
|
||||||
|
|| echo "⚠️ Could not send approval email (check GARC_APPROVAL_EMAIL and Gmail auth)"
|
||||||
|
else
|
||||||
|
echo " ℹ️ Set GARC_APPROVAL_EMAIL in ~/.garc/config.env to enable email notifications."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
echo "Status set to: blocked"
|
echo "Status set to: blocked"
|
||||||
|
|
@ -607,6 +639,68 @@ _ingress_stats() {
|
||||||
python3 "${INGRESS_HELPER}" stats --queue-dir "${GARC_QUEUE_DIR}"
|
python3 "${INGRESS_HELPER}" stats --queue-dir "${GARC_QUEUE_DIR}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# stale-reset — reset in_progress items that have been stuck too long
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_ingress_stale_reset() {
|
||||||
|
local timeout_min=30
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--timeout|-t) timeout_min="$2"; shift 2 ;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Scanning for in_progress items older than ${timeout_min} minutes..."
|
||||||
|
|
||||||
|
python3 - "${GARC_QUEUE_DIR}" "${timeout_min}" <<'PY'
|
||||||
|
import json, sys, os
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
queue_dir = Path(sys.argv[1])
|
||||||
|
timeout_min = int(sys.argv[2])
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(minutes=timeout_min)
|
||||||
|
|
||||||
|
reset_count = 0
|
||||||
|
if not queue_dir.exists():
|
||||||
|
print("Queue directory not found.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
for f in queue_dir.glob("*.jsonl"):
|
||||||
|
try:
|
||||||
|
line = f.read_text().strip().splitlines()[0]
|
||||||
|
item = json.loads(line)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if item.get("status") != "in_progress":
|
||||||
|
continue
|
||||||
|
|
||||||
|
updated_at_str = item.get("updated_at") or item.get("created_at", "")
|
||||||
|
try:
|
||||||
|
updated_at = datetime.fromisoformat(updated_at_str.replace("Z", "+00:00"))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if updated_at < cutoff:
|
||||||
|
age_min = int((datetime.now(timezone.utc) - updated_at).total_seconds() / 60)
|
||||||
|
item["status"] = "pending"
|
||||||
|
item["updated_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
item["note"] = f"[stale-reset] was in_progress for {age_min}min"
|
||||||
|
f.write_text(json.dumps(item) + "\n")
|
||||||
|
print(f" ↻ Reset: {item.get('queue_id','')[:16]} (stuck {age_min}min)")
|
||||||
|
reset_count += 1
|
||||||
|
|
||||||
|
if reset_count == 0:
|
||||||
|
print(f" No stale items found (threshold: {timeout_min}min).")
|
||||||
|
else:
|
||||||
|
print(f"\n✅ Reset {reset_count} stale item(s) → pending")
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────
|
||||||
# Internal helpers
|
# Internal helpers
|
||||||
# ─────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
108
lib/kg.sh
108
lib/kg.sh
|
|
@ -3,6 +3,7 @@
|
||||||
# Google Docs replaces Lark Wiki as the knowledge graph surface
|
# Google Docs replaces Lark Wiki as the knowledge graph surface
|
||||||
|
|
||||||
GARC_KG_CACHE="${GARC_CACHE_DIR:-${HOME}/.garc/cache}/knowledge-graph.json"
|
GARC_KG_CACHE="${GARC_CACHE_DIR:-${HOME}/.garc/cache}/knowledge-graph.json"
|
||||||
|
KG_QUERY_HELPER="${GARC_DIR}/scripts/garc-kg-query.py"
|
||||||
|
|
||||||
garc_kg() {
|
garc_kg() {
|
||||||
local subcommand="${1:-help}"
|
local subcommand="${1:-help}"
|
||||||
|
|
@ -13,37 +14,70 @@ garc_kg() {
|
||||||
query) garc_kg_query "$@" ;;
|
query) garc_kg_query "$@" ;;
|
||||||
show) garc_kg_show "$@" ;;
|
show) garc_kg_show "$@" ;;
|
||||||
*)
|
*)
|
||||||
echo "Usage: garc kg <build|query|show>"
|
cat <<EOF
|
||||||
|
Usage: garc kg <subcommand>
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
build [--folder-id <id>] [--depth <N>] Build KG index from Drive Docs
|
||||||
|
query "<keyword>" [--max <N>] Search knowledge graph
|
||||||
|
show <doc_id> Show doc metadata and links
|
||||||
|
EOF
|
||||||
return 1
|
return 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
# garc kg build
|
# garc kg build
|
||||||
# Crawls Google Drive folder and builds knowledge graph from Docs
|
|
||||||
garc_kg_build() {
|
garc_kg_build() {
|
||||||
local folder_id="${GARC_DRIVE_FOLDER_ID:-}"
|
local folder_id="${GARC_DRIVE_FOLDER_ID:-}"
|
||||||
|
local depth=3
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--folder-id|-f) folder_id="$2"; shift 2 ;;
|
||||||
|
--depth|-d) depth="$2"; shift 2 ;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
if [[ -z "${folder_id}" ]]; then
|
if [[ -z "${folder_id}" ]]; then
|
||||||
echo "Error: GARC_DRIVE_FOLDER_ID not set" >&2
|
echo "Error: GARC_DRIVE_FOLDER_ID not set. Use --folder-id or run 'garc setup all'." >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
local cache_dir
|
||||||
|
cache_dir="$(dirname "${GARC_KG_CACHE}")"
|
||||||
|
mkdir -p "${cache_dir}"
|
||||||
|
|
||||||
echo "Building knowledge graph from Google Drive folder: ${folder_id}"
|
echo "Building knowledge graph from Google Drive folder: ${folder_id}"
|
||||||
|
|
||||||
python3 "${GARC_DIR}/scripts/garc-drive-helper.py" kg-build \
|
python3 "${GARC_DIR}/scripts/garc-drive-helper.py" kg-build \
|
||||||
--folder-id "${folder_id}" \
|
--folder-id "${folder_id}" \
|
||||||
--output "${GARC_KG_CACHE}"
|
--output "${GARC_KG_CACHE}" \
|
||||||
|
--depth "${depth}"
|
||||||
|
|
||||||
echo "✅ Knowledge graph built: ${GARC_KG_CACHE}"
|
if [[ -f "${GARC_KG_CACHE}" ]]; then
|
||||||
|
local count
|
||||||
|
count=$(python3 -c "import json; d=json.load(open('${GARC_KG_CACHE}')); print(d.get('node_count', 0))" 2>/dev/null || echo "?")
|
||||||
|
echo "✅ Knowledge graph built: ${count} docs → ${GARC_KG_CACHE}"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# garc kg query "<concept>"
|
# garc kg query "<concept>"
|
||||||
garc_kg_query() {
|
garc_kg_query() {
|
||||||
local query="$*"
|
local max=10
|
||||||
|
local terms=()
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--max|-n) max="$2"; shift 2 ;;
|
||||||
|
*) terms+=("$1"); shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
local query="${terms[*]:-}"
|
||||||
if [[ -z "${query}" ]]; then
|
if [[ -z "${query}" ]]; then
|
||||||
echo "Usage: garc kg query \"<concept>\""
|
echo "Usage: garc kg query \"<concept>\" [--max N]"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -52,33 +86,11 @@ garc_kg_query() {
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
python3 -c "
|
# Pass query via argv to avoid shell injection
|
||||||
import json, sys
|
python3 "${GARC_DIR}/scripts/garc-kg-query.py" query \
|
||||||
query = '${query}'.lower()
|
--cache "${GARC_KG_CACHE}" \
|
||||||
with open('${GARC_KG_CACHE}') as f:
|
--query "${query}" \
|
||||||
kg = json.load(f)
|
--max "${max}"
|
||||||
|
|
||||||
matches = []
|
|
||||||
for node in kg.get('nodes', []):
|
|
||||||
name = node.get('title', '').lower()
|
|
||||||
content = node.get('content_preview', '').lower()
|
|
||||||
if query in name or query in content:
|
|
||||||
matches.append(node)
|
|
||||||
|
|
||||||
if not matches:
|
|
||||||
print(f'No results for: ${query}')
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
print(f'Results for \"{query}\" ({len(matches)} matches):')
|
|
||||||
for m in matches[:10]:
|
|
||||||
print(f' - [{m.get(\"doc_id\",\"\")}] {m.get(\"title\",\"\")}')
|
|
||||||
if m.get('content_preview'):
|
|
||||||
preview = m['content_preview'][:100].replace('\n', ' ')
|
|
||||||
print(f' {preview}...')
|
|
||||||
links = m.get('links', [])
|
|
||||||
if links:
|
|
||||||
print(f' Links: {len(links)} documents')
|
|
||||||
"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# garc kg show <doc_id>
|
# garc kg show <doc_id>
|
||||||
|
|
@ -95,29 +107,7 @@ garc_kg_show() {
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
python3 -c "
|
python3 "${GARC_DIR}/scripts/garc-kg-query.py" show \
|
||||||
import json
|
--cache "${GARC_KG_CACHE}" \
|
||||||
doc_id = '${doc_id}'
|
--doc-id "${doc_id}"
|
||||||
with open('${GARC_KG_CACHE}') as f:
|
|
||||||
kg = json.load(f)
|
|
||||||
|
|
||||||
for node in kg.get('nodes', []):
|
|
||||||
if node.get('doc_id') == doc_id:
|
|
||||||
print(f'Title: {node.get(\"title\", \"\")}')
|
|
||||||
print(f'Doc ID: {doc_id}')
|
|
||||||
print(f'Type: {node.get(\"mime_type\", \"\")}')
|
|
||||||
print(f'Modified: {node.get(\"modified_time\", \"\")}')
|
|
||||||
print()
|
|
||||||
print('Content preview:')
|
|
||||||
print(node.get('content_preview', '(none)'))
|
|
||||||
print()
|
|
||||||
links = node.get('links', [])
|
|
||||||
if links:
|
|
||||||
print(f'Links ({len(links)}):')
|
|
||||||
for link in links:
|
|
||||||
print(f' -> {link}')
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
print(f'Document {doc_id} not found in knowledge graph')
|
|
||||||
"
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
215
lib/profile.sh
Normal file
215
lib/profile.sh
Normal 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."
|
||||||
|
}
|
||||||
69
lib/send.sh
69
lib/send.sh
|
|
@ -3,13 +3,26 @@
|
||||||
# Replaces Lark IM with Gmail or Google Chat
|
# Replaces Lark IM with Gmail or Google Chat
|
||||||
|
|
||||||
garc_send() {
|
garc_send() {
|
||||||
|
local subcommand="${1:-}"
|
||||||
|
|
||||||
|
# Support 'garc send chat ...' / 'garc send email ...' sub-dispatching
|
||||||
|
case "${subcommand}" in
|
||||||
|
chat)
|
||||||
|
shift
|
||||||
|
_garc_send_chat_cmd "$@"
|
||||||
|
return $?
|
||||||
|
;;
|
||||||
|
email)
|
||||||
|
shift
|
||||||
|
;; # fall through to default Gmail path
|
||||||
|
esac
|
||||||
|
|
||||||
local message=""
|
local message=""
|
||||||
local to="${GARC_GMAIL_DEFAULT_TO:-}"
|
local to="${GARC_GMAIL_DEFAULT_TO:-}"
|
||||||
local subject="GARC Agent Notification"
|
local subject="GARC Agent Notification"
|
||||||
local use_chat=false
|
local use_chat=false
|
||||||
local space_id="${GARC_CHAT_SPACE_ID:-}"
|
local space_id="${GARC_CHAT_SPACE_ID:-}"
|
||||||
|
|
||||||
# Parse message (first non-flag argument)
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--to) to="$2"; shift 2 ;;
|
--to) to="$2"; shift 2 ;;
|
||||||
|
|
@ -21,7 +34,15 @@ garc_send() {
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ -z "${message}" ]]; then
|
if [[ -z "${message}" ]]; then
|
||||||
echo "Usage: garc send \"<message>\" [--to <email>] [--chat] [--space <space_id>]"
|
cat <<EOF
|
||||||
|
Usage:
|
||||||
|
garc send "<message>" [--to <email>] [--subject <s>] # Gmail
|
||||||
|
garc send --chat "<message>" [--space <space_id>] # Chat
|
||||||
|
garc send chat <subcommand> # Chat management
|
||||||
|
chat send "<message>" [--space <id>] [--thread <key>]
|
||||||
|
chat list-spaces
|
||||||
|
chat list-messages [--space <id>] [--max N]
|
||||||
|
EOF
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -32,6 +53,44 @@ garc_send() {
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_garc_send_chat_cmd() {
|
||||||
|
local sub="${1:-send}"
|
||||||
|
shift || true
|
||||||
|
local message="" space_id="${GARC_CHAT_SPACE_ID:-}" thread_key="" max=25
|
||||||
|
|
||||||
|
case "${sub}" in
|
||||||
|
list-spaces)
|
||||||
|
python3 "${GARC_DIR}/scripts/garc-chat-helper.py" list-spaces
|
||||||
|
return $?
|
||||||
|
;;
|
||||||
|
list-messages)
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--space) space_id="$2"; shift 2 ;;
|
||||||
|
--max) max="$2"; shift 2 ;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[[ -z "${space_id}" ]] && { echo "Error: --space required"; return 1; }
|
||||||
|
python3 "${GARC_DIR}/scripts/garc-chat-helper.py" list-messages \
|
||||||
|
--space-id "${space_id}" --max "${max}"
|
||||||
|
return $?
|
||||||
|
;;
|
||||||
|
send|*)
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--space) space_id="$2"; shift 2 ;;
|
||||||
|
--thread) thread_key="$2"; shift 2 ;;
|
||||||
|
*) message="${message:+${message} }$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[[ -z "${message}" ]] && { echo "Usage: garc send chat send \"<message>\" [--space <id>]"; return 1; }
|
||||||
|
_garc_send_chat "${message}" "${space_id}" "${thread_key}"
|
||||||
|
return $?
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
_garc_send_gmail() {
|
_garc_send_gmail() {
|
||||||
local message="$1"
|
local message="$1"
|
||||||
local to="$2"
|
local to="$2"
|
||||||
|
|
@ -59,7 +118,8 @@ _garc_send_gmail() {
|
||||||
|
|
||||||
_garc_send_chat() {
|
_garc_send_chat() {
|
||||||
local message="$1"
|
local message="$1"
|
||||||
local space_id="$2"
|
local space_id="${2:-${GARC_CHAT_SPACE_ID:-}}"
|
||||||
|
local thread_key="${3:-}"
|
||||||
|
|
||||||
if [[ -z "${space_id}" ]]; then
|
if [[ -z "${space_id}" ]]; then
|
||||||
echo "Error: No Chat space. Set GARC_CHAT_SPACE_ID or use --space <space_id>" >&2
|
echo "Error: No Chat space. Set GARC_CHAT_SPACE_ID or use --space <space_id>" >&2
|
||||||
|
|
@ -75,5 +135,6 @@ _garc_send_chat() {
|
||||||
|
|
||||||
python3 "${GARC_DIR}/scripts/garc-chat-helper.py" send \
|
python3 "${GARC_DIR}/scripts/garc-chat-helper.py" send \
|
||||||
--space-id "${space_id}" \
|
--space-id "${space_id}" \
|
||||||
--message "${message}"
|
--message "${message}" \
|
||||||
|
${thread_key:+--thread-key "${thread_key}"}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
28
pyproject.toml
Normal file
28
pyproject.toml
Normal 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"]
|
||||||
|
|
@ -2,6 +2,7 @@ google-api-python-client>=2.100.0
|
||||||
google-auth>=2.23.0
|
google-auth>=2.23.0
|
||||||
google-auth-oauthlib>=1.1.0
|
google-auth-oauthlib>=1.1.0
|
||||||
google-auth-httplib2>=0.1.1
|
google-auth-httplib2>=0.1.1
|
||||||
|
requests>=2.31.0
|
||||||
pyyaml>=6.0.1
|
pyyaml>=6.0.1
|
||||||
python-dateutil>=2.8.2
|
python-dateutil>=2.8.2
|
||||||
rich>=13.0.0
|
rich>=13.0.0
|
||||||
|
|
|
||||||
|
|
@ -220,6 +220,79 @@ def show_status():
|
||||||
print(f"Error reading token: {e}")
|
print(f"Error reading token: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_token():
|
||||||
|
"""Revoke the stored OAuth token and delete the token file."""
|
||||||
|
if not TOKEN_FILE.exists():
|
||||||
|
print(f"No token file found at {TOKEN_FILE}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests as req_lib
|
||||||
|
with open(TOKEN_FILE) as f:
|
||||||
|
token_data = json.load(f)
|
||||||
|
|
||||||
|
token = token_data.get("token") or token_data.get("access_token", "")
|
||||||
|
if token:
|
||||||
|
resp = req_lib.post(
|
||||||
|
"https://oauth2.googleapis.com/revoke",
|
||||||
|
params={"token": token},
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
print("✅ Token revoked at Google.")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ Revocation response: {resp.status_code} — {resp.text[:80]}")
|
||||||
|
else:
|
||||||
|
print("⚠️ No access token in token file — skipping remote revocation.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Could not revoke token remotely: {e}")
|
||||||
|
|
||||||
|
TOKEN_FILE.unlink(missing_ok=True)
|
||||||
|
print(f"✅ Deleted: {TOKEN_FILE}")
|
||||||
|
print(" Run 'garc auth login' to re-authenticate.")
|
||||||
|
|
||||||
|
|
||||||
|
def service_account_verify(sa_file: str):
|
||||||
|
"""Verify service account credentials by listing Drive files."""
|
||||||
|
sa_path = Path(sa_file)
|
||||||
|
if not sa_path.exists():
|
||||||
|
print(f"❌ Service account file not found: {sa_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from google.oauth2 import service_account
|
||||||
|
from googleapiclient.discovery import build
|
||||||
|
|
||||||
|
with open(sa_path) as f:
|
||||||
|
sa_data = json.load(f)
|
||||||
|
|
||||||
|
print(f"Service Account: {sa_data.get('client_email', 'N/A')}")
|
||||||
|
print(f"Project: {sa_data.get('project_id', 'N/A')}")
|
||||||
|
|
||||||
|
scopes = ["https://www.googleapis.com/auth/drive.readonly"]
|
||||||
|
creds = service_account.Credentials.from_service_account_file(str(sa_path), scopes=scopes)
|
||||||
|
|
||||||
|
# Check if GARC_IMPERSONATE_EMAIL is set for DWD
|
||||||
|
impersonate = os.environ.get("GARC_IMPERSONATE_EMAIL", "")
|
||||||
|
if impersonate:
|
||||||
|
creds = creds.with_subject(impersonate)
|
||||||
|
print(f"Impersonating: {impersonate}")
|
||||||
|
|
||||||
|
svc = build("drive", "v3", credentials=creds, cache_discovery=False)
|
||||||
|
resp = svc.files().list(pageSize=1, fields="files(id,name)").execute()
|
||||||
|
files = resp.get("files", [])
|
||||||
|
print(f"\n✅ Service account is valid. Drive access confirmed ({len(files)} file(s) visible).")
|
||||||
|
|
||||||
|
if not impersonate:
|
||||||
|
print("\n💡 Tip: For Domain-wide Delegation, set GARC_IMPERSONATE_EMAIL=user@yourdomain.com")
|
||||||
|
print(" Then re-run 'garc auth service-account verify'.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Service account verification failed: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="GARC Auth Helper")
|
parser = argparse.ArgumentParser(description="GARC Auth Helper")
|
||||||
subparsers = parser.add_subparsers(dest="command")
|
subparsers = parser.add_subparsers(dest="command")
|
||||||
|
|
@ -239,6 +312,13 @@ def main():
|
||||||
# status
|
# status
|
||||||
subparsers.add_parser("status", help="Show token status")
|
subparsers.add_parser("status", help="Show token status")
|
||||||
|
|
||||||
|
# revoke
|
||||||
|
subparsers.add_parser("revoke", help="Revoke and delete stored token")
|
||||||
|
|
||||||
|
# service-account-verify
|
||||||
|
sav_parser = subparsers.add_parser("service-account-verify", help="Verify service account credentials")
|
||||||
|
sav_parser.add_argument("--file", required=True, help="Path to service account JSON file")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.command == "suggest":
|
if args.command == "suggest":
|
||||||
|
|
@ -249,6 +329,10 @@ def main():
|
||||||
login(args.profile)
|
login(args.profile)
|
||||||
elif args.command == "status":
|
elif args.command == "status":
|
||||||
show_status()
|
show_status()
|
||||||
|
elif args.command == "revoke":
|
||||||
|
revoke_token()
|
||||||
|
elif args.command == "service-account-verify":
|
||||||
|
service_account_verify(args.file)
|
||||||
else:
|
else:
|
||||||
parser.print_help()
|
parser.print_help()
|
||||||
|
|
||||||
|
|
|
||||||
103
scripts/garc-chat-helper.py
Normal file
103
scripts/garc-chat-helper.py
Normal 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()
|
||||||
|
|
@ -269,12 +269,9 @@ def create_doc(name: str, folder_id: str = "root", content: str = ""):
|
||||||
|
|
||||||
doc_id = result["id"]
|
doc_id = result["id"]
|
||||||
|
|
||||||
# Add initial content if provided
|
# Add initial content if provided via batchUpdate
|
||||||
if content:
|
if content:
|
||||||
svc_docs.documents().batchUpdate(
|
_doc_insert_text(svc_docs, doc_id, content, append=False)
|
||||||
documentId=doc_id,
|
|
||||||
body={"requests": [{"insertText": {"location": {"index": 1}, "text": content}}]}
|
|
||||||
).execute()
|
|
||||||
|
|
||||||
print(f"✅ Doc created: {name}")
|
print(f"✅ Doc created: {name}")
|
||||||
print(f" ID: {doc_id}")
|
print(f" ID: {doc_id}")
|
||||||
|
|
@ -282,6 +279,35 @@ def create_doc(name: str, folder_id: str = "root", content: str = ""):
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _doc_insert_text(svc_docs, doc_id: str, text: str, append: bool = True):
|
||||||
|
"""Insert or append text to an existing Google Doc."""
|
||||||
|
if append:
|
||||||
|
# Get current end index
|
||||||
|
doc = svc_docs.documents().get(documentId=doc_id).execute()
|
||||||
|
body = doc.get("body", {})
|
||||||
|
content_items = body.get("content", [])
|
||||||
|
# End index of doc body is the last structural element's endIndex minus 1
|
||||||
|
end_index = content_items[-1]["endIndex"] - 1 if content_items else 1
|
||||||
|
insert_index = max(end_index, 1)
|
||||||
|
else:
|
||||||
|
insert_index = 1 # beginning of new doc
|
||||||
|
|
||||||
|
svc_docs.documents().batchUpdate(
|
||||||
|
documentId=doc_id,
|
||||||
|
body={"requests": [
|
||||||
|
{"insertText": {"location": {"index": insert_index}, "text": text}}
|
||||||
|
]}
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
|
||||||
|
@with_retry()
|
||||||
|
def append_doc(doc_id: str, content: str):
|
||||||
|
"""Append text to an existing Google Doc."""
|
||||||
|
svc_docs = build_service("docs", "v1")
|
||||||
|
_doc_insert_text(svc_docs, doc_id, content, append=True)
|
||||||
|
print(f"✅ Content appended to doc: {doc_id}")
|
||||||
|
|
||||||
|
|
||||||
@with_retry()
|
@with_retry()
|
||||||
def share_file(file_id: str, email: str, role: str = "reader",
|
def share_file(file_id: str, email: str, role: str = "reader",
|
||||||
send_notification: bool = True):
|
send_notification: bool = True):
|
||||||
|
|
@ -355,12 +381,21 @@ def kg_build(folder_id: str, output: str, depth: int = 3):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
q = f"'{fid}' in parents and trashed = false"
|
q = f"'{fid}' in parents and trashed = false"
|
||||||
results = svc.files().list(
|
files = []
|
||||||
q=q, pageSize=50,
|
page_token = None
|
||||||
fields="files(id,name,mimeType,modifiedTime,webViewLink)"
|
while True:
|
||||||
).execute()
|
kwargs: dict = dict(
|
||||||
files = results.get("files", [])
|
q=q, pageSize=100,
|
||||||
except Exception as e:
|
fields="nextPageToken,files(id,name,mimeType,modifiedTime,webViewLink)"
|
||||||
|
)
|
||||||
|
if page_token:
|
||||||
|
kwargs["pageToken"] = page_token
|
||||||
|
results = svc.files().list(**kwargs).execute()
|
||||||
|
files.extend(results.get("files", []))
|
||||||
|
page_token = results.get("nextPageToken")
|
||||||
|
if not page_token:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
return
|
return
|
||||||
|
|
||||||
for f in files:
|
for f in files:
|
||||||
|
|
@ -467,6 +502,11 @@ def main():
|
||||||
cd.add_argument("--folder-id", default="root")
|
cd.add_argument("--folder-id", default="root")
|
||||||
cd.add_argument("--content", default="")
|
cd.add_argument("--content", default="")
|
||||||
|
|
||||||
|
# append-doc
|
||||||
|
adp = sub.add_parser("append-doc", help="Append text to an existing Google Doc")
|
||||||
|
adp.add_argument("doc_id", help="Document ID")
|
||||||
|
adp.add_argument("--content", required=True, help="Text to append")
|
||||||
|
|
||||||
# share
|
# share
|
||||||
sh = sub.add_parser("share", help="Share file")
|
sh = sub.add_parser("share", help="Share file")
|
||||||
sh.add_argument("file_id")
|
sh.add_argument("file_id")
|
||||||
|
|
@ -506,6 +546,8 @@ def main():
|
||||||
create_folder(args.name, args.parent_id)
|
create_folder(args.name, args.parent_id)
|
||||||
elif args.command == "create-doc":
|
elif args.command == "create-doc":
|
||||||
create_doc(args.name, args.folder_id, args.content)
|
create_doc(args.name, args.folder_id, args.content)
|
||||||
|
elif args.command == "append-doc":
|
||||||
|
append_doc(args.doc_id, args.content)
|
||||||
elif args.command == "share":
|
elif args.command == "share":
|
||||||
share_file(args.file_id, args.email, args.role, not args.no_notify)
|
share_file(args.file_id, args.email, args.role, not args.no_notify)
|
||||||
elif args.command == "move":
|
elif args.command == "move":
|
||||||
|
|
|
||||||
179
scripts/garc-forms-helper.py
Normal file
179
scripts/garc-forms-helper.py
Normal 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
122
scripts/garc-kg-query.py
Normal 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()
|
||||||
|
|
@ -22,6 +22,7 @@ SHEET_APPROVAL = "approval"
|
||||||
SHEET_TASKS_LOG = "tasks_log"
|
SHEET_TASKS_LOG = "tasks_log"
|
||||||
SHEET_EMAIL_LOG = "email_log"
|
SHEET_EMAIL_LOG = "email_log"
|
||||||
SHEET_CALENDAR_LOG = "calendar_log"
|
SHEET_CALENDAR_LOG = "calendar_log"
|
||||||
|
SHEET_AUDIT = "audit"
|
||||||
|
|
||||||
|
|
||||||
def get_svc():
|
def get_svc():
|
||||||
|
|
@ -168,6 +169,80 @@ def clear_range(sheets_id: str, range_: str):
|
||||||
print(f"✅ Cleared: {range_}")
|
print(f"✅ Cleared: {range_}")
|
||||||
|
|
||||||
|
|
||||||
|
def trim_sheet(sheets_id: str, sheet_name: str) -> int:
|
||||||
|
"""Delete trailing empty rows from a sheet. Returns number of rows deleted."""
|
||||||
|
svc = get_svc()
|
||||||
|
|
||||||
|
# Get all data to find last non-empty row
|
||||||
|
result = svc.spreadsheets().values().get(
|
||||||
|
spreadsheetId=sheets_id, range=f"{sheet_name}!A:Z",
|
||||||
|
valueRenderOption="UNFORMATTED_VALUE"
|
||||||
|
).execute()
|
||||||
|
rows = result.get("values", [])
|
||||||
|
|
||||||
|
# Find last row index with any data (0-indexed)
|
||||||
|
last_data_row = -1
|
||||||
|
for i, row in enumerate(rows):
|
||||||
|
if any(str(cell).strip() for cell in row):
|
||||||
|
last_data_row = i
|
||||||
|
|
||||||
|
# Get sheet metadata for sheetId and total row count
|
||||||
|
meta = svc.spreadsheets().get(spreadsheetId=sheets_id).execute()
|
||||||
|
sheet_meta = next(
|
||||||
|
(s for s in meta.get("sheets", []) if s["properties"]["title"] == sheet_name),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
if not sheet_meta:
|
||||||
|
print(f"Sheet '{sheet_name}' not found.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
sheet_id = sheet_meta["properties"]["sheetId"]
|
||||||
|
total_rows = sheet_meta["properties"]["gridProperties"]["rowCount"]
|
||||||
|
first_empty = last_data_row + 1 # 0-indexed row after last data
|
||||||
|
|
||||||
|
# Keep at least 1 buffer row after data (avoid deleting to the bone)
|
||||||
|
delete_from = first_empty + 1
|
||||||
|
rows_to_delete = total_rows - delete_from
|
||||||
|
|
||||||
|
if rows_to_delete <= 0:
|
||||||
|
print(f" {sheet_name}: no trailing empty rows to remove.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
svc.spreadsheets().batchUpdate(
|
||||||
|
spreadsheetId=sheets_id,
|
||||||
|
body={
|
||||||
|
"requests": [{
|
||||||
|
"deleteDimension": {
|
||||||
|
"range": {
|
||||||
|
"sheetId": sheet_id,
|
||||||
|
"dimension": "ROWS",
|
||||||
|
"startIndex": delete_from,
|
||||||
|
"endIndex": total_rows,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
print(f" ✅ {sheet_name}: deleted {rows_to_delete} empty rows "
|
||||||
|
f"(kept rows 1–{delete_from})")
|
||||||
|
return rows_to_delete
|
||||||
|
|
||||||
|
|
||||||
|
def clean_all_sheets(sheets_id: str):
|
||||||
|
"""Trim trailing empty rows from all GARC-managed sheets."""
|
||||||
|
sheets = [SHEET_MEMORY, SHEET_AGENTS, SHEET_QUEUE, SHEET_HEARTBEAT,
|
||||||
|
SHEET_APPROVAL, SHEET_TASKS_LOG, SHEET_EMAIL_LOG, SHEET_CALENDAR_LOG]
|
||||||
|
total = 0
|
||||||
|
for name in sheets:
|
||||||
|
try:
|
||||||
|
deleted = trim_sheet(sheets_id, name)
|
||||||
|
total += deleted
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ {name}: {e}")
|
||||||
|
print(f"\nTotal rows removed: {total}")
|
||||||
|
|
||||||
|
|
||||||
# ─── GARC-specific operations ─────────────────────────────────────────────────
|
# ─── GARC-specific operations ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@with_retry()
|
@with_retry()
|
||||||
|
|
@ -227,20 +302,58 @@ def agent_register(sheets_id: str, yaml_file: str):
|
||||||
return
|
return
|
||||||
|
|
||||||
agents = config.get("agents", [])
|
agents = config.get("agents", [])
|
||||||
|
if not agents:
|
||||||
|
print("No agents defined in YAML.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fetch existing rows to detect duplicates
|
||||||
|
svc = get_svc()
|
||||||
|
result = svc.spreadsheets().values().get(
|
||||||
|
spreadsheetId=sheets_id, range=f"{SHEET_AGENTS}!A:H"
|
||||||
|
).execute()
|
||||||
|
existing_rows = result.get("values", [])
|
||||||
|
# Build map: agent_id -> row_number (1-indexed, row 1 = header)
|
||||||
|
existing_ids: dict[str, int] = {}
|
||||||
|
for i, row in enumerate(existing_rows):
|
||||||
|
if i == 0:
|
||||||
|
continue # skip header
|
||||||
|
if row and row[0]:
|
||||||
|
existing_ids[row[0]] = i + 1 # 1-indexed sheet row
|
||||||
|
|
||||||
ts = utc_now()
|
ts = utc_now()
|
||||||
|
added = updated = 0
|
||||||
for agent in agents:
|
for agent in agents:
|
||||||
|
agent_id = agent.get("id", "")
|
||||||
|
if not agent_id:
|
||||||
|
continue
|
||||||
scopes = ",".join(agent.get("scopes", []))
|
scopes = ",".join(agent.get("scopes", []))
|
||||||
append_row(sheets_id, SHEET_AGENTS, [
|
row_values = [
|
||||||
agent.get("id", ""),
|
agent_id,
|
||||||
agent.get("model", ""),
|
agent.get("model", ""),
|
||||||
scopes,
|
scopes,
|
||||||
agent.get("description", ""),
|
agent.get("description", ""),
|
||||||
agent.get("profile", ""),
|
agent.get("profile", ""),
|
||||||
"active",
|
"active",
|
||||||
agent.get("drive_folder", ""),
|
agent.get("drive_folder", ""),
|
||||||
ts
|
ts,
|
||||||
])
|
]
|
||||||
print(f"✅ Registered {len(agents)} agents")
|
if agent_id in existing_ids:
|
||||||
|
# Update existing row in-place
|
||||||
|
row_num = existing_ids[agent_id]
|
||||||
|
svc.spreadsheets().values().update(
|
||||||
|
spreadsheetId=sheets_id,
|
||||||
|
range=f"{SHEET_AGENTS}!A{row_num}:H{row_num}",
|
||||||
|
valueInputOption="USER_ENTERED",
|
||||||
|
body={"values": [row_values]},
|
||||||
|
).execute()
|
||||||
|
print(f" ↻ Updated: {agent_id} (row {row_num})")
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
append_row(sheets_id, SHEET_AGENTS, row_values)
|
||||||
|
print(f" ✅ Registered: {agent_id}")
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
print(f"\nDone — {added} added, {updated} updated (no duplicates created)")
|
||||||
|
|
||||||
|
|
||||||
@with_retry()
|
@with_retry()
|
||||||
|
|
@ -301,6 +414,57 @@ def approval_act(sheets_id: str, approval_id: str, action: str, timestamp: str):
|
||||||
print(f"Approval not found: {approval_id}")
|
print(f"Approval not found: {approval_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def audit_append(sheets_id: str, agent_id: str, command: str, args_str: str,
|
||||||
|
result: str, user: str, timestamp: str):
|
||||||
|
"""Append an audit event row."""
|
||||||
|
append_row(sheets_id, SHEET_AUDIT, [
|
||||||
|
timestamp, agent_id, user, command, args_str, result
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def audit_list(sheets_id: str, agent_id: str = "", since: str = "",
|
||||||
|
output_format: str = "table"):
|
||||||
|
"""List audit events, optionally filtered by agent or date."""
|
||||||
|
svc = get_svc()
|
||||||
|
result = svc.spreadsheets().values().get(
|
||||||
|
spreadsheetId=sheets_id, range=f"{SHEET_AUDIT}!A:F"
|
||||||
|
).execute()
|
||||||
|
rows = result.get("values", [])
|
||||||
|
if not rows:
|
||||||
|
print("(no audit events)")
|
||||||
|
return
|
||||||
|
|
||||||
|
headers = rows[0] if rows else []
|
||||||
|
data = rows[1:]
|
||||||
|
|
||||||
|
# Filter
|
||||||
|
if agent_id:
|
||||||
|
data = [r for r in data if len(r) > 1 and r[1] == agent_id]
|
||||||
|
if since:
|
||||||
|
data = [r for r in data if r and r[0] >= since]
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
print("No audit events match the filter.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if output_format == "json":
|
||||||
|
records = [dict(zip(headers, r + [""] * (len(headers) - len(r)))) for r in data]
|
||||||
|
print(json.dumps(records, ensure_ascii=False, indent=2))
|
||||||
|
else:
|
||||||
|
widths = [min(max(len(str(headers[i])) if i < len(headers) else 0,
|
||||||
|
max((len(str(r[i] if i < len(r) else "")) for r in data), default=0)), 30)
|
||||||
|
for i in range(len(headers))]
|
||||||
|
header_line = " ".join(str(headers[i] if i < len(headers) else "").ljust(widths[i])
|
||||||
|
for i in range(len(headers)))
|
||||||
|
print(header_line)
|
||||||
|
print(" ".join("─" * w for w in widths))
|
||||||
|
for row in data[-50:]: # last 50 rows
|
||||||
|
print(" ".join(str(row[i] if i < len(row) else "").ljust(widths[i])[:widths[i]]
|
||||||
|
for i in range(len(headers))))
|
||||||
|
if len(data) > 50:
|
||||||
|
print(f" ... ({len(data) - 50} older events not shown)")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="GARC Sheets Helper")
|
parser = argparse.ArgumentParser(description="GARC Sheets Helper")
|
||||||
sub = parser.add_subparsers(dest="command")
|
sub = parser.add_subparsers(dest="command")
|
||||||
|
|
@ -335,6 +499,28 @@ def main():
|
||||||
clp.add_argument("--sheets-id", required=True)
|
clp.add_argument("--sheets-id", required=True)
|
||||||
clp.add_argument("--range", required=True, dest="range_")
|
clp.add_argument("--range", required=True, dest="range_")
|
||||||
|
|
||||||
|
trp = sub.add_parser("trim-sheet", help="Delete trailing empty rows from a sheet")
|
||||||
|
trp.add_argument("--sheets-id", required=True)
|
||||||
|
trp.add_argument("--sheet", required=True)
|
||||||
|
|
||||||
|
cap = sub.add_parser("clean-all", help="Trim all GARC-managed sheets")
|
||||||
|
cap.add_argument("--sheets-id", required=True)
|
||||||
|
|
||||||
|
aap = sub.add_parser("audit-append", help="Append an audit event")
|
||||||
|
aap.add_argument("--sheets-id", required=True)
|
||||||
|
aap.add_argument("--agent-id", default="")
|
||||||
|
aap.add_argument("--cmd", required=True, dest="cmd_name")
|
||||||
|
aap.add_argument("--args", default="", dest="args_str")
|
||||||
|
aap.add_argument("--result", default="ok")
|
||||||
|
aap.add_argument("--user", default="")
|
||||||
|
aap.add_argument("--timestamp", default="")
|
||||||
|
|
||||||
|
alp = sub.add_parser("audit-list", help="List audit events")
|
||||||
|
alp.add_argument("--sheets-id", required=True)
|
||||||
|
alp.add_argument("--agent-id", default="")
|
||||||
|
alp.add_argument("--since", default="", help="ISO date filter (YYYY-MM-DD)")
|
||||||
|
alp.add_argument("--format", default="table", choices=["table", "json"])
|
||||||
|
|
||||||
# GARC-specific
|
# GARC-specific
|
||||||
mpl = sub.add_parser("memory-pull")
|
mpl = sub.add_parser("memory-pull")
|
||||||
mpl.add_argument("--sheets-id", required=True)
|
mpl.add_argument("--sheets-id", required=True)
|
||||||
|
|
@ -401,6 +587,16 @@ def main():
|
||||||
get_sheet_info(args.sheets_id)
|
get_sheet_info(args.sheets_id)
|
||||||
elif args.command == "clear":
|
elif args.command == "clear":
|
||||||
clear_range(args.sheets_id, args.range_)
|
clear_range(args.sheets_id, args.range_)
|
||||||
|
elif args.command == "trim-sheet":
|
||||||
|
trim_sheet(args.sheets_id, args.sheet)
|
||||||
|
elif args.command == "clean-all":
|
||||||
|
clean_all_sheets(args.sheets_id)
|
||||||
|
elif args.command == "audit-append":
|
||||||
|
ts = args.timestamp or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
audit_append(args.sheets_id, args.agent_id, args.cmd_name,
|
||||||
|
args.args_str, args.result, args.user, ts)
|
||||||
|
elif args.command == "audit-list":
|
||||||
|
audit_list(args.sheets_id, args.agent_id, args.since, args.format)
|
||||||
elif args.command == "memory-pull":
|
elif args.command == "memory-pull":
|
||||||
memory_pull(args.sheets_id, args.agent_id, args.output)
|
memory_pull(args.sheets_id, args.agent_id, args.output)
|
||||||
elif args.command == "memory-push":
|
elif args.command == "memory-push":
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,19 @@ import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
import warnings
|
||||||
import functools
|
import functools
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
|
|
||||||
|
# Suppress noisy deprecation warnings from urllib3 / requests bundled
|
||||||
|
# inside google-auth and google-api-python-client on Python 3.12+
|
||||||
|
warnings.filterwarnings("ignore", message=".*urllib3.*", category=DeprecationWarning)
|
||||||
|
warnings.filterwarnings("ignore", message=".*ssl.wrap_socket.*", category=DeprecationWarning)
|
||||||
|
warnings.filterwarnings("ignore", message=".*imp module.*", category=DeprecationWarning)
|
||||||
|
warnings.filterwarnings("ignore", category=DeprecationWarning, module="googleapiclient")
|
||||||
|
|
||||||
GARC_CONFIG_DIR = Path(os.environ.get("GARC_CONFIG_DIR", Path.home() / ".garc"))
|
GARC_CONFIG_DIR = Path(os.environ.get("GARC_CONFIG_DIR", Path.home() / ".garc"))
|
||||||
TOKEN_FILE = Path(os.environ.get("GARC_TOKEN_FILE", GARC_CONFIG_DIR / "token.json"))
|
TOKEN_FILE = Path(os.environ.get("GARC_TOKEN_FILE", GARC_CONFIG_DIR / "token.json"))
|
||||||
CREDENTIALS_FILE = Path(os.environ.get("GARC_CREDENTIALS_FILE", GARC_CONFIG_DIR / "credentials.json"))
|
CREDENTIALS_FILE = Path(os.environ.get("GARC_CREDENTIALS_FILE", GARC_CONFIG_DIR / "credentials.json"))
|
||||||
|
|
@ -82,21 +90,36 @@ def get_credentials(scopes: Optional[list] = None, use_service_account: bool = F
|
||||||
try:
|
try:
|
||||||
from google.oauth2.credentials import Credentials
|
from google.oauth2.credentials import Credentials
|
||||||
from google.auth.transport.requests import Request
|
from google.auth.transport.requests import Request
|
||||||
|
from google.auth.exceptions import RefreshError
|
||||||
|
|
||||||
creds = None
|
creds = None
|
||||||
if TOKEN_FILE.exists():
|
if TOKEN_FILE.exists():
|
||||||
creds = Credentials.from_authorized_user_file(str(TOKEN_FILE), scopes)
|
creds = Credentials.from_authorized_user_file(str(TOKEN_FILE), scopes)
|
||||||
|
|
||||||
if creds and creds.valid:
|
if creds and creds.valid:
|
||||||
|
# Verify the token covers the requested scopes
|
||||||
|
if _scopes_covered(creds, scopes):
|
||||||
return creds
|
return creds
|
||||||
|
# Scope mismatch — need re-auth
|
||||||
|
print("⚠️ Token scopes insufficient for requested operation.", file=sys.stderr)
|
||||||
|
print(" Re-authenticating to add required scopes...", file=sys.stderr)
|
||||||
|
creds = None
|
||||||
|
|
||||||
if creds and creds.expired and creds.refresh_token:
|
if creds and creds.expired and creds.refresh_token:
|
||||||
try:
|
try:
|
||||||
creds.refresh(Request())
|
creds.refresh(Request())
|
||||||
_save_token(creds)
|
_save_token(creds)
|
||||||
return creds
|
return creds
|
||||||
|
except RefreshError as e:
|
||||||
|
# Token revoked or expired beyond refresh — delete and re-auth
|
||||||
|
print(f"⚠️ Token refresh failed (revoked or expired): {e}", file=sys.stderr)
|
||||||
|
print(" Deleting stale token. You will be prompted to log in again.", file=sys.stderr)
|
||||||
|
TOKEN_FILE.unlink(missing_ok=True)
|
||||||
|
creds = None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"⚠️ Token refresh failed: {e}", file=sys.stderr)
|
print(f"⚠️ Token refresh error: {e}", file=sys.stderr)
|
||||||
|
TOKEN_FILE.unlink(missing_ok=True)
|
||||||
|
creds = None
|
||||||
|
|
||||||
# Need fresh OAuth flow
|
# Need fresh OAuth flow
|
||||||
if not CREDENTIALS_FILE.exists():
|
if not CREDENTIALS_FILE.exists():
|
||||||
|
|
@ -117,6 +140,17 @@ def get_credentials(scopes: Optional[list] = None, use_service_account: bool = F
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _scopes_covered(creds, requested_scopes: list) -> bool:
|
||||||
|
"""Return True if the credential's granted scopes cover all requested scopes."""
|
||||||
|
if not requested_scopes:
|
||||||
|
return True
|
||||||
|
granted = set(getattr(creds, "scopes", None) or [])
|
||||||
|
if not granted:
|
||||||
|
# Token file may not carry scope info — assume OK to avoid spurious re-auth
|
||||||
|
return True
|
||||||
|
return all(s in granted for s in requested_scopes)
|
||||||
|
|
||||||
|
|
||||||
def _save_token(creds):
|
def _save_token(creds):
|
||||||
"""Save credentials to token file."""
|
"""Save credentials to token file."""
|
||||||
GARC_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
GARC_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue