Initial commit: 25 scripts Python sauvegardés
Date: 2026-08-28 11:43:34 EDT
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
"""Resolve HERMES_HOME for standalone skill scripts.
|
||||
|
||||
Skill scripts may run outside the Hermes process (e.g. system Python,
|
||||
nix env, CI) where ``hermes_constants`` is not importable. This module
|
||||
provides the same ``get_hermes_home()`` and ``display_hermes_home()``
|
||||
contracts as ``hermes_constants`` without requiring it on ``sys.path``.
|
||||
|
||||
When ``hermes_constants`` IS available it is used directly so that any
|
||||
future enhancements (profile resolution, Docker detection, etc.) are
|
||||
picked up automatically. The fallback path replicates the core logic
|
||||
from ``hermes_constants.py`` using only the stdlib.
|
||||
|
||||
All scripts under ``google-workspace/scripts/`` should import from here
|
||||
instead of duplicating the ``HERMES_HOME = Path(os.getenv(...))`` pattern.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from hermes_constants import display_hermes_home as display_hermes_home
|
||||
from hermes_constants import get_hermes_home as get_hermes_home
|
||||
except (ModuleNotFoundError, ImportError):
|
||||
|
||||
def get_hermes_home() -> Path:
|
||||
"""Return the Hermes home directory (default: ~/.hermes).
|
||||
|
||||
Mirrors ``hermes_constants.get_hermes_home()``."""
|
||||
val = os.environ.get("HERMES_HOME", "").strip()
|
||||
return Path(val) if val else Path.home() / ".hermes"
|
||||
|
||||
def display_hermes_home() -> str:
|
||||
"""Return a user-friendly ``~/``-shortened display string.
|
||||
|
||||
Mirrors ``hermes_constants.display_hermes_home()``."""
|
||||
home = get_hermes_home()
|
||||
try:
|
||||
return "~/" + str(home.relative_to(Path.home()))
|
||||
except ValueError:
|
||||
return str(home)
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract text from documents using pymupdf. Lightweight (~25MB), no models.
|
||||
|
||||
Usage:
|
||||
python extract_pymupdf.py document.pdf
|
||||
python extract_pymupdf.py document.pdf --markdown
|
||||
python extract_pymupdf.py document.pdf --pages 0-4
|
||||
python extract_pymupdf.py document.pdf --images output_dir/
|
||||
python extract_pymupdf.py document.pdf --tables
|
||||
python extract_pymupdf.py document.pdf --metadata
|
||||
"""
|
||||
import sys
|
||||
import json
|
||||
|
||||
def extract_text(path, pages=None):
|
||||
import pymupdf
|
||||
doc = pymupdf.open(path)
|
||||
page_range = range(len(doc)) if pages is None else pages
|
||||
for i in page_range:
|
||||
if i < len(doc):
|
||||
print(f"\n--- Page {i+1}/{len(doc)} ---\n")
|
||||
print(doc[i].get_text())
|
||||
|
||||
def extract_markdown(path, pages=None):
|
||||
import pymupdf4llm
|
||||
md = pymupdf4llm.to_markdown(path, pages=pages)
|
||||
print(md)
|
||||
|
||||
def extract_tables(path):
|
||||
import pymupdf
|
||||
doc = pymupdf.open(path)
|
||||
for i, page in enumerate(doc):
|
||||
tables = page.find_tables()
|
||||
for j, table in enumerate(tables.tables):
|
||||
print(f"\n--- Page {i+1}, Table {j+1} ---\n")
|
||||
df = table.to_pandas()
|
||||
print(df.to_markdown(index=False))
|
||||
|
||||
def extract_images(path, output_dir):
|
||||
import pymupdf
|
||||
from pathlib import Path
|
||||
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||
doc = pymupdf.open(path)
|
||||
count = 0
|
||||
for i, page in enumerate(doc):
|
||||
for img_idx, img in enumerate(page.get_images(full=True)):
|
||||
xref = img[0]
|
||||
pix = pymupdf.Pixmap(doc, xref)
|
||||
if pix.n >= 5:
|
||||
pix = pymupdf.Pixmap(pymupdf.csRGB, pix)
|
||||
out_path = f"{output_dir}/page{i+1}_img{img_idx+1}.png"
|
||||
pix.save(out_path)
|
||||
count += 1
|
||||
print(f"Extracted {count} images to {output_dir}/")
|
||||
|
||||
def show_metadata(path):
|
||||
import pymupdf
|
||||
doc = pymupdf.open(path)
|
||||
print(json.dumps({
|
||||
"pages": len(doc),
|
||||
"title": doc.metadata.get("title", ""),
|
||||
"author": doc.metadata.get("author", ""),
|
||||
"subject": doc.metadata.get("subject", ""),
|
||||
"creator": doc.metadata.get("creator", ""),
|
||||
"producer": doc.metadata.get("producer", ""),
|
||||
"format": doc.metadata.get("format", ""),
|
||||
}, indent=2))
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = sys.argv[1:]
|
||||
if not args or args[0] in {"-h", "--help"}:
|
||||
print(__doc__)
|
||||
sys.exit(0)
|
||||
|
||||
path = args[0]
|
||||
pages = None
|
||||
|
||||
if "--pages" in args:
|
||||
idx = args.index("--pages")
|
||||
p = args[idx + 1]
|
||||
if "-" in p:
|
||||
start, end = p.split("-")
|
||||
pages = list(range(int(start), int(end) + 1))
|
||||
else:
|
||||
pages = [int(p)]
|
||||
|
||||
if "--metadata" in args:
|
||||
show_metadata(path)
|
||||
elif "--tables" in args:
|
||||
extract_tables(path)
|
||||
elif "--images" in args:
|
||||
idx = args.index("--images")
|
||||
output_dir = args[idx + 1] if idx + 1 < len(args) else "./images"
|
||||
extract_images(path, output_dir)
|
||||
elif "--markdown" in args:
|
||||
extract_markdown(path, pages=pages)
|
||||
else:
|
||||
extract_text(path, pages=pages)
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Add a new slide to an unpacked PPTX directory.
|
||||
|
||||
Usage: python add_slide.py <unpacked_dir> <source>
|
||||
|
||||
The source can be:
|
||||
- A slide file (e.g., slide2.xml) - duplicates the slide
|
||||
- A layout file (e.g., slideLayout2.xml) - creates from layout
|
||||
|
||||
Examples:
|
||||
python add_slide.py unpacked/ slide2.xml
|
||||
# Duplicates slide2, creates slide5.xml
|
||||
|
||||
python add_slide.py unpacked/ slideLayout2.xml
|
||||
# Creates slide5.xml from slideLayout2.xml
|
||||
|
||||
To see available layouts: ls unpacked/ppt/slideLayouts/
|
||||
|
||||
Prints the <p:sldId> element to add to presentation.xml.
|
||||
"""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_next_slide_number(slides_dir: Path) -> int:
|
||||
existing = [int(m.group(1)) for f in slides_dir.glob("slide*.xml")
|
||||
if (m := re.match(r"slide(\d+)\.xml", f.name))]
|
||||
return max(existing) + 1 if existing else 1
|
||||
|
||||
|
||||
def create_slide_from_layout(unpacked_dir: Path, layout_file: str) -> None:
|
||||
slides_dir = unpacked_dir / "ppt" / "slides"
|
||||
rels_dir = slides_dir / "_rels"
|
||||
layouts_dir = unpacked_dir / "ppt" / "slideLayouts"
|
||||
|
||||
layout_path = layouts_dir / layout_file
|
||||
if not layout_path.exists():
|
||||
print(f"Error: {layout_path} not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
next_num = get_next_slide_number(slides_dir)
|
||||
dest = f"slide{next_num}.xml"
|
||||
dest_slide = slides_dir / dest
|
||||
dest_rels = rels_dir / f"{dest}.rels"
|
||||
|
||||
slide_xml = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
|
||||
<p:cSld>
|
||||
<p:spTree>
|
||||
<p:nvGrpSpPr>
|
||||
<p:cNvPr id="1" name=""/>
|
||||
<p:cNvGrpSpPr/>
|
||||
<p:nvPr/>
|
||||
</p:nvGrpSpPr>
|
||||
<p:grpSpPr>
|
||||
<a:xfrm>
|
||||
<a:off x="0" y="0"/>
|
||||
<a:ext cx="0" cy="0"/>
|
||||
<a:chOff x="0" y="0"/>
|
||||
<a:chExt cx="0" cy="0"/>
|
||||
</a:xfrm>
|
||||
</p:grpSpPr>
|
||||
</p:spTree>
|
||||
</p:cSld>
|
||||
<p:clrMapOvr>
|
||||
<a:masterClrMapping/>
|
||||
</p:clrMapOvr>
|
||||
</p:sld>'''
|
||||
dest_slide.write_text(slide_xml, encoding="utf-8")
|
||||
|
||||
rels_dir.mkdir(exist_ok=True)
|
||||
rels_xml = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/{layout_file}"/>
|
||||
</Relationships>'''
|
||||
dest_rels.write_text(rels_xml, encoding="utf-8")
|
||||
|
||||
_add_to_content_types(unpacked_dir, dest)
|
||||
|
||||
rid = _add_to_presentation_rels(unpacked_dir, dest)
|
||||
|
||||
next_slide_id = _get_next_slide_id(unpacked_dir)
|
||||
|
||||
print(f"Created {dest} from {layout_file}")
|
||||
print(f'Add to presentation.xml <p:sldIdLst>: <p:sldId id="{next_slide_id}" r:id="{rid}"/>')
|
||||
|
||||
|
||||
def duplicate_slide(unpacked_dir: Path, source: str) -> None:
|
||||
slides_dir = unpacked_dir / "ppt" / "slides"
|
||||
rels_dir = slides_dir / "_rels"
|
||||
|
||||
source_slide = slides_dir / source
|
||||
|
||||
if not source_slide.exists():
|
||||
print(f"Error: {source_slide} not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
next_num = get_next_slide_number(slides_dir)
|
||||
dest = f"slide{next_num}.xml"
|
||||
dest_slide = slides_dir / dest
|
||||
|
||||
source_rels = rels_dir / f"{source}.rels"
|
||||
dest_rels = rels_dir / f"{dest}.rels"
|
||||
|
||||
shutil.copy2(source_slide, dest_slide)
|
||||
|
||||
if source_rels.exists():
|
||||
shutil.copy2(source_rels, dest_rels)
|
||||
|
||||
rels_content = dest_rels.read_text(encoding="utf-8")
|
||||
rels_content = re.sub(
|
||||
r'\s*<Relationship[^>]*Type="[^"]*notesSlide"[^>]*/>\s*',
|
||||
"\n",
|
||||
rels_content,
|
||||
)
|
||||
dest_rels.write_text(rels_content, encoding="utf-8")
|
||||
|
||||
_add_to_content_types(unpacked_dir, dest)
|
||||
|
||||
rid = _add_to_presentation_rels(unpacked_dir, dest)
|
||||
|
||||
next_slide_id = _get_next_slide_id(unpacked_dir)
|
||||
|
||||
print(f"Created {dest} from {source}")
|
||||
print(f'Add to presentation.xml <p:sldIdLst>: <p:sldId id="{next_slide_id}" r:id="{rid}"/>')
|
||||
|
||||
|
||||
def _add_to_content_types(unpacked_dir: Path, dest: str) -> None:
|
||||
content_types_path = unpacked_dir / "[Content_Types].xml"
|
||||
content_types = content_types_path.read_text(encoding="utf-8")
|
||||
|
||||
new_override = f'<Override PartName="/ppt/slides/{dest}" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>'
|
||||
|
||||
if f"/ppt/slides/{dest}" not in content_types:
|
||||
content_types = content_types.replace("</Types>", f" {new_override}\n</Types>")
|
||||
content_types_path.write_text(content_types, encoding="utf-8")
|
||||
|
||||
|
||||
def _add_to_presentation_rels(unpacked_dir: Path, dest: str) -> str:
|
||||
pres_rels_path = unpacked_dir / "ppt" / "_rels" / "presentation.xml.rels"
|
||||
pres_rels = pres_rels_path.read_text(encoding="utf-8")
|
||||
|
||||
rids = [int(m) for m in re.findall(r'Id="rId(\d+)"', pres_rels)]
|
||||
next_rid = max(rids) + 1 if rids else 1
|
||||
rid = f"rId{next_rid}"
|
||||
|
||||
new_rel = f'<Relationship Id="{rid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/{dest}"/>'
|
||||
|
||||
if f"slides/{dest}" not in pres_rels:
|
||||
pres_rels = pres_rels.replace("</Relationships>", f" {new_rel}\n</Relationships>")
|
||||
pres_rels_path.write_text(pres_rels, encoding="utf-8")
|
||||
|
||||
return rid
|
||||
|
||||
|
||||
def _get_next_slide_id(unpacked_dir: Path) -> int:
|
||||
pres_path = unpacked_dir / "ppt" / "presentation.xml"
|
||||
pres_content = pres_path.read_text(encoding="utf-8")
|
||||
slide_ids = [int(m) for m in re.findall(r'<p:sldId[^>]*id="(\d+)"', pres_content)]
|
||||
return max(slide_ids) + 1 if slide_ids else 256
|
||||
|
||||
|
||||
def parse_source(source: str) -> tuple[str, str | None]:
|
||||
if source.startswith("slideLayout") and source.endswith(".xml"):
|
||||
return ("layout", source)
|
||||
|
||||
return ("slide", None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 3:
|
||||
print("Usage: python add_slide.py <unpacked_dir> <source>", file=sys.stderr)
|
||||
print("", file=sys.stderr)
|
||||
print("Source can be:", file=sys.stderr)
|
||||
print(" slide2.xml - duplicate an existing slide", file=sys.stderr)
|
||||
print(" slideLayout2.xml - create from a layout template", file=sys.stderr)
|
||||
print("", file=sys.stderr)
|
||||
print("To see available layouts: ls <unpacked_dir>/ppt/slideLayouts/", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
unpacked_dir = Path(sys.argv[1])
|
||||
source = sys.argv[2]
|
||||
|
||||
if not unpacked_dir.exists():
|
||||
print(f"Error: {unpacked_dir} not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
source_type, layout_file = parse_source(source)
|
||||
|
||||
if source_type == "layout" and layout_file is not None:
|
||||
create_slide_from_layout(unpacked_dir, layout_file)
|
||||
else:
|
||||
duplicate_slide(unpacked_dir, source)
|
||||
@@ -0,0 +1,481 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Google Workspace OAuth2 setup for Hermes Agent.
|
||||
|
||||
Fully non-interactive — designed to be driven by the agent via terminal commands.
|
||||
The agent mediates between this script and the user (works on CLI, Telegram, Discord, etc.)
|
||||
|
||||
Commands:
|
||||
setup.py --check # Is auth valid? Exit 0 = yes, 1 = no
|
||||
setup.py --client-secret /path/to.json # Store OAuth client credentials
|
||||
setup.py --auth-url # Print the OAuth URL for user to visit
|
||||
setup.py --auth-code CODE # Exchange auth code for token
|
||||
setup.py --revoke # Revoke and delete stored token
|
||||
setup.py --install-deps # Install Python dependencies only
|
||||
|
||||
Agent workflow:
|
||||
1. Run --check. If exit 0, auth is good — skip setup.
|
||||
2. Ask user for client_secret.json path. Run --client-secret PATH.
|
||||
3. Run --auth-url. Send the printed URL to the user.
|
||||
4. User opens URL, authorizes, gets redirected to a page with a code.
|
||||
5. User pastes the code. Agent runs --auth-code CODE.
|
||||
6. Run --check to verify. Done.
|
||||
"""
|
||||
|
||||
from __future__ import annotations # allow PEP 604 `X | None` on Python 3.9+
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure sibling modules (_hermes_home) are importable when run standalone.
|
||||
_SCRIPTS_DIR = str(Path(__file__).resolve().parent)
|
||||
if _SCRIPTS_DIR not in sys.path:
|
||||
sys.path.insert(0, _SCRIPTS_DIR)
|
||||
|
||||
from _hermes_home import display_hermes_home, get_hermes_home
|
||||
|
||||
HERMES_HOME = get_hermes_home()
|
||||
TOKEN_PATH = HERMES_HOME / "google_token.json"
|
||||
CLIENT_SECRET_PATH = HERMES_HOME / "google_client_secret.json"
|
||||
PENDING_AUTH_PATH = HERMES_HOME / "google_oauth_pending.json"
|
||||
|
||||
SCOPES = [
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
"https://www.googleapis.com/auth/gmail.send",
|
||||
"https://www.googleapis.com/auth/gmail.modify",
|
||||
"https://www.googleapis.com/auth/calendar",
|
||||
"https://www.googleapis.com/auth/drive",
|
||||
"https://www.googleapis.com/auth/contacts.readonly",
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
"https://www.googleapis.com/auth/documents",
|
||||
]
|
||||
|
||||
REQUIRED_PACKAGES = ["google-api-python-client", "google-auth-oauthlib", "google-auth-httplib2"]
|
||||
|
||||
# OAuth redirect for "out of band" manual code copy flow.
|
||||
# Google deprecated OOB, so we use a localhost redirect and tell the user to
|
||||
# copy the code from the browser's URL bar (or the page body).
|
||||
REDIRECT_URI = "http://localhost:1"
|
||||
|
||||
|
||||
def _normalize_authorized_user_payload(payload: dict) -> dict:
|
||||
normalized = dict(payload)
|
||||
if not normalized.get("type"):
|
||||
normalized["type"] = "authorized_user"
|
||||
return normalized
|
||||
|
||||
|
||||
def _load_token_payload(path: Path = TOKEN_PATH) -> dict:
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _missing_scopes_from_payload(payload: dict) -> list[str]:
|
||||
raw = payload.get("scopes") or payload.get("scope")
|
||||
if not raw:
|
||||
return []
|
||||
granted = {s.strip() for s in (raw.split() if isinstance(raw, str) else raw) if s.strip()}
|
||||
return sorted(scope for scope in SCOPES if scope not in granted)
|
||||
|
||||
|
||||
def _format_missing_scopes(missing_scopes: list[str]) -> str:
|
||||
bullets = "\n".join(f" - {scope}" for scope in missing_scopes)
|
||||
return (
|
||||
"Token is valid but missing required Google Workspace scopes:\n"
|
||||
f"{bullets}\n"
|
||||
"Run the Google Workspace setup again from this same Hermes profile to refresh consent."
|
||||
)
|
||||
|
||||
|
||||
def install_deps():
|
||||
"""Install Google API packages if missing. Returns True on success."""
|
||||
try:
|
||||
import googleapiclient # noqa: F401
|
||||
import google_auth_oauthlib # noqa: F401
|
||||
print("Dependencies already installed.")
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
print("Installing Google API dependencies...")
|
||||
|
||||
# First choice: pip in the current interpreter. Works for most installs.
|
||||
try:
|
||||
subprocess.check_call(
|
||||
[sys.executable, "-m", "pip", "install", "--quiet"] + REQUIRED_PACKAGES,
|
||||
stdout=subprocess.DEVNULL,
|
||||
)
|
||||
print("Dependencies installed.")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
pip_error = e
|
||||
|
||||
# Fallback: the interpreter has no pip (the Hermes Docker image's venv is
|
||||
# built with `uv sync`, which does not bootstrap pip). `uv pip install
|
||||
# --python <interpreter>` installs into that exact interpreter without
|
||||
# needing pip present. Targeting sys.executable keeps us on the venv the
|
||||
# script is actually running under, rather than guessing.
|
||||
uv = shutil.which("uv")
|
||||
if uv:
|
||||
try:
|
||||
subprocess.check_call(
|
||||
[uv, "pip", "install", "--python", sys.executable, "--quiet"]
|
||||
+ REQUIRED_PACKAGES,
|
||||
stdout=subprocess.DEVNULL,
|
||||
)
|
||||
print("Dependencies installed.")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"ERROR: Failed to install dependencies via uv: {e}")
|
||||
print(f"Manually: {uv} pip install --python {sys.executable} {' '.join(REQUIRED_PACKAGES)}")
|
||||
return False
|
||||
|
||||
print(f"ERROR: Failed to install dependencies: {pip_error}")
|
||||
print(
|
||||
"On environments without pip (e.g. Nix, or the Hermes Docker image's "
|
||||
"uv-managed venv), install the optional extra instead:"
|
||||
)
|
||||
print(" pip install 'hermes-agent[google]'")
|
||||
print(f"Or manually: {sys.executable} -m pip install {' '.join(REQUIRED_PACKAGES)}")
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_deps():
|
||||
"""Check deps are available, install if not, exit on failure."""
|
||||
try:
|
||||
import googleapiclient # noqa: F401
|
||||
import google_auth_oauthlib # noqa: F401
|
||||
except ImportError:
|
||||
if not install_deps():
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def check_auth_live():
|
||||
"""Check auth with a real API call to detect disabled_client/account issues."""
|
||||
# quiet=True suppresses the "AUTHENTICATED" print from check_auth so the
|
||||
# final status line reflects the live-call outcome (OK or FAILED).
|
||||
if not check_auth(quiet=True):
|
||||
return False
|
||||
try:
|
||||
from googleapiclient.discovery import build
|
||||
from google.oauth2.credentials import Credentials
|
||||
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH))
|
||||
service = build("calendar", "v3", credentials=creds)
|
||||
service.calendarList().list(maxResults=1).execute()
|
||||
print("LIVE_CHECK_OK: Real API call succeeded.")
|
||||
return True
|
||||
except Exception as e:
|
||||
err_str = str(e).lower()
|
||||
if "disabled_client" in err_str or "invalid_client" in err_str:
|
||||
print(f"LIVE_CHECK_FAILED: OAuth client or account disabled: {e}")
|
||||
print(" 1. Check Google Cloud Console for disabled OAuth client")
|
||||
print(" 2. Check myaccount.google.com for account status")
|
||||
print(" 3. Do NOT retry with a disabled account")
|
||||
else:
|
||||
print(f"LIVE_CHECK_FAILED: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def check_auth(quiet: bool = False):
|
||||
"""Check if stored credentials are valid. Prints status, exits 0 or 1."""
|
||||
if not TOKEN_PATH.exists():
|
||||
print(f"NOT_AUTHENTICATED: No token at {TOKEN_PATH}")
|
||||
return False
|
||||
|
||||
_ensure_deps()
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google.auth.transport.requests import Request
|
||||
|
||||
try:
|
||||
# Don't pass scopes — user may have authorized only a subset.
|
||||
# Passing scopes forces google-auth to validate them on refresh,
|
||||
# which fails with invalid_scope if the token has fewer scopes
|
||||
# than requested.
|
||||
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH))
|
||||
except Exception as e:
|
||||
print(f"TOKEN_CORRUPT: {e}")
|
||||
return False
|
||||
|
||||
payload = _load_token_payload(TOKEN_PATH)
|
||||
if creds.valid:
|
||||
missing_scopes = _missing_scopes_from_payload(payload)
|
||||
if missing_scopes:
|
||||
print(f"AUTHENTICATED (partial): Token valid but missing {len(missing_scopes)} scopes:")
|
||||
for s in missing_scopes:
|
||||
print(f" - {s}")
|
||||
if not quiet:
|
||||
print(f"AUTHENTICATED: Token valid at {TOKEN_PATH}")
|
||||
return True
|
||||
|
||||
if creds.expired and creds.refresh_token:
|
||||
try:
|
||||
creds.refresh(Request())
|
||||
TOKEN_PATH.write_text(
|
||||
json.dumps(
|
||||
_normalize_authorized_user_payload(json.loads(creds.to_json())),
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
missing_scopes = _missing_scopes_from_payload(_load_token_payload(TOKEN_PATH))
|
||||
if missing_scopes:
|
||||
print(f"AUTHENTICATED (partial): Token refreshed but missing {len(missing_scopes)} scopes:")
|
||||
for s in missing_scopes:
|
||||
print(f" - {s}")
|
||||
if not quiet:
|
||||
print(f"AUTHENTICATED: Token refreshed at {TOKEN_PATH}")
|
||||
return True
|
||||
except Exception as e:
|
||||
err_str = str(e).lower()
|
||||
if "disabled_client" in err_str or "invalid_client" in err_str:
|
||||
print(f"OAUTH_CLIENT_DISABLED: {e}")
|
||||
print(" The OAuth client or Google account has been disabled.")
|
||||
print(" Steps to resolve:")
|
||||
print(" 1. Check your Google Cloud Console — verify the OAuth client is not disabled")
|
||||
print(" 2. Check if your Google account itself has been disabled at myaccount.google.com")
|
||||
print(" 3. If the account is disabled, you can appeal at accounts.google.com/signin/recovery")
|
||||
print(" 4. Do NOT retry API calls with a disabled account — this may worsen the situation")
|
||||
print(" 5. If the OAuth client is disabled, create a new one in Google Cloud Console")
|
||||
elif "token_revoked" in err_str or "invalid_grant" in err_str:
|
||||
print(f"TOKEN_REVOKED: {e}")
|
||||
print(" Re-run setup to re-authenticate.")
|
||||
else:
|
||||
print(f"REFRESH_FAILED: {e}")
|
||||
return False
|
||||
|
||||
print("TOKEN_INVALID: Re-run setup.")
|
||||
return False
|
||||
|
||||
|
||||
def store_client_secret(path: str):
|
||||
"""Copy and validate client_secret.json to Hermes home."""
|
||||
src = Path(path).expanduser().resolve()
|
||||
if not src.exists():
|
||||
print(f"ERROR: File not found: {src}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
data = json.loads(src.read_text())
|
||||
except json.JSONDecodeError:
|
||||
print("ERROR: File is not valid JSON.")
|
||||
sys.exit(1)
|
||||
|
||||
if "installed" not in data and "web" not in data:
|
||||
print("ERROR: Not a Google OAuth client secret file (missing 'installed' key).")
|
||||
print("Download the correct file from: https://console.cloud.google.com/apis/credentials")
|
||||
sys.exit(1)
|
||||
|
||||
CLIENT_SECRET_PATH.write_text(json.dumps(data, indent=2))
|
||||
print(f"OK: Client secret saved to {CLIENT_SECRET_PATH}")
|
||||
|
||||
|
||||
def _save_pending_auth(*, state: str, code_verifier: str):
|
||||
"""Persist the OAuth session bits needed for a later token exchange."""
|
||||
PENDING_AUTH_PATH.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"state": state,
|
||||
"code_verifier": code_verifier,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _load_pending_auth() -> dict:
|
||||
"""Load the pending OAuth session created by get_auth_url()."""
|
||||
if not PENDING_AUTH_PATH.exists():
|
||||
print("ERROR: No pending OAuth session found. Run --auth-url first.")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
data = json.loads(PENDING_AUTH_PATH.read_text())
|
||||
except Exception as e:
|
||||
print(f"ERROR: Could not read pending OAuth session: {e}")
|
||||
print("Run --auth-url again to start a fresh OAuth session.")
|
||||
sys.exit(1)
|
||||
|
||||
if not data.get("state") or not data.get("code_verifier"):
|
||||
print("ERROR: Pending OAuth session is missing PKCE data.")
|
||||
print("Run --auth-url again to start a fresh OAuth session.")
|
||||
sys.exit(1)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _extract_code_and_state(code_or_url: str) -> tuple[str, str | None]:
|
||||
"""Accept either a raw auth code or the full redirect URL pasted by the user."""
|
||||
if not code_or_url.startswith("http"):
|
||||
return code_or_url, None
|
||||
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
parsed = urlparse(code_or_url)
|
||||
params = parse_qs(parsed.query)
|
||||
if "code" not in params:
|
||||
print("ERROR: No 'code' parameter found in URL.")
|
||||
sys.exit(1)
|
||||
|
||||
state = params.get("state", [None])[0]
|
||||
return params["code"][0], state
|
||||
|
||||
|
||||
def get_auth_url():
|
||||
"""Print the OAuth authorization URL. User visits this in a browser."""
|
||||
if not CLIENT_SECRET_PATH.exists():
|
||||
print("ERROR: No client secret stored. Run --client-secret first.")
|
||||
sys.exit(1)
|
||||
|
||||
_ensure_deps()
|
||||
from google_auth_oauthlib.flow import Flow
|
||||
|
||||
flow = Flow.from_client_secrets_file(
|
||||
str(CLIENT_SECRET_PATH),
|
||||
scopes=SCOPES,
|
||||
redirect_uri=REDIRECT_URI,
|
||||
autogenerate_code_verifier=True,
|
||||
)
|
||||
auth_url, state = flow.authorization_url(
|
||||
access_type="offline",
|
||||
prompt="consent",
|
||||
)
|
||||
_save_pending_auth(state=state, code_verifier=flow.code_verifier)
|
||||
# Print just the URL so the agent can extract it cleanly
|
||||
print(auth_url)
|
||||
|
||||
|
||||
def exchange_auth_code(code: str):
|
||||
"""Exchange the authorization code for a token and save it."""
|
||||
if not CLIENT_SECRET_PATH.exists():
|
||||
print("ERROR: No client secret stored. Run --client-secret first.")
|
||||
sys.exit(1)
|
||||
|
||||
pending_auth = _load_pending_auth()
|
||||
raw_callback = code
|
||||
code, returned_state = _extract_code_and_state(code)
|
||||
if returned_state and returned_state != pending_auth["state"]:
|
||||
print("ERROR: OAuth state mismatch. Run --auth-url again to start a fresh session.")
|
||||
sys.exit(1)
|
||||
|
||||
_ensure_deps()
|
||||
from google_auth_oauthlib.flow import Flow
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
# Extract granted scopes from the callback URL if the user pasted the full redirect URL.
|
||||
granted_scopes = list(SCOPES)
|
||||
if isinstance(raw_callback, str) and raw_callback.startswith("http"):
|
||||
params = parse_qs(urlparse(raw_callback).query)
|
||||
scope_val = (params.get("scope") or [""])[0].strip()
|
||||
if scope_val:
|
||||
granted_scopes = scope_val.split()
|
||||
|
||||
flow = Flow.from_client_secrets_file(
|
||||
str(CLIENT_SECRET_PATH),
|
||||
scopes=granted_scopes,
|
||||
redirect_uri=pending_auth.get("redirect_uri", REDIRECT_URI),
|
||||
state=pending_auth["state"],
|
||||
code_verifier=pending_auth["code_verifier"],
|
||||
)
|
||||
|
||||
try:
|
||||
# Accept partial scopes — user may deselect some permissions in the consent screen
|
||||
os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1"
|
||||
flow.fetch_token(code=code)
|
||||
except Exception as e:
|
||||
print(f"ERROR: Token exchange failed: {e}")
|
||||
print("The code may have expired. Run --auth-url to get a fresh URL.")
|
||||
sys.exit(1)
|
||||
|
||||
creds = flow.credentials
|
||||
token_payload = _normalize_authorized_user_payload(json.loads(creds.to_json()))
|
||||
|
||||
# Store only the scopes actually granted by the user, not what was requested.
|
||||
# creds.to_json() writes the requested scopes, which causes refresh to fail
|
||||
# with invalid_scope if the user only authorized a subset.
|
||||
actually_granted = list(creds.granted_scopes or []) if hasattr(creds, "granted_scopes") and creds.granted_scopes else []
|
||||
if actually_granted:
|
||||
token_payload["scopes"] = actually_granted
|
||||
elif granted_scopes != SCOPES:
|
||||
# granted_scopes was extracted from the callback URL
|
||||
token_payload["scopes"] = granted_scopes
|
||||
|
||||
missing_scopes = _missing_scopes_from_payload(token_payload)
|
||||
if missing_scopes:
|
||||
print(f"WARNING: Token missing some Google Workspace scopes: {', '.join(missing_scopes)}")
|
||||
print("Some services may not be available.")
|
||||
|
||||
TOKEN_PATH.write_text(json.dumps(token_payload, indent=2))
|
||||
PENDING_AUTH_PATH.unlink(missing_ok=True)
|
||||
print(f"OK: Authenticated. Token saved to {TOKEN_PATH}")
|
||||
print(f"Profile-scoped token location: {display_hermes_home()}/google_token.json")
|
||||
|
||||
|
||||
def revoke():
|
||||
"""Revoke stored token and delete it."""
|
||||
if not TOKEN_PATH.exists():
|
||||
print("No token to revoke.")
|
||||
return
|
||||
|
||||
_ensure_deps()
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google.auth.transport.requests import Request
|
||||
|
||||
try:
|
||||
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES)
|
||||
if creds.expired and creds.refresh_token:
|
||||
creds.refresh(Request())
|
||||
|
||||
import urllib.request
|
||||
urllib.request.urlopen(
|
||||
urllib.request.Request(
|
||||
f"https://oauth2.googleapis.com/revoke?token={creds.token}",
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
),
|
||||
timeout=15,
|
||||
)
|
||||
print("Token revoked with Google.")
|
||||
except Exception as e:
|
||||
print(f"Remote revocation failed (token may already be invalid): {e}")
|
||||
|
||||
TOKEN_PATH.unlink(missing_ok=True)
|
||||
PENDING_AUTH_PATH.unlink(missing_ok=True)
|
||||
print(f"Deleted {TOKEN_PATH}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Google Workspace OAuth setup for Hermes")
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument("--check", action="store_true", help="Check if auth is valid (exit 0=yes, 1=no)")
|
||||
group.add_argument("--check-live", action="store_true", help="Check auth with a real API call (detects disabled_client)")
|
||||
group.add_argument("--client-secret", metavar="PATH", help="Store OAuth client_secret.json")
|
||||
group.add_argument("--auth-url", action="store_true", help="Print OAuth URL for user to visit")
|
||||
group.add_argument("--auth-code", metavar="CODE", help="Exchange auth code for token")
|
||||
group.add_argument("--revoke", action="store_true", help="Revoke and delete stored token")
|
||||
group.add_argument("--install-deps", action="store_true", help="Install Python dependencies")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.check:
|
||||
sys.exit(0 if check_auth() else 1)
|
||||
if getattr(args, "check_live", False):
|
||||
sys.exit(0 if check_auth_live() else 1)
|
||||
elif args.client_secret:
|
||||
store_client_secret(args.client_secret)
|
||||
elif args.auth_url:
|
||||
get_auth_url()
|
||||
elif args.auth_code:
|
||||
exchange_auth_code(args.auth_code)
|
||||
elif args.revoke:
|
||||
revoke()
|
||||
elif args.install_deps:
|
||||
sys.exit(0 if install_deps() else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user