Compare commits

...

16 Commits

Author SHA1 Message Date
Peter Steinberger
505c4df730
ci: update homebrew tap on release
Some checks failed
ci / darwin-cgo-build (push) Has been cancelled
ci / test (push) Has been cancelled
ci / worker (push) Has been cancelled
ci / windows (push) Has been cancelled
2026-05-07 03:56:52 +01:00
Peter Steinberger
cde550f749
ci: fix release signing guard
Some checks failed
ci / test (push) Waiting to run
ci / worker (push) Waiting to run
ci / windows (push) Waiting to run
ci / darwin-cgo-build (push) Waiting to run
pages / Deploy docs (push) Has been cancelled
2026-05-06 09:56:55 +01:00
Peter Steinberger
05914139e5
docs(site): polish homepage and code highlighting 2026-05-06 09:55:38 +01:00
Peter Steinberger
2c9c1dcc8b
build: sign macos release binaries
Some checks failed
ci / test (push) Has been cancelled
ci / worker (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / darwin-cgo-build (push) Has been cancelled
pages / Deploy docs (push) Has been cancelled
2026-05-05 17:06:03 +01:00
Peter Steinberger
56755e94ec
fix: remove stale classroom nolint 2026-05-05 08:55:21 +01:00
Peter Steinberger
c65c88304b
fix: satisfy lint after refactor 2026-05-05 08:52:50 +01:00
Peter Steinberger
917e4b98b4
refactor(cmd): split drive command modules 2026-05-05 08:49:02 +01:00
Peter Steinberger
ad59efba58
refactor(cmd): reuse paged list helper 2026-05-05 08:48:59 +01:00
Peter Steinberger
4a2a72fa4e
test(cmd): share google service fixtures 2026-05-05 08:48:54 +01:00
Peter Steinberger
cd37734c99
refactor(googleapi): share service constructor setup 2026-05-05 08:48:51 +01:00
Peter Steinberger
20afed7f4b
refactor(cmd): share raw response helpers 2026-05-05 08:30:06 +01:00
Peter Steinberger
4e61efe0b8
docs: add docs site theme and social card 2026-05-05 08:25:41 +01:00
Peter Steinberger
e8e04a49f9
docs: refresh docs site 2026-05-05 07:39:03 +01:00
Peter Steinberger
e322aad2e9
docs: add feature coverage pages 2026-05-05 07:17:38 +01:00
Peter Steinberger
8b78a94c76
docs: rewrite readme 2026-05-05 07:04:07 +01:00
Peter Steinberger
dd39bb794c
fix(release): use api workflow checks 2026-05-05 06:54:43 +01:00
92 changed files with 3339 additions and 5158 deletions

View File

@ -8,6 +8,7 @@ on:
- "docs/**"
- "scripts/gen-command-reference.sh"
- "scripts/build-docs-site.mjs"
- "scripts/docs-site-assets.mjs"
- "Makefile"
- ".github/workflows/pages.yml"
workflow_dispatch:

View File

@ -48,6 +48,31 @@ jobs:
RELEASE_TAG: ${{ inputs.tag }}
run: git checkout "$RELEASE_TAG"
- name: Import macOS signing certificate
env:
MACOS_SIGNING_CERT_BASE64: ${{ secrets.MACOS_SIGNING_CERT_BASE64 }}
MACOS_SIGNING_CERT_PASSWORD: ${{ secrets.MACOS_SIGNING_CERT_PASSWORD }}
run: |
set -euo pipefail
if [ -z "$MACOS_SIGNING_CERT_BASE64" ]; then
echo "No macOS signing certificate configured; skipping import."
exit 0
fi
KEYCHAIN="build.keychain"
KEYCHAIN_PASSWORD="$(uuidgen)"
echo "$MACOS_SIGNING_CERT_BASE64" | base64 --decode > /tmp/codesign.p12
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security default-keychain -s "$KEYCHAIN"
security list-keychains -d user -s "$KEYCHAIN"
security import /tmp/codesign.p12 -k "$KEYCHAIN" -P "$MACOS_SIGNING_CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
- name: GoReleaser
uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7.2.1
with:
@ -56,3 +81,63 @@ jobs:
args: release --clean --config /tmp/.goreleaser.yaml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GOG_CODESIGN_IDENTITY: ${{ secrets.MACOS_CODESIGN_IDENTITY }}
update-homebrew-tap:
runs-on: ubuntu-latest
needs: goreleaser
steps:
- name: Resolve release tag
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "RELEASE_TAG=${{ inputs.tag }}" >> "$GITHUB_ENV"
else
echo "RELEASE_TAG=${{ github.ref_name }}" >> "$GITHUB_ENV"
fi
- name: Dispatch tap formula update
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::error::Set HOMEBREW_TAP_TOKEN with workflow access to steipete/homebrew-tap"
exit 1
fi
request_id="gogcli-${RELEASE_TAG}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
expected_title="Update gogcli for ${RELEASE_TAG} (${request_id})"
gh workflow run update-formula.yml \
--repo steipete/homebrew-tap \
--ref main \
-f formula=gogcli \
-f tag="$RELEASE_TAG" \
-f repository=steipete/gogcli \
-f artifact_template="{formula}_{version}_{target}.tar.gz" \
-f request_id="$request_id"
run_id=""
for _ in {1..30}; do
run_id=$(gh run list \
--repo steipete/homebrew-tap \
--workflow update-formula.yml \
--branch main \
--event workflow_dispatch \
--limit 20 \
--json databaseId,displayTitle \
--jq ".[] | select(.displayTitle == \"$expected_title\") | .databaseId" | head -n1)
if [ -n "$run_id" ]; then
break
fi
sleep 5
done
if [ -z "$run_id" ]; then
echo "::error::Could not find tap workflow run with title: $expected_title"
exit 1
fi
gh run watch "$run_id" \
--repo steipete/homebrew-tap \
--exit-status \
--interval 10

View File

@ -34,16 +34,21 @@ builds:
targets:
- darwin_amd64
- darwin_arm64
hooks:
post:
- ./scripts/codesign-macos.sh "{{ .Path }}"
archives:
- builds:
- ids:
- gog
- gog_darwin
format: tar.gz
formats:
- tar.gz
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
format_overrides:
- goos: windows
format: zip
formats:
- zip
checksum:
name_template: checksums.txt

View File

@ -74,6 +74,7 @@ docs-site: docs-commands
@node scripts/build-docs-site.mjs
docs-check: docs-site
@node scripts/check-docs-coverage.mjs
tools:
@mkdir -p $(TOOLS_DIR)

2291
README.md

File diff suppressed because it is too large Load Diff

View File

@ -1,51 +0,0 @@
# gog Docs
`gog` is a single CLI for Google Workspace automation: Gmail, Calendar, Drive,
Docs, Sheets, Slides, Contacts, Tasks, People, Forms, Apps Script, Groups, Admin,
Keep, and related agent workflows.
## Start Here
- Install and authenticate from the repository
[README](https://github.com/steipete/gogcli#readme).
- Read [Auth Clients](auth-clients.md) when setting up OAuth clients, service
accounts, or Workspace domain-wide delegation.
- Read [Command Guards and Baked Safety Profiles](safety-profiles.md) when
running `gog` from agents or automation.
- Read the bundled [`gog` agent skill](../.agents/skills/gog/SKILL.md) when an
agent needs safe auth preflight, JSON-first output, or guarded Workspace
automation patterns.
- Read [Sheets Tables](sheets-tables.md) when creating or inspecting Google
Sheets structured tables.
- Open the [Command Index](commands/README.md) for generated docs for every CLI
command.
## Common Paths
```bash
gog auth add you@gmail.com --services gmail,calendar,drive
gog gmail search 'newer_than:7d' --max 10
gog gmail get <messageId> --sanitize-content --json
gog calendar events --today
gog drive ls --max 20
```
## Command Docs
Every command page under `docs/commands/` is generated from
`gog schema --json`. Do not hand-edit generated command pages. After changing
commands, flags, aliases, arguments, or help text, run:
```bash
make docs-commands
```
Then build the GitHub Pages site locally:
```bash
make docs-site
open dist/docs-site/index.html
```
The site is intentionally static: no framework, no package install, and no
client-side dependency beyond a small navigation script embedded by the builder.

View File

@ -24,6 +24,10 @@ Assumptions:
- Go toolchain installed (Go version comes from `go.mod`).
- `make` works locally.
- Access to the tap repo (e.g. `steipete/homebrew-tap`).
- For signed macOS release binaries (recommended): GitHub Actions secrets set:
- `MACOS_SIGNING_CERT_BASE64` (base64-encoded `.p12`)
- `MACOS_SIGNING_CERT_PASSWORD`
- `MACOS_CODESIGN_IDENTITY` (e.g. `Developer ID Application: …`)
## 1) Verify build is green
```sh
@ -71,7 +75,14 @@ gh workflow run release.yml -f tag=vX.Y.Z
## 5) Update (or add) the Homebrew formula
In the tap repo (assumed sibling at `../homebrew-tap`), create/update `Formula/gogcli.rb`.
Recommended formula shape (build-from-source, no binary assets needed):
Recommended formula shape (download GitHub release assets; preserves macOS code signature):
- `version "X.Y.Z"`
- `url "https://github.com/steipete/gogcli/releases/download/vX.Y.Z/gogcli_X.Y.Z_darwin_arm64.tar.gz"` (or `darwin_amd64`)
- `sha256 "<sha256>"`
- Install:
- `bin.install "gog"`
Alternative (build-from-source; macOS binary will be ad-hoc signed, which can trigger repeated Keychain prompts with `KeychainTrustApplication`):
- `version "X.Y.Z"`
- `url "https://github.com/steipete/gogcli/archive/refs/tags/vX.Y.Z.tar.gz"`
- `sha256 "<sha256>"`

View File

@ -1,489 +0,0 @@
:root {
--bg0: #07070b;
--bg1: #0b0b11;
--bg2: #11111a;
--card: rgba(17, 17, 26, 0.72);
--card2: rgba(12, 12, 18, 0.64);
--stroke: rgba(255, 255, 255, 0.08);
--stroke2: rgba(255, 255, 255, 0.05);
--text: #f3f4f6;
--muted: rgba(243, 244, 246, 0.7);
--dim: rgba(243, 244, 246, 0.46);
--b: #4285f4;
--r: #ea4335;
--y: #fbbc05;
--g: #34a853;
--shadow: 0 24px 60px rgba(0, 0, 0, 0.55);
--shadow2: 0 16px 40px rgba(0, 0, 0, 0.45);
--radius: 16px;
--radius2: 22px;
--serif: "Fraunces", ui-serif, Georgia, serif;
--sans: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
background: radial-gradient(1200px 800px at 50% -20%, rgba(66, 133, 244, 0.18), transparent 60%),
radial-gradient(900px 700px at 80% 18%, rgba(234, 67, 53, 0.14), transparent 55%),
radial-gradient(1000px 900px at 18% 62%, rgba(52, 168, 83, 0.14), transparent 55%),
radial-gradient(900px 900px at 65% 88%, rgba(251, 188, 5, 0.12), transparent 55%),
linear-gradient(180deg, var(--bg0), var(--bg1) 40%, var(--bg0));
color: var(--text);
font-family: var(--sans);
-webkit-font-smoothing: antialiased;
line-height: 1.55;
overflow-x: hidden;
}
a {
color: inherit;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
code {
font-family: var(--mono);
font-size: 0.95em;
}
.skip {
position: absolute;
left: -999px;
top: 10px;
background: var(--bg2);
border: 1px solid var(--stroke);
color: var(--text);
padding: 10px 12px;
border-radius: 10px;
z-index: 20;
}
.skip:focus {
left: 12px;
}
.bg {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
}
.bg__mesh {
position: absolute;
inset: 0;
background: radial-gradient(1200px 800px at 20% 20%, rgba(66, 133, 244, 0.18), transparent 60%),
radial-gradient(1000px 800px at 82% 30%, rgba(234, 67, 53, 0.12), transparent 55%),
radial-gradient(1000px 900px at 40% 85%, rgba(52, 168, 83, 0.12), transparent 55%);
filter: blur(4px) saturate(1.12);
opacity: 0.95;
}
.bg__grid {
position: absolute;
inset: -2px;
background-image: linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
background-size: 72px 72px;
opacity: 0.055;
transform: perspective(900px) rotateX(58deg) translateY(-18%);
transform-origin: top;
mask-image: radial-gradient(60% 60% at 50% 30%, rgba(0, 0, 0, 1), transparent 72%);
}
.bg__grain {
position: absolute;
inset: 0;
opacity: 0.2;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='240' height='240' filter='url(%23n)' opacity='.45'/%3E%3C/svg%3E");
mix-blend-mode: overlay;
}
.wrap {
width: min(1100px, calc(100% - 48px));
margin: 0 auto;
position: relative;
z-index: 1;
}
.top {
position: sticky;
top: 0;
z-index: 10;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
background: rgba(7, 7, 11, 0.58);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.top__row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 0;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none !important;
}
.brand__mark {
width: 26px;
height: 26px;
border-radius: 10px;
background: conic-gradient(from 200deg, var(--b), var(--g), var(--y), var(--r), var(--b));
box-shadow: 0 10px 24px rgba(66, 133, 244, 0.15), 0 10px 24px rgba(52, 168, 83, 0.12);
}
.brand__mark--small {
width: 18px;
height: 18px;
border-radius: 7px;
}
.brand__name {
font-family: var(--mono);
font-weight: 500;
letter-spacing: -0.02em;
}
.brand__tag {
font-size: 12px;
color: var(--dim);
border: 1px solid var(--stroke2);
background: rgba(17, 17, 26, 0.55);
padding: 3px 8px;
border-radius: 999px;
}
.nav {
display: flex;
align-items: center;
gap: 18px;
font-size: 14px;
color: var(--muted);
}
.nav a {
text-decoration: none;
}
.nav a:hover {
color: var(--text);
text-decoration: none;
}
.nav__cta {
color: var(--text) !important;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
padding: 8px 12px;
border-radius: 999px;
}
.main {
padding-bottom: 70px;
}
.hero {
padding: 56px 0 26px;
}
.hero__grid {
display: grid;
grid-template-columns: 1.05fr 0.95fr;
gap: 28px;
align-items: start;
}
.kicker {
display: inline-flex;
gap: 10px;
align-items: center;
font-size: 12px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(243, 244, 246, 0.58);
margin: 0 0 14px;
}
.kicker::before {
content: "";
width: 10px;
height: 10px;
border-radius: 3px;
background: linear-gradient(135deg, rgba(66, 133, 244, 0.9), rgba(52, 168, 83, 0.85));
box-shadow: 0 0 0 4px rgba(66, 133, 244, 0.08);
}
h1 {
font-family: var(--serif);
font-weight: 700;
letter-spacing: -0.03em;
line-height: 0.95;
margin: 0 0 14px;
font-size: clamp(44px, 5.5vw, 68px);
}
.hero__word {
display: block;
opacity: 0;
transform: translateY(10px);
animation: rise 700ms cubic-bezier(0.2, 1, 0.2, 1) forwards;
}
.hero__word:nth-child(1) {
animation-delay: 80ms;
}
.hero__word:nth-child(2) {
animation-delay: 160ms;
}
.hero__word:nth-child(3) {
animation-delay: 260ms;
}
.hero__word--mono {
font-family: var(--mono);
font-weight: 500;
letter-spacing: -0.04em;
color: rgba(243, 244, 246, 0.92);
}
@keyframes rise {
to {
opacity: 1;
transform: translateY(0);
}
}
.lede {
margin: 0 0 18px;
font-size: 16.5px;
color: var(--muted);
max-width: 52ch;
opacity: 0;
transform: translateY(10px);
animation: rise 700ms cubic-bezier(0.2, 1, 0.2, 1) 320ms forwards;
}
.lede strong {
color: var(--text);
font-weight: 600;
}
.pills {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 0 20px;
opacity: 0;
transform: translateY(10px);
animation: rise 700ms cubic-bezier(0.2, 1, 0.2, 1) 380ms forwards;
}
.pill {
font-size: 12px;
border-radius: 999px;
padding: 6px 10px;
border: 1px solid var(--stroke2);
background: rgba(17, 17, 26, 0.55);
color: rgba(243, 244, 246, 0.76);
}
.pill--b {
box-shadow: inset 0 0 0 1px rgba(66, 133, 244, 0.25);
}
.pill--r {
box-shadow: inset 0 0 0 1px rgba(234, 67, 53, 0.22);
}
.pill--y {
box-shadow: inset 0 0 0 1px rgba(251, 188, 5, 0.22);
}
.pill--g {
box-shadow: inset 0 0 0 1px rgba(52, 168, 83, 0.22);
}
.hero__actions {
display: flex;
gap: 12px;
margin: 0 0 12px;
opacity: 0;
transform: translateY(10px);
animation: rise 700ms cubic-bezier(0.2, 1, 0.2, 1) 440ms forwards;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 10px 14px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
text-decoration: none !important;
font-weight: 600;
font-size: 14px;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.22);
}
.btn--primary {
background: linear-gradient(135deg, rgba(66, 133, 244, 0.92), rgba(52, 168, 83, 0.86));
color: #091018;
border-color: rgba(255, 255, 255, 0.12);
box-shadow: 0 18px 44px rgba(66, 133, 244, 0.18), 0 18px 44px rgba(52, 168, 83, 0.14);
}
.btn--ghost {
background: rgba(255, 255, 255, 0.06);
color: var(--text);
}
.btn:hover {
transform: translateY(-1px);
}
.note {
margin: 0;
color: rgba(243, 244, 246, 0.56);
font-size: 13px;
opacity: 0;
transform: translateY(10px);
animation: rise 700ms cubic-bezier(0.2, 1, 0.2, 1) 520ms forwards;
}
.note code {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.07);
padding: 2px 6px;
border-radius: 8px;
color: rgba(243, 244, 246, 0.84);
}
.hero__panel {
display: grid;
gap: 14px;
opacity: 0;
transform: translateY(10px);
animation: rise 700ms cubic-bezier(0.2, 1, 0.2, 1) 260ms forwards;
}
.card {
border: 1px solid var(--stroke);
background: var(--card);
border-radius: var(--radius2);
box-shadow: var(--shadow);
overflow: hidden;
}
.term__bar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: rgba(12, 12, 18, 0.58);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.dots {
display: inline-flex;
gap: 6px;
}
.dot {
width: 11px;
height: 11px;
border-radius: 999px;
}
.dot--r {
background: #ff5f57;
}
.dot--y {
background: #febc2e;
}
.dot--g {
background: #28c840;
}
.term__title {
margin-left: auto;
margin-right: auto;
font-family: var(--mono);
font-size: 12px;
color: rgba(243, 244, 246, 0.55);
}
.term__body {
padding: 14px 14px 16px;
background: rgba(11, 11, 17, 0.6);
}
.term__pre {
margin: 0;
font-family: var(--mono);
font-size: 12.5px;
color: rgba(243, 244, 246, 0.86);
line-height: 1.6;
white-space: pre-wrap;
}
.term__pre code {
display: block;
}
.meta {
padding: 14px 14px;
background: var(--card2);
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: var(--shadow2);
}
.meta__item {
display: grid;
grid-template-columns: 90px 1fr;
gap: 12px;
padding: 10px 0;
}
.meta__item + .meta__item {
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.meta__k {
color: rgba(243, 244, 246, 0.55);
font-size: 12px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.meta__v {
color: rgba(243, 244, 246, 0.86);
font-size: 13px;
}
.meta__v code {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.07);
padding: 2px 6px;
border-radius: 8px;
color: rgba(243, 244, 246, 0.9);
}

View File

@ -1,34 +0,0 @@
(() => {
const el = document.getElementById("demo");
if (!el) return;
const original = el.textContent || "";
const prefersReduced = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
if (prefersReduced) return;
el.textContent = "";
const lines = original.split("\n");
const chunkDelay = 16;
const lineDelay = 140;
let i = 0;
let j = 0;
const tick = () => {
if (i >= lines.length) return;
const line = lines[i];
if (j <= line.length) {
el.textContent += line.slice(j, j + 1);
j += 1;
window.setTimeout(tick, chunkDelay);
return;
}
el.textContent += "\n";
i += 1;
j = 0;
window.setTimeout(tick, lineDelay);
};
window.setTimeout(tick, 260);
})();

View File

@ -1,239 +0,0 @@
.section {
padding: 52px 0;
}
.section__grid {
display: grid;
grid-template-columns: 0.36fr 0.64fr;
gap: 28px;
align-items: start;
}
h2 {
font-family: var(--serif);
font-weight: 700;
letter-spacing: -0.02em;
margin: 0 0 6px;
font-size: 28px;
}
h3 {
margin: 0 0 8px;
font-size: 16px;
font-weight: 700;
}
.muted {
margin: 0;
color: var(--muted);
}
.cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.block {
padding: 16px 16px;
}
.code {
margin: 0;
padding: 12px 12px;
border-radius: 14px;
background: rgba(0, 0, 0, 0.22);
border: 1px solid rgba(255, 255, 255, 0.08);
overflow: auto;
}
.code code {
font-family: var(--mono);
font-size: 12.5px;
color: rgba(243, 244, 246, 0.88);
}
.steps {
display: grid;
gap: 14px;
}
.step {
display: grid;
grid-template-columns: 46px 1fr;
gap: 14px;
padding: 16px;
border-radius: var(--radius2);
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(12, 12, 18, 0.46);
box-shadow: var(--shadow2);
}
.step__n {
width: 40px;
height: 40px;
border-radius: 14px;
display: grid;
place-items: center;
font-family: var(--mono);
font-weight: 500;
color: rgba(243, 244, 246, 0.92);
background: linear-gradient(135deg, rgba(66, 133, 244, 0.22), rgba(52, 168, 83, 0.18));
border: 1px solid rgba(255, 255, 255, 0.08);
}
.step p {
margin: 0 0 10px;
color: var(--muted);
}
.callout {
margin-top: 14px;
display: grid;
grid-template-columns: 44px 1fr;
gap: 14px;
padding: 16px;
border-radius: var(--radius2);
border: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(135deg, rgba(66, 133, 244, 0.14), rgba(234, 67, 53, 0.08));
box-shadow: var(--shadow2);
}
.callout__icon {
width: 44px;
height: 44px;
border-radius: 16px;
background: rgba(0, 0, 0, 0.18);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: inset 0 0 0 1px rgba(66, 133, 244, 0.18);
}
.callout__body p {
margin: 0 0 10px;
color: var(--muted);
}
.callout__body code {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.07);
padding: 2px 6px;
border-radius: 8px;
color: rgba(243, 244, 246, 0.92);
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
.feat {
padding: 16px;
background: rgba(12, 12, 18, 0.46);
box-shadow: var(--shadow2);
}
.feat p {
margin: 0;
color: var(--muted);
}
.footerline {
display: flex;
gap: 12px;
margin-top: 18px;
}
.sitefoot {
padding: 36px 0 26px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(7, 7, 11, 0.35);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.sitefoot__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.sitefoot__brand {
display: inline-flex;
align-items: center;
gap: 10px;
font-family: var(--mono);
}
.sitefoot__small {
margin-top: 8px;
color: rgba(243, 244, 246, 0.6);
font-size: 13px;
}
.sitefoot__small a {
color: rgba(243, 244, 246, 0.75);
}
.sep {
margin: 0 8px;
color: rgba(243, 244, 246, 0.28);
}
.sitefoot__right {
display: flex;
gap: 14px;
color: rgba(243, 244, 246, 0.7);
font-size: 14px;
}
.sitefoot__fineprint {
margin-top: 18px;
color: rgba(243, 244, 246, 0.46);
font-size: 12px;
}
@media (max-width: 980px) {
.hero__grid {
grid-template-columns: 1fr;
}
.section__grid {
grid-template-columns: 1fr;
}
.grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 640px) {
.wrap {
width: min(1100px, calc(100% - 28px));
}
.nav {
display: none;
}
.cols {
grid-template-columns: 1fr;
}
.grid {
grid-template-columns: 1fr;
}
.footerline {
flex-direction: column;
align-items: flex-start;
}
.sitefoot__row {
flex-direction: column;
align-items: flex-start;
}
}
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
scroll-behavior: auto !important;
}
}

68
docs/contacts-dedupe.md Normal file
View File

@ -0,0 +1,68 @@
# Contacts Dedupe Preview
read_when:
- Finding duplicate Google Contacts.
- Reviewing or changing `gog contacts dedupe`.
`gog contacts dedupe` finds likely duplicate personal contacts and prints a
merge plan. It is preview-only: it does not merge, update, or delete contacts.
## Command Page
- [`gog contacts dedupe`](commands/gog-contacts-dedupe.md)
## Basic Use
```bash
gog contacts dedupe
gog contacts dedupe --json
gog contacts dedupe --max 500 --json
```
Default matching uses normalized email and phone values:
```bash
gog contacts dedupe --match email,phone
```
Name matching is opt-in because it can produce false positives:
```bash
gog contacts dedupe --match email,phone,name
```
## Output
The command groups contacts that share a matching key. JSON output includes:
- `scanned`: number of contacts examined
- `groups`: likely duplicate groups
- `primary`: the contact gog would keep first in a hypothetical merge plan
- `merged`: merged emails/phones for preview
- `matched_on`: duplicate email/phone/name keys that caused the group
- `members`: all contacts in the group
## Safety
`contacts dedupe` is read-only. There is no apply flag.
Use `--dry-run` in automation anyway when you want a uniform safety habit across
commands:
```bash
gog contacts dedupe --dry-run --json
```
Use `--fail-empty` in scheduled checks when "no duplicates" should be reported
as a distinct exit code:
```bash
gog contacts dedupe --fail-empty
```
## Related Pages
- [Raw API Dumps](raw-api.md)
- [Generated Contacts command pages](commands/gog-contacts.md)
- [`gog contacts export`](commands/gog-contacts-export.md)
- [`gog contacts raw`](commands/gog-contacts-raw.md)

94
docs/docs-editing.md Normal file
View File

@ -0,0 +1,94 @@
# Google Docs Editing
read_when:
- Editing Google Docs content, tabs, formatting, comments, or raw Docs output.
- Reviewing Docs write, format, find-replace, or tab commands.
Docs commands cover document creation, export, content writes, find/replace,
comments, tabs, formatting, and raw API inspection.
## Write Markdown
Append Markdown and convert it to Google Docs formatting:
```bash
gog docs write <docId> --append --markdown --text '## Status'
```
Replace the document body with Markdown from a file:
```bash
gog docs write <docId> --replace --markdown --content-file README.md
```
Command pages:
- [`gog docs write`](commands/gog-docs-write.md)
- [`gog docs export`](commands/gog-docs-export.md)
- [`gog docs cat`](commands/gog-docs-cat.md)
## Format Text
Apply text or paragraph formatting:
```bash
gog docs format <docId> --match Status --bold --font-size 18
gog docs format <docId> --match "Action item" --text-color '#b00020'
gog docs format <docId> --match Heading --alignment center --line-spacing 120
```
Use `--match-all` when every occurrence should be formatted.
Command page:
- [`gog docs format`](commands/gog-docs-format.md)
## Tabs
Manage Google Docs tabs:
```bash
gog docs list-tabs <docId>
gog docs add-tab <docId> --title "Notes"
gog docs rename-tab <docId> <tabId> "Archive"
gog docs delete-tab <docId> <tabId> --force
```
Tab-aware commands accept `--tab` by title or ID:
```bash
gog docs write <docId> --append --tab "Notes" --text "Follow-up"
gog docs find-replace <docId> old new --tab "Notes" --dry-run
```
Command pages:
- [`gog docs list-tabs`](commands/gog-docs-list-tabs.md)
- [`gog docs add-tab`](commands/gog-docs-add-tab.md)
- [`gog docs rename-tab`](commands/gog-docs-rename-tab.md)
- [`gog docs delete-tab`](commands/gog-docs-delete-tab.md)
## Find and Replace
```bash
gog docs find-replace <docId> old new --dry-run
gog docs find-replace <docId> old '' --first
gog docs find-replace <docId> PLACEHOLDER --content-file replacement.md --format markdown
```
`--dry-run` is read-only and reports match counts. Empty replacement strings are
allowed and delete matches.
Command page:
- [`gog docs find-replace`](commands/gog-docs-find-replace.md)
## Raw Docs Output
Use raw output when a script needs the Google Docs API object:
```bash
gog docs raw <docId> --pretty
```
See [Raw API Dumps](raw-api.md) for lossless-output safety notes.

75
docs/drive-audits.md Normal file
View File

@ -0,0 +1,75 @@
# Drive Audits
read_when:
- Auditing Drive folder contents, size, or inventory without changing files.
- Reviewing `drive tree`, `drive du`, or `drive inventory`.
Drive audit commands are read-only reporting helpers. They are meant for cleanup
planning, migration review, and automation that needs stable JSON without
writing back to Drive.
## Commands
- [`gog drive tree`](commands/gog-drive-tree.md)
- [`gog drive du`](commands/gog-drive-du.md)
- [`gog drive inventory`](commands/gog-drive-inventory.md)
- [`gog drive ls`](commands/gog-drive-ls.md)
- [`gog drive get`](commands/gog-drive-get.md)
- [`gog drive raw`](commands/gog-drive-raw.md)
## Folder Tree
Print a readable folder tree:
```bash
gog drive tree --parent <folderId> --depth 2
```
Use JSON when another tool should consume the result:
```bash
gog drive tree --parent <folderId> --depth 3 --json
```
## Size Summary
Summarize folder sizes:
```bash
gog drive du --parent <folderId> --max 20
gog drive du --parent <folderId> --depth 2 --sort size --json
```
`drive du` counts files under folders and sorts by `size`, `path`, or `files`.
## Inventory Export
Export a read-only item inventory:
```bash
gog drive inventory --parent <folderId> --json
gog drive inventory --parent <folderId> --max 0 --depth 0 --json > drive-inventory.json
```
Use inventory output when you need a machine-readable list of Drive objects for
review, diffing, or downstream cleanup scripts.
## Shared Drives
The audit commands include shared drives by default where the underlying Drive
API supports it. Pass `--no-all-drives` to restrict a scan to My Drive:
```bash
gog drive inventory --parent root --no-all-drives --json
```
## Custom Fields
For object-level inspection, use `drive get --fields`:
```bash
gog drive get <fileId> --fields 'id,name,mimeType,size,owners,emailAddress' --json
```
Use [`gog drive raw`](commands/gog-drive-raw.md) when you need the raw Drive API
object, with the sensitive-field behavior described in [Raw API Dumps](raw-api.md).

88
docs/gmail-workflows.md Normal file
View File

@ -0,0 +1,88 @@
# Gmail Workflows
read_when:
- Working with Gmail content, filters, watches, labels, or agent-safe reads.
- Reviewing Gmail commands that cross from read-only into send or modify flows.
Gmail is one of gog's broadest surfaces. Use command-specific pages for exact
flags, and use this page to choose the right workflow shape.
## Search and Read
```bash
gog gmail search 'newer_than:7d' --max 10 --json
gog gmail get <messageId> --json
gog gmail thread get <threadId> --json
```
For agents, logs, or issue reports, prefer sanitized content:
```bash
gog gmail get <messageId> --sanitize-content --json
gog gmail thread get <threadId> --sanitize-content --json
```
`--sanitize-content` strips unsafe/raw payload details while keeping useful
message text for automation.
## Filters
Export filters as Gmail WebUI-compatible XML:
```bash
gog gmail settings filters export --out filters.xml
```
Keep API JSON when a script needs the Gmail API shape:
```bash
gog gmail settings filters export --format json --json
```
Command pages:
- [`gog gmail settings filters export`](commands/gog-gmail-settings-filters-export.md)
- [`gog gmail settings filters list`](commands/gog-gmail-settings-filters-list.md)
- [`gog gmail settings filters create`](commands/gog-gmail-settings-filters-create.md)
- [`gog gmail settings filters delete`](commands/gog-gmail-settings-filters-delete.md)
## Send Guardrails
Block send operations globally for one run:
```bash
gog --gmail-no-send gmail send --to you@example.com --subject test --text body
```
Or use the environment variable in agent shells:
```bash
export GOG_GMAIL_NO_SEND=1
```
For account-specific send blocking, use the no-send config commands:
- [`gog config no-send set`](commands/gog-config-no-send-set.md)
- [`gog config no-send list`](commands/gog-config-no-send-list.md)
- [`gog config no-send remove`](commands/gog-config-no-send-remove.md)
## Watches and Push
Gmail watch/PubSub workflows are documented in [Gmail watch](watch.md).
Key command pages:
- [`gog gmail watch start`](commands/gog-gmail-settings-watch-start.md)
- [`gog gmail watch serve`](commands/gog-gmail-settings-watch-serve.md)
- [`gog gmail watch renew`](commands/gog-gmail-settings-watch-renew.md)
- [`gog gmail history`](commands/gog-gmail-history.md)
## Email Tracking
Open tracking is documented in [Email Tracking](email-tracking.md) and
[Email Tracking Worker](email-tracking-worker.md).
## Raw Gmail
Use [`gog gmail raw`](commands/gog-gmail-raw.md) when you need the underlying
Gmail API `Message` object. See [Raw API Dumps](raw-api.md) for safety notes.

View File

@ -1,301 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>gog — Google in your terminal</title>
<meta
name="description"
content="gog: a single CLI for Gmail, Calendar, Drive, Contacts, Tasks, Sheets, Docs, Slides, and People."
/>
<meta name="theme-color" content="#0b0b11" />
<meta property="og:title" content="gog — Google in your terminal" />
<meta
property="og:description"
content="One CLI for Gmail, Calendar, Drive, Contacts, Tasks, Sheets, Docs, Slides, and People."
/>
<meta property="og:type" content="website" />
<meta property="og:url" content="https://gogcli.sh/" />
<meta name="twitter:card" content="summary_large_image" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700&family=DM+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="./assets/site.css" />
<link rel="stylesheet" href="./assets/site.more.css" />
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<div class="bg" aria-hidden="true">
<div class="bg__mesh"></div>
<div class="bg__grid"></div>
<div class="bg__grain"></div>
</div>
<header class="top">
<div class="wrap top__row">
<a class="brand" href="/">
<span class="brand__mark" aria-hidden="true"></span>
<span class="brand__name">gog</span>
<span class="brand__tag">gogcli</span>
</a>
<nav class="nav">
<a href="#install">Install</a>
<a href="#quickstart">Quickstart</a>
<a href="#features">Features</a>
<a href="#examples">Examples</a>
<a class="nav__cta" href="https://github.com/steipete/gogcli">GitHub</a>
</nav>
</div>
</header>
<main id="main" class="main">
<section class="hero">
<div class="wrap hero__grid">
<div class="hero__copy">
<p class="kicker">Google Workspace. One binary.</p>
<h1>
<span class="hero__word">Google</span>
<span class="hero__word">in your</span>
<span class="hero__word hero__word--mono">terminal</span>
</h1>
<p class="lede">
<strong>gog</strong> unifies Gmail, Calendar, Drive, Contacts, Tasks, Sheets, Docs, Slides, and People
under one CLI — with JSON output and sane defaults.
</p>
<div class="pills" aria-label="Supported services">
<span class="pill pill--b">Gmail</span>
<span class="pill pill--g">Calendar</span>
<span class="pill pill--r">Drive</span>
<span class="pill pill--y">Contacts</span>
<span class="pill pill--g">Tasks</span>
<span class="pill pill--g">Sheets</span>
<span class="pill pill--b">Docs</span>
<span class="pill pill--r">Slides</span>
<span class="pill pill--y">People</span>
</div>
<div class="hero__actions">
<a class="btn btn--primary" href="#install">Install</a>
<a class="btn btn--ghost" href="https://github.com/steipete/gogcli#quick-start">Readme</a>
</div>
<p class="note">
Tip: set a default in <code>gog auth manage</code> or export <code>GOG_ACCOUNT=you@gmail.com</code> once, stop repeating <code>--account</code>.
</p>
</div>
<div class="hero__panel">
<div class="card term" role="region" aria-label="Terminal demo">
<div class="term__bar" aria-hidden="true">
<span class="dots">
<span class="dot dot--r"></span>
<span class="dot dot--y"></span>
<span class="dot dot--g"></span>
</span>
<span class="term__title">gogcli.sh</span>
</div>
<div class="term__body">
<pre class="term__pre"><code id="demo">
$ brew install gogcli
$ gog auth credentials ~/Downloads/client_secret.json
$ gog auth add you@gmail.com
$ export GOG_ACCOUNT=you@gmail.com
$ gog gmail labels list
$ gog calendar calendars --max 5 --json | jq '.calendars[].summary'
$ gog drive ls --query "mimeType='application/pdf'" --max 3
</code></pre>
</div>
</div>
<div class="card meta">
<div class="meta__item">
<div class="meta__k">Output</div>
<div class="meta__v">tables / <code>--plain</code> / <code>--json</code></div>
</div>
<div class="meta__item">
<div class="meta__k">Accounts</div>
<div class="meta__v">multi-account + <code>gog auth manage</code></div>
</div>
<div class="meta__item">
<div class="meta__k">Secrets</div>
<div class="meta__v">OS keyring (Keychain / Secret Service / CredMan)</div>
</div>
</div>
</div>
</div>
</section>
<section id="install" class="section">
<div class="wrap section__grid">
<div>
<h2>Install</h2>
<p class="muted">Homebrew, or build from source.</p>
</div>
<div class="cols">
<div class="card block">
<h3>Homebrew</h3>
<pre class="code"><code>brew install gogcli</code></pre>
</div>
<div class="card block">
<h3>From source</h3>
<pre class="code"><code>git clone https://github.com/steipete/gogcli.git
cd gogcli
make
./bin/gog --help</code></pre>
</div>
</div>
</div>
</section>
<section id="quickstart" class="section">
<div class="wrap section__grid">
<div>
<h2>Quickstart</h2>
<p class="muted">
Youll need a Google Cloud “Desktop app” OAuth client JSON once. Then you can keep adding accounts.
</p>
</div>
<div class="steps">
<div class="step">
<div class="step__n">1</div>
<div class="step__b">
<h3>Store credentials</h3>
<p>Save your downloaded client JSON into gogs config.</p>
<pre class="code"><code>gog auth credentials ~/Downloads/client_secret_....json</code></pre>
</div>
</div>
<div class="step">
<div class="step__n">2</div>
<div class="step__b">
<h3>Authorize an account</h3>
<p>Browser flow by default. Use <code>--manual</code> for headless.</p>
<pre class="code"><code>gog auth add you@gmail.com</code></pre>
</div>
</div>
<div class="step">
<div class="step__n">3</div>
<div class="step__b">
<h3>Run commands</h3>
<p>Use <code>--json</code> for scripting.</p>
<pre class="code"><code>export GOG_ACCOUNT=you@gmail.com
gog gmail search 'newer_than:7d' --max 10 --json | jq</code></pre>
</div>
</div>
</div>
<div class="callout">
<div class="callout__icon" aria-hidden="true"></div>
<div class="callout__body">
<h3>Re-auth a service (e.g. Sheets)</h3>
<p>
If you add scopes later and Google doesnt return a refresh token, re-run with
<code>--force-consent</code>.
</p>
<pre class="code"><code>gog auth add you@gmail.com --services sheets --force-consent</code></pre>
</div>
</div>
</div>
</section>
<section id="features" class="section">
<div class="wrap section__grid">
<div>
<h2>Features</h2>
<p class="muted">High leverage commands, consistent UX, and clean output.</p>
</div>
<div class="grid">
<div class="card feat">
<h3>Gmail</h3>
<p>Search threads, send mail, manage labels, drafts, filters, settings, and watch (Pub/Sub push).</p>
</div>
<div class="card feat">
<h3>Calendar</h3>
<p>List/create/update events, respond to invites, detect conflicts, and check free/busy.</p>
</div>
<div class="card feat">
<h3>Drive</h3>
<p>List/search/upload/download, export Docs formats, permissions, folders, URLs.</p>
</div>
<div class="card feat">
<h3>Sheets / Docs / Slides</h3>
<p>Read/write Sheets; export Docs/Slides/Sheets to PDF/DOCX/PPTX/XLSX/CSV via Drive.</p>
</div>
<div class="card feat">
<h3>Contacts / People</h3>
<p>Personal contacts, “other contacts”, Workspace directory, and your profile.</p>
</div>
<div class="card feat">
<h3>Tasks</h3>
<p>Tasklists + tasks: add/update/done/undo/delete/clear with paging and JSON output.</p>
</div>
</div>
</div>
</section>
<section id="examples" class="section">
<div class="wrap section__grid">
<div>
<h2>Examples</h2>
<p class="muted">A few commands youll actually use.</p>
</div>
<div class="cols">
<div class="card block">
<h3>Find unread mail</h3>
<pre class="code"><code>gog gmail search 'is:unread newer_than:7d' --max 20</code></pre>
<p class="muted">Pipe JSON to jq for scripts.</p>
<pre class="code"><code>gog gmail search 'newer_than:7d' --max 50 --json | jq '.threads[] | .subject'</code></pre>
</div>
<div class="card block">
<h3>Export a Sheet as PDF</h3>
<pre class="code"><code>gog sheets export &lt;spreadsheetId&gt; --format pdf --out ./sheet.pdf</code></pre>
<p class="muted">Docs and Slides are similar.</p>
<pre class="code"><code>gog docs export &lt;docId&gt; --format docx --out ./doc.docx
gog slides export &lt;presentationId&gt; --format pptx --out ./deck.pptx</code></pre>
</div>
</div>
<div class="footerline">
<a class="btn btn--primary" href="https://github.com/steipete/gogcli">Go to GitHub</a>
<a class="btn btn--ghost" href="https://github.com/steipete/gogcli/blob/main/CHANGELOG.md">Changelog</a>
</div>
</div>
</section>
</main>
<footer class="sitefoot">
<div class="wrap sitefoot__row">
<div class="sitefoot__left">
<div class="sitefoot__brand">
<span class="brand__mark brand__mark--small" aria-hidden="true"></span>
<span>gog</span>
</div>
<div class="sitefoot__small">
<span>Built by <a href="https://steipete.me">Peter Steinberger</a>.</span>
<span class="sep" aria-hidden="true">·</span>
<a href="https://github.com/steipete/gogcli/blob/main/LICENSE">MIT</a>
</div>
</div>
<div class="sitefoot__right">
<a href="https://github.com/steipete/gogcli">Source</a>
<a href="https://github.com/steipete/gogcli#installation">Install</a>
<a href="https://github.com/steipete/gogcli#commands">Commands</a>
</div>
</div>
<div class="wrap sitefoot__fineprint">
<span>Not affiliated with Google. Google is a trademark of Google LLC.</span>
</div>
</footer>
<script src="./assets/site.js"></script>
</body>
</html>

50
docs/index.md Normal file
View File

@ -0,0 +1,50 @@
---
title: Overview
permalink: /
description: "gog is a single Go CLI for Gmail, Calendar, Drive, Docs, Sheets, Slides, Forms, Apps Script, Contacts, Tasks, and Workspace admin — built for terminals, scripts, CI, and coding agents."
---
## Try it
After you store an OAuth client and authorize an account ([Quickstart](quickstart.md) walks through the five-minute version), everything is a one-liner.
```bash
# Search this week's mail and read a sanitized message body for an agent.
gog gmail search 'newer_than:7d' --max 10
gog gmail get <messageId> --sanitize-content --json
# Today's calendar.
gog calendar events --today
# Audit a Drive folder without changing anything.
gog drive tree --parent <folderId> --depth 2
gog drive du --parent <folderId> --max 20 --json
# Edit a Doc, append to a Sheet table, push slides from Markdown.
gog docs format <docId> --match Status --bold --font-size 18
gog sheets table append <spreadsheetId> Tasks 'Ship README|done'
gog slides create-from-markdown "Weekly update" --content-file slides.md
```
`--json` produces a stable JSON envelope on stdout, `--plain` produces TSV; human progress, prompts, and warnings always go to stderr so pipes stay parseable.
## What gog does
- **One binary, every API.** Gmail, Calendar, Drive, Docs, Sheets, Slides, Forms, Apps Script, Contacts, People, Tasks, Classroom, Chat, Groups, Keep, and Workspace Admin.
- **Stable output.** `--json` for scripts, `--plain` TSV for `awk`, human output on stderr.
- **Multi-account, multi-client.** Many Google accounts and OAuth client projects in one config; OAuth, direct access tokens, ADC, and Workspace service accounts all supported.
- **Built for agents.** Runtime allow/deny lists (`--enable-commands`, `--disable-commands`, `--gmail-no-send`) plus baked safety-profile binaries that cannot be reconfigured at runtime.
- **Read-only audits.** Drive `tree`, `du`, `inventory`; Contacts `dedupe` preview; raw API JSON dumps without ever mutating remote state.
- **Generated reference.** Every command has a docs page produced from `gog schema --json`.
## Pick your path
- **Trying it.** [Install](install.md) → [Quickstart](quickstart.md). Five minutes from `brew install` to your first authenticated query.
- **Wiring up an agent.** [Safety Profiles](safety-profiles.md) and the bundled [`gog` agent skill](https://github.com/steipete/gogcli/blob/main/.agents/skills/gog/SKILL.md). Lock the binary down before handing it to a model.
- **Running Workspace at scale.** [Auth Clients](auth-clients.md) for service accounts, named OAuth clients, and domain-wide delegation.
- **Backing up an account.** [Backup](backup.md) before pointing `gog backup push` at a busy mailbox.
- **Looking up a flag.** The [Command Index](commands/) has a generated page for every subcommand.
## Project
Active development; the [changelog](https://github.com/steipete/gogcli/blob/main/CHANGELOG.md) tracks what shipped recently. Goals and non-goals live in the [spec](spec.md). Released under the [MIT license](https://github.com/steipete/gogcli/blob/main/LICENSE). Not affiliated with Google.

124
docs/install.md Normal file
View File

@ -0,0 +1,124 @@
# Install
`gog` ships as a single binary. The visible version is injected at build time:
release builds use the tag, while local builds use `git describe`.
## Homebrew (macOS, Linux)
```bash
brew install gogcli
gog --version
```
The Homebrew formula lives in `steipete/homebrew-tap` and installs the `gog`
binary. Release verification should run:
```bash
brew test steipete/tap/gogcli
gog --version
```
## Docker / GHCR
Release tags publish a non-root GitHub Container Registry image:
```bash
docker run --rm ghcr.io/steipete/gogcli:latest version
docker run --rm ghcr.io/steipete/gogcli:v0.15.0 version
```
Authenticated container runs should mount a persistent config directory and
use the encrypted file keyring:
```bash
docker volume create gogcli-config
docker run --rm -it \
-e GOG_KEYRING_BACKEND=file \
-e GOG_KEYRING_PASSWORD \
-v gogcli-config:/home/gog/.config/gogcli \
ghcr.io/steipete/gogcli:latest \
auth add you@gmail.com --services gmail,calendar,drive
```
Keep `GOG_KEYRING_PASSWORD` in the shell session or your CI secret store. Do
not bake it into images, scripts, or checked-in profiles.
## Windows
Download the matching ZIP from the
[latest release](https://github.com/steipete/gogcli/releases):
- `gogcli_<version>_windows_amd64.zip`
- `gogcli_<version>_windows_arm64.zip`
Extract `gog.exe` and put its directory on `PATH`.
## GitHub releases (raw binaries)
Release assets are uploaded by GoReleaser:
- `gogcli_<version>_darwin_amd64.tar.gz`
- `gogcli_<version>_darwin_arm64.tar.gz`
- `gogcli_<version>_linux_amd64.tar.gz`
- `gogcli_<version>_linux_arm64.tar.gz`
- `gogcli_<version>_windows_amd64.zip`
- `gogcli_<version>_windows_arm64.zip`
- `checksums.txt`
Browse the [releases page](https://github.com/steipete/gogcli/releases) for
the latest tag and the full asset list.
## Build from source
```bash
git clone https://github.com/steipete/gogcli.git
cd gogcli
make
./bin/gog --version
```
Source builds require the Go version declared in `go.mod`.
## Safety-profile binaries
When `gog` is going to be invoked by an agent, sandbox, or other caller that
should not be able to broaden its own permissions, build a safety-profile
binary instead of the default one. See [Safety Profiles](safety-profiles.md).
```bash
./build-safe.sh safety-profiles/agent-safe.yaml -o bin/gog-agent-safe
./build-safe.sh safety-profiles/readonly.yaml -o bin/gog-readonly
```
## Verify the install
```bash
gog --version
gog auth keyring # report current keyring backend
gog --help # discover top-level commands
```
After running [`gog auth credentials`](commands/gog-auth-credentials.md) and
[`gog auth add`](commands/gog-auth-add.md), `gog auth doctor --check` reports
keyring health, refresh-token validity, and Workspace-specific failure modes.
## Updating
- **Homebrew:** `brew upgrade gogcli`.
- **Docker:** pull a new tag (`ghcr.io/steipete/gogcli:vX.Y.Z`).
- **GitHub release archives:** download the new tarball/ZIP and replace the
binary.
- **Source builds:** `git pull && make` — the version string comes from
`git describe`.
Refresh tokens and OAuth clients are forward-compatible across point releases;
no migration step is required for normal upgrades.
## Related command pages
- [`gog version`](commands/gog-version.md)
- [`gog auth keyring`](commands/gog-auth-keyring.md)
- [`gog auth credentials`](commands/gog-auth-credentials.md)
- [`gog auth add`](commands/gog-auth-add.md)
- [`gog auth doctor`](commands/gog-auth-doctor.md)

131
docs/quickstart.md Normal file
View File

@ -0,0 +1,131 @@
---
title: Quickstart
description: "Five minutes from a clean machine to a working gog setup with one Google account."
---
# Quickstart
Five minutes from a clean machine to authenticated Gmail, Calendar, and Drive
queries. For a deeper look at OAuth clients, service accounts, and named
profiles, read [Auth Clients](auth-clients.md) after this.
## 1. Install
```bash
brew install gogcli
gog --version
```
Other options (Docker, Windows ZIPs, source builds) are documented on
[Install](install.md).
## 2. Get an OAuth client
`gog` talks to Google APIs as you, using your own Cloud project. The one-time
setup is:
1. Open <https://console.cloud.google.com/projectcreate> and create a project.
2. Enable the APIs you intend to use: Gmail, Calendar, Drive, Docs, Sheets,
Slides, Forms, Apps Script, People (Contacts), Tasks, Classroom — whatever
you actually need. The [API library](https://console.cloud.google.com/apis/library)
is the fastest way to enable several at once.
3. Configure the [OAuth consent screen](https://console.cloud.google.com/auth/branding)
for "External" + your email; that is enough for personal use.
4. Create a **Desktop app** OAuth client at
<https://console.cloud.google.com/auth/clients> and download the JSON.
Personal `gmail.com` accounts work for normal user APIs (Gmail, Calendar,
Drive, Docs, Sheets, Slides, Forms, Apps Script, Contacts/People, Tasks,
Classroom). Workspace-only APIs (Admin Directory, Cloud Identity Groups, Chat,
Keep with domain-wide delegation) require a managed domain — see
[Auth Clients](auth-clients.md).
> External + Testing OAuth apps issue refresh tokens that expire after seven
> days. Publish the OAuth app for long-lived tokens, or be ready to re-run
> `gog auth add` weekly.
## 3. Store the OAuth client
```bash
gog auth credentials ~/Downloads/client_secret_*.json
```
The file is copied to your per-user config (`$XDG_CONFIG_HOME/gogcli/` or the
OS-equivalent) with mode `0600`.
## 4. Authorize an account
```bash
gog auth add you@gmail.com --services gmail,calendar,drive,docs,sheets,contacts
```
A browser tab opens, you grant the requested scopes, and `gog` stores a
refresh token in your OS keyring (Keychain on macOS, Secret Service on Linux,
Credential Manager on Windows). Headless? Add `--manual` for a paste-the-URL
flow, or `--remote --step 1`/`--step 2` for fully split server runs.
Verify:
```bash
gog auth list --check
gog auth doctor --check
```
## 5. Set a default account
```bash
export GOG_ACCOUNT=you@gmail.com
# or persist a default with gog auth alias
gog auth alias set default you@gmail.com
```
Now you can drop `--account` from every command.
## 6. Run real commands
```bash
# Gmail
gog gmail search 'newer_than:7d' --max 10
gog gmail get <messageId> --sanitize-content --json
# Calendar
gog calendar events --today
gog calendar create --summary "Review" \
--from "2026-05-06T10:00:00+02:00" \
--to "2026-05-06T10:30:00+02:00"
# Drive
gog drive ls --max 20
gog drive tree --parent <folderId> --depth 2
gog drive du --parent <folderId> --max 20 --json
# Docs / Sheets / Slides
gog docs cat <docId> --tab "Notes"
gog sheets get <spreadsheetId> 'Sheet1!A1:D20' --json
gog slides create-from-markdown "Weekly update" --content-file slides.md
# Profile
gog me
```
`--json` produces a stable JSON envelope on stdout; `--plain` produces TSV.
Human-facing progress, hints, and warnings always go to stderr, so pipes stay
parseable.
## 7. Shell completion (optional)
```bash
gog completion bash >> ~/.bash_completion
gog completion zsh > "${fpath[1]}/_gog"
gog completion fish > ~/.config/fish/completions/gog.fish
```
## Where next
- [Auth Clients](auth-clients.md) — named clients, service accounts, ADC,
Workspace domain-wide delegation, OIDC subject migration.
- [Safety Profiles](safety-profiles.md) — runtime allow/deny lists and baked
agent-safe binaries.
- [Gmail Workflows](gmail-workflows.md) and [Drive Audits](drive-audits.md) for
the two surfaces most people start automating.
- [Command Index](commands/) — generated reference for every subcommand.

64
docs/raw-api.md Normal file
View File

@ -0,0 +1,64 @@
# Raw API Dumps
read_when:
- Using `gog <service> raw` commands for lossless Google API JSON.
- Passing Google API responses into scripts, debuggers, or LLM workflows.
- Reviewing sensitive-field behavior for raw output.
Raw commands return the canonical Google API response shape instead of gog's
normal curated table/JSON output. They are useful when a script needs a field
that gog does not model yet, or when debugging an API object exactly as Google
returns it.
## Commands
- [`gog calendar raw`](commands/gog-calendar-raw.md)
- [`gog contacts raw`](commands/gog-contacts-raw.md)
- [`gog docs raw`](commands/gog-docs-raw.md)
- [`gog drive raw`](commands/gog-drive-raw.md)
- [`gog forms raw`](commands/gog-forms-raw.md)
- [`gog gmail raw`](commands/gog-gmail-raw.md)
- [`gog people raw`](commands/gog-people-raw.md)
- [`gog sheets raw`](commands/gog-sheets-raw.md)
- [`gog slides raw`](commands/gog-slides-raw.md)
- [`gog tasks raw`](commands/gog-tasks-raw.md)
## Examples
```bash
gog drive raw <fileId> --pretty
gog docs raw <docId> --json > doc-api.json
gog gmail raw <messageId> --format metadata --json
gog sheets raw <spreadsheetId> --include-grid-data --json
```
Use service-native field masks when available:
```bash
gog drive raw <fileId> --fields 'id,name,mimeType,owners(emailAddress)' --json
gog contacts raw people/c123 --person-fields names,emailAddresses,phoneNumbers --json
```
## Safety Model
Raw output is intentionally less opinionated than normal gog output. It may
include private document content, contact data, event attendees, Gmail payloads,
or service-specific metadata.
Drive has the highest capability-URL risk. By default, `gog drive raw` redacts
fields such as `thumbnailLink`, `webContentLink`, `exportLinks`, `resourceKey`,
`properties`, `appProperties`, and embedded thumbnail bytes unless the user
explicitly names fields via `--fields`.
Sheets warns when grid data or developer metadata could expose sensitive data,
but keeps output lossless. Docs and Slides may include short-lived image URLs.
For the full sensitive-field review, read [Raw API Sensitive Field Audit](raw-audit.md).
## Automation Tips
- Prefer `--json` for scripts.
- Prefer `--pretty` for humans.
- Use narrow `--fields` or service-specific field masks whenever possible.
- Do not pipe raw output into logs or LLMs unless you are comfortable with the
object's full Google API payload.

BIN
docs/social-card.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

114
docs/social-card.svg Normal file
View File

@ -0,0 +1,114 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-labelledby="title desc">
<title id="title">gog social card</title>
<desc id="desc">gog: Google Workspace in your terminal. One Go CLI for Gmail, Calendar, Drive, Docs, Sheets, Slides, Forms, Apps Script, Contacts, Tasks, and Workspace admin.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#fbfcff"/>
<stop offset="0.55" stop-color="#f3f7fd"/>
<stop offset="1" stop-color="#eef3fa"/>
</linearGradient>
<linearGradient id="panel" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#0f172a"/>
<stop offset="1" stop-color="#0a0f1f"/>
</linearGradient>
<linearGradient id="panelBar" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1b2438"/>
<stop offset="1" stop-color="#141c2e"/>
</linearGradient>
<linearGradient id="googleSweep" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#4285f4"/>
<stop offset="0.34" stop-color="#ea4335"/>
<stop offset="0.67" stop-color="#fbbc04"/>
<stop offset="1" stop-color="#34a853"/>
</linearGradient>
<filter id="shadow" x="-10%" y="-10%" width="120%" height="130%">
<feDropShadow dx="0" dy="24" stdDeviation="28" flood-color="#0b1430" flood-opacity="0.18"/>
</filter>
</defs>
<rect width="1200" height="630" fill="url(#bg)"/>
<!-- Brand mark (top-left): big rounded 2x2 inside a tile -->
<g transform="translate(76 76)">
<rect x="0" y="0" width="118" height="118" rx="26" fill="#0f1115"/>
<rect x="22" y="22" width="32" height="32" rx="6" fill="#4285f4"/>
<rect x="64" y="22" width="32" height="32" rx="6" fill="#ea4335"/>
<rect x="22" y="64" width="32" height="32" rx="6" fill="#fbbc04"/>
<rect x="64" y="64" width="32" height="32" rx="6" fill="#34a853"/>
</g>
<!-- Title -->
<text x="76" y="270" fill="#0f1115" font-family="Inter, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="92" font-weight="800" letter-spacing="0">gog</text>
<!-- Tagline (one strong line) -->
<text x="80" y="332" fill="#0f1115" font-family="Inter, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="34" font-weight="700" letter-spacing="0">Google in your terminal.</text>
<!-- Description (two lines) -->
<text x="80" y="382" fill="#475569" font-family="Inter, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="22" font-weight="500" letter-spacing="0">One Go CLI for Gmail, Calendar, Drive, Docs,</text>
<text x="80" y="412" fill="#475569" font-family="Inter, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="22" font-weight="500" letter-spacing="0">Sheets, Slides, Contacts, Tasks, and admin.</text>
<!-- Multi-color accent bar -->
<rect x="80" y="438" width="280" height="4" rx="2" fill="url(#googleSweep)"/>
<!-- Bottom row: install pill + URL pill -->
<g transform="translate(80 478)">
<!-- install pill -->
<rect x="0" y="0" width="320" height="48" rx="11" fill="#0f172a"/>
<text x="20" y="32" fill="#64748b" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="18" font-weight="500">$</text>
<text x="42" y="32" fill="#e6edf3" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="18" font-weight="600">brew install gogcli</text>
<!-- URL pill -->
<rect x="340" y="0" width="180" height="48" rx="11" fill="#ffffff" stroke="#cbd5e1"/>
<text x="362" y="32" fill="#0f1115" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="18" font-weight="600">gogcli.sh</text>
</g>
<!-- Right-side terminal mockup -->
<g transform="translate(670 142)" filter="url(#shadow)">
<rect x="0" y="0" width="464" height="346" rx="20" fill="url(#panel)"/>
<rect x="0" y="0" width="464" height="42" rx="20" fill="url(#panelBar)"/>
<rect x="0" y="22" width="464" height="20" fill="url(#panelBar)"/>
<circle cx="24" cy="21" r="6" fill="#ea4335"/>
<circle cx="46" cy="21" r="6" fill="#fbbc04"/>
<circle cx="68" cy="21" r="6" fill="#34a853"/>
<text x="232" y="27" fill="#94a3b8" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13" font-weight="500" text-anchor="middle">gog — you@gmail.com</text>
<!-- Terminal content -->
<text x="22" y="80" fill="#93c5fd" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="15" font-weight="600">$ gog gmail search 'newer_than:7d' --max 3</text>
<text x="22" y="110" fill="#cbd5e1" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13">Mary Lou Re: review notes 2h</text>
<text x="22" y="132" fill="#cbd5e1" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13">GitHub PR #547 ready 5h</text>
<text x="22" y="154" fill="#cbd5e1" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13">Calendar Standup at 10:00 1d</text>
<text x="22" y="194" fill="#93c5fd" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="15" font-weight="600">$ gog calendar events --today --json</text>
<rect x="22" y="212" width="420" height="118" rx="10" fill="#0a1024" stroke="#1f2937"/>
<text x="38" y="238" fill="#fbbf24" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13">{</text>
<text x="54" y="258" fill="#a5b4fc" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13">"events": [</text>
<text x="74" y="278" fill="#a7f3d0" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13">{ "summary": "Standup", "start": "10:00" },</text>
<text x="74" y="298" fill="#a7f3d0" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13">{ "summary": "Review", "start": "14:30" }</text>
<text x="54" y="318" fill="#a5b4fc" font-family="JetBrains Mono, SFMono-Regular, Menlo, monospace" font-size="13">]</text>
</g>
<!-- Service pills below terminal -->
<g transform="translate(670 514)" font-family="Inter, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="14" font-weight="600">
<g>
<rect x="0" y="0" width="74" height="30" rx="15" fill="#ffffff" stroke="#dbe2eb"/>
<text x="37" y="20" fill="#4285f4" text-anchor="middle">Gmail</text>
</g>
<g transform="translate(86 0)">
<rect x="0" y="0" width="92" height="30" rx="15" fill="#ffffff" stroke="#dbe2eb"/>
<text x="46" y="20" fill="#34a853" text-anchor="middle">Calendar</text>
</g>
<g transform="translate(190 0)">
<rect x="0" y="0" width="64" height="30" rx="15" fill="#ffffff" stroke="#dbe2eb"/>
<text x="32" y="20" fill="#ea4335" text-anchor="middle">Drive</text>
</g>
<g transform="translate(266 0)">
<rect x="0" y="0" width="74" height="30" rx="15" fill="#ffffff" stroke="#dbe2eb"/>
<text x="37" y="20" fill="#fbbc04" text-anchor="middle">Sheets</text>
</g>
<g transform="translate(352 0)">
<rect x="0" y="0" width="60" height="30" rx="15" fill="#ffffff" stroke="#dbe2eb"/>
<text x="30" y="20" fill="#4285f4" text-anchor="middle">Docs</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

@ -58,19 +58,9 @@ func (c *AdminGroupsListCmd) Run(ctx context.Context, flags *RootFlags) error {
return resp.Groups, resp.NextPageToken, nil
}
var groups []*admin.Group
nextPageToken := ""
if c.All {
all, collectErr := collectAllPages(c.Page, fetch)
if collectErr != nil {
return collectErr
}
groups = all
} else {
groups, nextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
groups, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
@ -176,19 +166,9 @@ func (c *AdminGroupsMembersListCmd) Run(ctx context.Context, flags *RootFlags) e
return resp.Members, resp.NextPageToken, nil
}
var members []*admin.Member
nextPageToken := ""
if c.All {
all, collectErr := collectAllPages(c.Page, fetch)
if collectErr != nil {
return collectErr
}
members = all
} else {
members, nextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
members, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {

View File

@ -60,19 +60,9 @@ func (c *AdminUsersListCmd) Run(ctx context.Context, flags *RootFlags) error {
return resp.Users, resp.NextPageToken, nil
}
var users []*admin.User
nextPageToken := ""
if c.All {
all, collectErr := collectAllPages(c.Page, fetch)
if collectErr != nil {
return collectErr
}
users = all
} else {
users, nextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
users, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {

View File

@ -51,20 +51,9 @@ func listCalendarEvents(ctx context.Context, svc *calendar.Service, calendarID,
return resp.Items, resp.NextPageToken, nil
}
var items []*calendar.Event
nextPageToken := ""
if allPages {
all, err := collectAllPages(page, fetch)
if err != nil {
return err
}
items = all
} else {
var err error
items, nextPageToken, err = fetch(page)
if err != nil {
return err
}
items, nextPageToken, err := loadPagedItems(page, allPages, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
@ -164,21 +153,10 @@ func listCalendarIDsEvents(ctx context.Context, svc *calendar.Service, calendarI
return resp.Items, resp.NextPageToken, nil
}
var events []*calendar.Event
var err error
if allPages {
allEvents, collectErr := collectAllPages(page, fetch)
if collectErr != nil {
u.Err().Printf("calendar %s: %v", calID, collectErr)
continue
}
events = allEvents
} else {
events, _, err = fetch(page)
if err != nil {
u.Err().Printf("calendar %s: %v", calID, err)
continue
}
events, _, err := loadPagedItems(page, allPages, fetch)
if err != nil {
u.Err().Printf("calendar %s: %v", calID, err)
continue
}
for _, e := range events {

View File

@ -43,19 +43,9 @@ func (c *CalendarCalendarsCmd) Run(ctx context.Context, flags *RootFlags) error
return r.Items, r.NextPageToken, nil
}
var items []*calendar.CalendarListEntry
nextPageToken := ""
if c.All {
all, collectErr := collectAllPages(c.Page, fetch)
if collectErr != nil {
return collectErr
}
items = all
} else {
items, nextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
items, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
@ -173,19 +163,9 @@ func (c *CalendarAclCmd) Run(ctx context.Context, flags *RootFlags) error {
return r.Items, r.NextPageToken, nil
}
var items []*calendar.AclRule
nextPageToken := ""
if c.All {
all, collectErr := collectAllPages(c.Page, fetch)
if collectErr != nil {
return collectErr
}
items = all
} else {
items, nextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
items, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{

View File

@ -2,10 +2,6 @@ package cmd
import (
"context"
"errors"
"os"
"github.com/steipete/gogcli/internal/outfmt"
)
// CalendarRawCmd dumps the full Events.Get response as JSON, using the
@ -44,9 +40,10 @@ func (c *CalendarRawCmd) Run(ctx context.Context, flags *RootFlags) error {
if err != nil {
return err
}
if event == nil {
return errors.New("event not found")
event, err = requireRawResponse(event, "event not found")
if err != nil {
return err
}
return outfmt.WriteRaw(ctx, os.Stdout, event, outfmt.RawOptions{Pretty: c.Pretty})
return writeRawJSON(ctx, event, c.Pretty)
}

View File

@ -1,7 +1,6 @@
package cmd
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@ -9,7 +8,6 @@ import (
"testing"
"google.golang.org/api/calendar/v3"
"google.golang.org/api/option"
)
func newCalendarRawTestServer(t *testing.T, status int, body map[string]any) *httptest.Server {
@ -42,18 +40,8 @@ func newCalendarRawTestServer(t *testing.T, status int, body map[string]any) *ht
func installMockCalendarService(t *testing.T, srv *httptest.Server) {
t.Helper()
orig := newCalendarService
t.Cleanup(func() { newCalendarService = orig })
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", calendar.NewService)
stubGoogleTestService(t, &newCalendarService, svc)
}
func fullCalendarEventResponse(id string) map[string]any {

View File

@ -4,11 +4,9 @@ import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"google.golang.org/api/calendar/v3"
"google.golang.org/api/option"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
@ -17,17 +15,7 @@ import (
func newCalendarServiceForTest(t *testing.T, h http.Handler) (*calendar.Service, func()) {
t.Helper()
srv := httptest.NewServer(h)
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
srv.Close()
t.Fatalf("NewService: %v", err)
}
return svc, srv.Close
return newGoogleTestService(t, h, calendar.NewService)
}
func newTestCalendarService(t *testing.T, h http.Handler) (*calendar.Service, func()) {
@ -37,9 +25,7 @@ func newTestCalendarService(t *testing.T, h http.Handler) (*calendar.Service, fu
func stubCalendarServiceForTest(t *testing.T, svc *calendar.Service) {
t.Helper()
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
stubGoogleTestService(t, &newCalendarService, svc)
}
func newCalendarOutputContext(t *testing.T, stdout, stderr io.Writer) context.Context {

View File

@ -50,31 +50,20 @@ func (c *CalendarUsersCmd) Run(ctx context.Context, flags *RootFlags) error {
if strings.TrimSpace(pageToken) != "" {
call = call.PageToken(pageToken)
}
resp, err := call.Do()
if err != nil {
if strings.Contains(err.Error(), "accessNotConfigured") ||
strings.Contains(err.Error(), "People API has not been used") {
return nil, "", fmt.Errorf("people API is not enabled; enable it at: https://console.developers.google.com/apis/api/people.googleapis.com/overview (%w)", err)
resp, callErr := call.Do()
if callErr != nil {
if strings.Contains(callErr.Error(), "accessNotConfigured") ||
strings.Contains(callErr.Error(), "People API has not been used") {
return nil, "", fmt.Errorf("people API is not enabled; enable it at: https://console.developers.google.com/apis/api/people.googleapis.com/overview (%w)", callErr)
}
return nil, "", err
return nil, "", callErr
}
return resp.People, resp.NextPageToken, nil
}
var peopleList []*people.Person
nextPageToken := ""
if c.All {
all, err := collectAllPages(c.Page, fetch)
if err != nil {
return err
}
peopleList = all
} else {
var err error
peopleList, nextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
peopleList, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {

View File

@ -83,27 +83,16 @@ func (c *ChatMessagesListCmd) Run(ctx context.Context, flags *RootFlags) error {
if filter != "" {
call = call.Filter(filter)
}
resp, err := call.Do()
if err != nil {
return nil, "", err
resp, callErr := call.Do()
if callErr != nil {
return nil, "", callErr
}
return resp.Messages, resp.NextPageToken, nil
}
var messages []*chat.Message
nextPageToken := ""
if c.All {
all, err := collectAllPages(c.Page, fetch)
if err != nil {
return err
}
messages = all
} else {
var err error
messages, nextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
messages, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {

View File

@ -120,27 +120,16 @@ func (c *ChatMessagesReactionsListCmd) Run(ctx context.Context, flags *RootFlags
if strings.TrimSpace(pageToken) != "" {
call = call.PageToken(pageToken)
}
resp, err := call.Do()
if err != nil {
return nil, "", err
resp, callErr := call.Do()
if callErr != nil {
return nil, "", callErr
}
return resp.Reactions, resp.NextPageToken, nil
}
var reactions []*chat.Reaction
nextPageToken := ""
if c.All {
all, err := collectAllPages(c.Page, fetch)
if err != nil {
return err
}
reactions = all
} else {
var err error
reactions, nextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
reactions, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {

View File

@ -52,20 +52,9 @@ func (c *ChatSpacesListCmd) Run(ctx context.Context, flags *RootFlags) error {
return resp.Spaces, resp.NextPageToken, nil
}
var spaces []*chat.Space
nextPageToken := ""
if c.All {
all, err := collectAllPages(c.Page, fetch)
if err != nil {
return err
}
spaces = all
} else {
var err error
spaces, nextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
spaces, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {

View File

@ -52,27 +52,16 @@ func (c *ChatThreadsListCmd) Run(ctx context.Context, flags *RootFlags) error {
if strings.TrimSpace(pageToken) != "" {
call = call.PageToken(pageToken)
}
resp, err := call.Do()
if err != nil {
return nil, "", err
resp, callErr := call.Do()
if callErr != nil {
return nil, "", callErr
}
return resp.Messages, resp.NextPageToken, nil
}
var messages []*chat.Message
nextPageToken := ""
if c.All {
all, err := collectAllPages(c.Page, fetch)
if err != nil {
return err
}
messages = all
} else {
var err error
messages, nextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
messages, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
threads := make([]*chatMessageThreadItem, 0, len(messages))

View File

@ -65,27 +65,16 @@ func (c *ClassroomCoursesListCmd) Run(ctx context.Context, flags *RootFlags) err
if v := strings.TrimSpace(c.StudentID); v != "" {
call.StudentId(v)
}
resp, err := call.Do()
if err != nil {
return nil, "", wrapClassroomError(err)
resp, callErr := call.Do()
if callErr != nil {
return nil, "", wrapClassroomError(callErr)
}
return resp.Courses, resp.NextPageToken, nil
}
var courses []*classroom.Course
nextPageToken := ""
if c.All {
all, err := collectAllPages(c.Page, fetch)
if err != nil {
return err
}
courses = all
} else {
var err error
courses, nextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
courses, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {

View File

@ -71,7 +71,7 @@ func (c *ClassroomCourseworkListCmd) Run(ctx context.Context, flags *RootFlags)
var coursework []*classroom.CourseWork
var nextPageToken string
if c.All {
all, err := collectAllPages(c.Page, fetch)
all, _, err := loadPagedItems(c.Page, true, fetch)
if err != nil {
return wrapClassroomError(err)
}

View File

@ -51,27 +51,16 @@ func (c *ClassroomGuardiansListCmd) Run(ctx context.Context, flags *RootFlags) e
if v := strings.TrimSpace(c.Email); v != "" {
call.InvitedEmailAddress(v)
}
resp, err := call.Do()
if err != nil {
return nil, "", wrapClassroomError(err)
resp, callErr := call.Do()
if callErr != nil {
return nil, "", wrapClassroomError(callErr)
}
return resp.Guardians, resp.NextPageToken, nil
}
var guardians []*classroom.Guardian
nextPageToken := ""
if c.All {
all, err := collectAllPages(c.Page, fetch)
if err != nil {
return err
}
guardians = all
} else {
var err error
guardians, nextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
guardians, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
@ -238,27 +227,16 @@ func (c *ClassroomGuardianInvitesListCmd) Run(ctx context.Context, flags *RootFl
}
call.States(upper...)
}
resp, err := call.Do()
if err != nil {
return nil, "", wrapClassroomError(err)
resp, callErr := call.Do()
if callErr != nil {
return nil, "", wrapClassroomError(callErr)
}
return resp.GuardianInvitations, resp.NextPageToken, nil
}
var invitations []*classroom.GuardianInvitation
nextPageToken := ""
if c.All {
all, err := collectAllPages(c.Page, fetch)
if err != nil {
return err
}
invitations = all
} else {
var err error
invitations, nextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
invitations, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {

View File

@ -53,27 +53,16 @@ func (c *ClassroomInvitationsListCmd) Run(ctx context.Context, flags *RootFlags)
call.UserId(v)
}
resp, err := call.Do()
if err != nil {
return nil, "", wrapClassroomError(err)
resp, callErr := call.Do()
if callErr != nil {
return nil, "", wrapClassroomError(callErr)
}
return resp.Invitations, resp.NextPageToken, nil
}
var invitations []*classroom.Invitation
nextPageToken := ""
if c.All {
all, err := collectAllPages(c.Page, fetch)
if err != nil {
return err
}
invitations = all
} else {
var err error
invitations, nextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
invitations, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {

View File

@ -10,14 +10,7 @@ import (
)
func fetchClassroomPagedList[T any](all bool, page string, fetch func(string) ([]*T, string, error)) ([]*T, string, error) {
if all {
items, err := collectAllPages(page, fetch)
if err != nil {
return nil, "", err
}
return items, "", nil
}
return fetch(page)
return loadPagedItems(page, all, fetch)
}
func writeClassroomPagedList[T any](

View File

@ -70,7 +70,7 @@ func (c *ClassroomMaterialsListCmd) Run(ctx context.Context, flags *RootFlags) e
var materials []*classroom.CourseWorkMaterial
var nextPageToken string
if c.All {
all, err := collectAllPages(c.Page, fetch)
all, _, err := loadPagedItems(c.Page, true, fetch)
if err != nil {
return wrapClassroomError(err)
}

View File

@ -48,27 +48,16 @@ func (c *ClassroomStudentsListCmd) Run(ctx context.Context, flags *RootFlags) er
if strings.TrimSpace(pageToken) != "" {
call = call.PageToken(pageToken)
}
resp, err := call.Do()
if err != nil {
return nil, "", wrapClassroomError(err)
resp, callErr := call.Do()
if callErr != nil {
return nil, "", wrapClassroomError(callErr)
}
return resp.Students, resp.NextPageToken, nil
}
var students []*classroom.Student
nextPageToken := ""
if c.All {
all, err := collectAllPages(c.Page, fetch)
if err != nil {
return err
}
students = all
} else {
var err error
students, nextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
students, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
@ -280,27 +269,16 @@ func (c *ClassroomTeachersListCmd) Run(ctx context.Context, flags *RootFlags) er
if strings.TrimSpace(pageToken) != "" {
call = call.PageToken(pageToken)
}
resp, err := call.Do()
if err != nil {
return nil, "", wrapClassroomError(err)
resp, callErr := call.Do()
if callErr != nil {
return nil, "", wrapClassroomError(callErr)
}
return resp.Teachers, resp.NextPageToken, nil
}
var teachers []*classroom.Teacher
nextPageToken := ""
if c.All {
all, err := collectAllPages(c.Page, fetch)
if err != nil {
return err
}
teachers = all
} else {
var err error
teachers, nextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
teachers, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
@ -477,7 +455,6 @@ type ClassroomRosterCmd struct {
FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"`
}
//nolint:gocyclo,cyclop // command orchestration across two role paths
func (c *ClassroomRosterCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
@ -514,17 +491,9 @@ func (c *ClassroomRosterCmd) Run(ctx context.Context, flags *RootFlags) error {
}
return resp.Students, resp.NextPageToken, nil
}
if c.All {
all, collectErr := collectAllPages(c.Page, fetch)
if collectErr != nil {
return collectErr
}
students = all
} else {
students, studentsNextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
students, studentsNextPageToken, err = loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
}
if includeTeachers {
@ -539,17 +508,9 @@ func (c *ClassroomRosterCmd) Run(ctx context.Context, flags *RootFlags) error {
}
return resp.Teachers, resp.NextPageToken, nil
}
if c.All {
all, collectErr := collectAllPages(c.Page, fetch)
if collectErr != nil {
return collectErr
}
teachers = all
} else {
teachers, teachersNextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
teachers, teachersNextPageToken, err = loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
}

View File

@ -79,27 +79,16 @@ func (c *ClassroomSubmissionsListCmd) Run(ctx context.Context, flags *RootFlags)
}
}
resp, err := call.Do()
if err != nil {
return nil, "", wrapClassroomError(err)
resp, callErr := call.Do()
if callErr != nil {
return nil, "", wrapClassroomError(callErr)
}
return resp.StudentSubmissions, resp.NextPageToken, nil
}
var submissions []*classroom.StudentSubmission
nextPageToken := ""
if c.All {
all, err := collectAllPages(c.Page, fetch)
if err != nil {
return err
}
submissions = all
} else {
var err error
submissions, nextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
submissions, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {

View File

@ -55,27 +55,16 @@ func (c *ContactsDirectoryListCmd) Run(ctx context.Context, flags *RootFlags) er
call = call.PageToken(pageToken)
}
resp, err := call.Do()
if err != nil {
return nil, "", err
resp, callErr := call.Do()
if callErr != nil {
return nil, "", callErr
}
return resp.People, resp.NextPageToken, nil
}
var peopleList []*people.Person
nextPageToken := ""
if c.All {
all, err := collectAllPages(c.Page, fetch)
if err != nil {
return err
}
peopleList = all
} else {
var err error
peopleList, nextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
peopleList, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
type item struct {
@ -162,27 +151,16 @@ func (c *ContactsDirectorySearchCmd) Run(ctx context.Context, flags *RootFlags)
if strings.TrimSpace(pageToken) != "" {
call = call.PageToken(pageToken)
}
resp, err := call.Do()
if err != nil {
return nil, "", err
resp, callErr := call.Do()
if callErr != nil {
return nil, "", callErr
}
return resp.People, resp.NextPageToken, nil
}
var peopleList []*people.Person
nextPageToken := ""
if c.All {
all, err := collectAllPages(c.Page, fetch)
if err != nil {
return err
}
peopleList = all
} else {
var err error
peopleList, nextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
peopleList, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
type item struct {
@ -268,27 +246,16 @@ func (c *ContactsOtherListCmd) Run(ctx context.Context, flags *RootFlags) error
if strings.TrimSpace(pageToken) != "" {
call = call.PageToken(pageToken)
}
resp, err := call.Do()
if err != nil {
return nil, "", err
resp, callErr := call.Do()
if callErr != nil {
return nil, "", callErr
}
return resp.OtherContacts, resp.NextPageToken, nil
}
var contacts []*people.Person
nextPageToken := ""
if c.All {
all, err := collectAllPages(c.Page, fetch)
if err != nil {
return err
}
contacts = all
} else {
var err error
contacts, nextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
contacts, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
type item struct {

View File

@ -71,11 +71,12 @@ func (c *DocsRawCmd) Run(ctx context.Context, flags *RootFlags) error {
}
return err
}
if doc == nil {
return errors.New("doc not found")
doc, err = requireRawResponse(doc, "doc not found")
if err != nil {
return err
}
return outfmt.WriteRaw(ctx, os.Stdout, doc, outfmt.RawOptions{Pretty: c.Pretty})
return writeRawJSON(ctx, doc, c.Pretty)
}
type DocsExportCmd struct {

View File

@ -2,13 +2,8 @@ package cmd
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
@ -58,15 +53,6 @@ const (
extMD = ".md"
extHTML = ".html"
formatAuto = literalAuto
driveShareToAnyone = "anyone"
driveShareToUser = "user"
driveShareToDomain = "domain"
// Drive sharing permission roles matching the Google Drive API roles.
// "commenter" allows view + comment access without edit rights.
drivePermRoleReader = "reader"
drivePermRoleWriter = "writer"
drivePermRoleCommenter = "commenter"
)
type DriveCmd struct {
@ -92,93 +78,6 @@ type DriveCmd struct {
Raw DriveRawCmd `cmd:"" name:"raw" help:"Dump raw Google Drive API response as JSON (Files.Get; lossless; for scripting and LLM consumption)"`
}
// driveRawSensitiveFields is the set of top-level File fields redacted from
// `gog drive raw` output when the user did not name them via --fields. See
// docs/raw-audit.md for the rationale per field.
var driveRawSensitiveFields = []string{
"thumbnailLink",
"webContentLink",
"exportLinks",
"resourceKey",
"appProperties",
"properties",
}
// DriveRawCmd dumps the full Files.Get response as JSON. Uses fields=* by
// default to expose the entire File resource. When --fields is absent the
// command redacts a small set of capability/token-shaped fields (see
// driveRawSensitiveFields); when --fields is explicitly set the response is
// returned verbatim, honoring exactly what the user asked for. This means
// passing `--fields "id,name,thumbnailLink"` returns thumbnailLink as
// requested.
//
// REST reference: https://developers.google.com/drive/api/reference/rest/v3/files/get
// Go type: https://pkg.go.dev/google.golang.org/api/drive/v3#File
type DriveRawCmd struct {
FileID string `arg:"" name:"fileId" help:"File ID"`
Fields string `name:"fields" help:"Drive API field mask (default: * with sensitive fields redacted client-side). Set explicitly to disable redaction."`
Pretty bool `name:"pretty" help:"Pretty-print JSON (default: compact single-line)"`
}
func (c *DriveRawCmd) Run(ctx context.Context, flags *RootFlags) error {
fileID := strings.TrimSpace(c.FileID)
if fileID == "" {
return usage("empty fileId")
}
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newDriveService(ctx, account)
if err != nil {
return err
}
userSetFields := strings.TrimSpace(c.Fields) != ""
mask := "*"
if userSetFields {
mask = c.Fields
}
f, err := svc.Files.Get(fileID).
SupportsAllDrives(true).
Fields(gapi.Field(mask)).
Context(ctx).
Do()
if err != nil {
return err
}
if f == nil {
return errors.New("file not found")
}
// Round-trip through JSON so we can redact by key when needed.
raw, err := json.Marshal(f)
if err != nil {
return fmt.Errorf("marshal drive file: %w", err)
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return fmt.Errorf("unmarshal drive file: %w", err)
}
// Redact only when the user did not explicitly request fields.
if !userSetFields {
for _, key := range driveRawSensitiveFields {
delete(m, key)
}
// contentHints.thumbnail.image is the one nested leak.
if hints, ok := m["contentHints"].(map[string]any); ok {
if thumb, ok := hints["thumbnail"].(map[string]any); ok {
delete(thumb, "image")
}
}
}
return outfmt.WriteRaw(ctx, os.Stdout, m, outfmt.RawOptions{Pretty: c.Pretty})
}
type DriveLsCmd struct {
Max int64 `name:"max" aliases:"limit" help:"Max results" default:"20"`
Page string `name:"page" aliases:"cursor" help:"Page token"`
@ -253,91 +152,6 @@ func (c *DriveGetCmd) Run(ctx context.Context, flags *RootFlags) error {
return nil
}
type DriveDownloadCmd struct {
FileID string `arg:"" name:"fileId" help:"File ID"`
Output OutputPathFlag `embed:""`
Format string `name:"format" help:"Export format for Google Docs files: pdf|csv|xlsx|pptx|txt|png|docx|md (default: inferred)"`
Tab string `name:"tab" help:"(experimental) Export a specific tab by title or ID (Google Docs only; see 'gog docs list-tabs')"`
}
func (c *DriveDownloadCmd) Run(ctx context.Context, flags *RootFlags) error {
account, err := requireAccount(flags)
if err != nil {
return err
}
fileID := normalizeGoogleID(strings.TrimSpace(c.FileID))
if fileID == "" {
return usage("empty fileId")
}
if tab := strings.TrimSpace(c.Tab); tab != "" {
if f := c.Format; f != "" && f != formatAuto {
if _, fmtErr := tabExportFormatParam(f); fmtErr != nil {
return fmt.Errorf("--tab limits export formats (pdf|docx|txt|md|html); %q is not supported with --tab", f)
}
}
return runDocsTabExport(ctx, flags, tabExportParams{
DocID: fileID,
OutFlag: c.Output.Path,
Format: c.Format,
TabQuery: tab,
})
}
u := ui.FromContext(ctx)
if formatErr := validateDriveDownloadFormatFlag(c.Format); formatErr != nil {
return formatErr
}
svc, err := newDriveService(ctx, account)
if err != nil {
return err
}
meta, err := svc.Files.Get(fileID).
SupportsAllDrives(true).
Fields("id, name, mimeType").
Context(ctx).
Do()
if err != nil {
return err
}
if meta.Name == "" {
return errors.New("file has no name")
}
if fileFormatErr := validateDriveDownloadFormatForFile(meta, c.Format); fileFormatErr != nil {
return fileFormatErr
}
destPath, err := resolveDriveDownloadDestPath(meta, c.Output.Path)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) && isStdoutPath(destPath) {
return usage("can't combine --json with --out -")
}
downloadedPath, size, err := downloadDriveFile(ctx, svc, meta, destPath, c.Format)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"path": downloadedPath,
"size": size,
})
}
if isStdoutPath(downloadedPath) {
return nil
}
u.Out().Printf("path\t%s", downloadedPath)
u.Out().Printf("size\t%s", formatDriveSize(size))
return nil
}
type DriveCopyCmd struct {
FileID string `arg:"" name:"fileId" help:"File ID"`
Name string `arg:"" name:"name" help:"New file name"`
@ -350,18 +164,6 @@ func (c *DriveCopyCmd) Run(ctx context.Context, flags *RootFlags) error {
}, c.FileID, c.Name, c.Parent)
}
type DriveUploadCmd struct {
LocalPath string `arg:"" name:"localPath" help:"Path to local file"`
Name string `name:"name" help:"Override filename (create) or rename target (replace)"`
Parent string `name:"parent" help:"Destination folder ID (create only)"`
ReplaceFileID string `name:"replace" help:"Replace the content of an existing Drive file ID (preserves shared link/permissions)"`
MimeType string `name:"mime-type" help:"Override MIME type inference"`
KeepRevisionForever bool `name:"keep-revision-forever" help:"Keep the new head revision forever (binary files only)"`
Convert bool `name:"convert" help:"Auto-convert to native Google format based on file extension (create only)"`
ConvertTo string `name:"convert-to" help:"Convert to a specific Google format: doc|sheet|slides (create only)"`
KeepFrontmatter bool `name:"keep-frontmatter" help:"Keep YAML frontmatter (---) in Markdown when converting to a Google Doc (--convert or --convert-to doc; default: strip)"`
}
type DriveMkdirCmd struct {
Name string `arg:"" name:"name" help:"Folder name"`
Parent string `name:"parent" help:"Parent folder ID"`
@ -568,312 +370,6 @@ func (c *DriveRenameCmd) Run(ctx context.Context, flags *RootFlags) error {
return nil
}
type DriveShareCmd struct {
FileID string `arg:"" name:"fileId" help:"File ID"`
To string `name:"to" help:"Share target: anyone|user|domain"`
Anyone bool `name:"anyone" hidden:"" help:"(deprecated) Use --to=anyone"`
Email string `name:"email" help:"User email (for --to=user)"`
Domain string `name:"domain" help:"Domain (for --to=domain; e.g. example.com)"`
Role string `name:"role" help:"Permission: reader|writer|commenter" default:"reader"`
Discoverable bool `name:"discoverable" help:"Allow file discovery in search (anyone/domain only)"`
}
type driveShareTarget struct {
to string
email string
domain string
}
func (c *DriveShareCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
fileID := strings.TrimSpace(c.FileID)
if fileID == "" {
return usage("empty fileId")
}
target, err := c.normalizeTarget()
if err != nil {
return err
}
role, err := normalizeDrivePermissionRole(c.Role)
if err != nil {
return err
}
if target.to == driveShareToAnyone {
if confirmErr := confirmDestructive(ctx, flags, fmt.Sprintf("share drive file %s with anyone (public)", fileID)); confirmErr != nil {
return confirmErr
}
}
svc, err := newDriveService(ctx, account)
if err != nil {
return err
}
perm := target.permission(role, c.Discoverable)
created, err := svc.Permissions.Create(fileID, perm).
SupportsAllDrives(true).
SendNotificationEmail(false).
Fields("id, type, role, emailAddress, domain, allowFileDiscovery").
Context(ctx).
Do()
if err != nil {
return err
}
link, err := driveWebLink(ctx, svc, fileID)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"link": link,
"permissionId": created.Id,
"permission": created,
})
}
u.Out().Printf("link\t%s", link)
u.Out().Printf("permission_id\t%s", created.Id)
return nil
}
func (c *DriveShareCmd) normalizeTarget() (driveShareTarget, error) {
to := strings.TrimSpace(c.To)
email := strings.TrimSpace(c.Email)
domain := strings.TrimSpace(c.Domain)
// Back-compat: allow legacy target flags without --to, but keep it unambiguous.
// New UX: prefer explicit --to + matching parameter.
if to == "" {
switch {
case c.Anyone && email == "" && domain == "":
to = driveShareToAnyone
case !c.Anyone && email != "" && domain == "":
to = driveShareToUser
case !c.Anyone && email == "" && domain != "":
to = driveShareToDomain
case !c.Anyone && email == "" && domain == "":
return driveShareTarget{}, usage("must specify --to (anyone|user|domain)")
default:
return driveShareTarget{}, usage("ambiguous share target (use --to=anyone|user|domain)")
}
}
switch to {
case driveShareToAnyone:
if email != "" || domain != "" {
return driveShareTarget{}, usage("--to=anyone cannot be combined with --email or --domain")
}
case driveShareToUser:
if email == "" {
return driveShareTarget{}, usage("missing --email for --to=user")
}
if domain != "" || c.Anyone {
return driveShareTarget{}, usage("--to=user cannot be combined with --anyone or --domain")
}
if c.Discoverable {
return driveShareTarget{}, usage("--discoverable is only valid for --to=anyone or --to=domain")
}
case driveShareToDomain:
if domain == "" {
return driveShareTarget{}, usage("missing --domain for --to=domain")
}
if email != "" || c.Anyone {
return driveShareTarget{}, usage("--to=domain cannot be combined with --anyone or --email")
}
default:
// Should be guarded by enum, but keep a friendly message for future changes.
return driveShareTarget{}, usage("invalid --to (expected anyone|user|domain)")
}
return driveShareTarget{to: to, email: email, domain: domain}, nil
}
func (target driveShareTarget) permission(role string, discoverable bool) *drive.Permission {
perm := &drive.Permission{Role: role}
switch target.to {
case driveShareToAnyone:
perm.Type = "anyone"
perm.AllowFileDiscovery = discoverable
case driveShareToDomain:
perm.Type = "domain"
perm.Domain = target.domain
perm.AllowFileDiscovery = discoverable
default:
perm.Type = "user"
perm.EmailAddress = target.email
}
return perm
}
func normalizeDrivePermissionRole(role string) (string, error) {
role = strings.TrimSpace(role)
if role == "" {
return drivePermRoleReader, nil
}
switch role {
case drivePermRoleReader, drivePermRoleWriter, drivePermRoleCommenter:
return role, nil
default:
return "", usage("invalid --role (expected reader|writer|commenter)")
}
}
type DriveUnshareCmd struct {
FileID string `arg:"" name:"fileId" help:"File ID"`
PermissionID string `arg:"" name:"permissionId" help:"Permission ID"`
}
func (c *DriveUnshareCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
fileID := strings.TrimSpace(c.FileID)
permissionID := strings.TrimSpace(c.PermissionID)
if fileID == "" {
return usage("empty fileId")
}
if permissionID == "" {
return usage("empty permissionId")
}
if confirmErr := confirmDestructive(ctx, flags, fmt.Sprintf("remove permission %s from drive file %s", permissionID, fileID)); confirmErr != nil {
return confirmErr
}
svc, err := newDriveService(ctx, account)
if err != nil {
return err
}
if err := svc.Permissions.Delete(fileID, permissionID).SupportsAllDrives(true).Context(ctx).Do(); err != nil {
return err
}
return writeResult(ctx, u,
kv("removed", true),
kv("fileId", fileID),
kv("permissionId", permissionID),
)
}
type DrivePermissionsCmd struct {
FileID string `arg:"" name:"fileId" help:"File ID"`
Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"`
Page string `name:"page" aliases:"cursor" help:"Page token"`
}
func (c *DrivePermissionsCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
fileID := strings.TrimSpace(c.FileID)
if fileID == "" {
return usage("empty fileId")
}
svc, err := newDriveService(ctx, account)
if err != nil {
return err
}
call := svc.Permissions.List(fileID).
SupportsAllDrives(true).
Fields("nextPageToken, permissions(id, type, role, emailAddress, domain)").
Context(ctx)
if c.Max > 0 {
call = call.PageSize(c.Max)
}
if strings.TrimSpace(c.Page) != "" {
call = call.PageToken(c.Page)
}
resp, err := call.Do()
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"fileId": fileID,
"permissions": resp.Permissions,
"permissionCount": len(resp.Permissions),
"nextPageToken": resp.NextPageToken,
})
}
if len(resp.Permissions) == 0 {
u.Err().Println("No permissions")
return nil
}
w, flush := tableWriter(ctx)
defer flush()
fmt.Fprintln(w, "ID\tTYPE\tROLE\tEMAIL")
for _, p := range resp.Permissions {
email := p.EmailAddress
if email == "" && p.Domain != "" {
email = p.Domain
}
if email == "" {
email = "-"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", p.Id, p.Type, p.Role, email)
}
printNextPageHint(u, resp.NextPageToken)
return nil
}
type DriveURLCmd struct {
FileIDs []string `arg:"" name:"fileId" help:"File IDs"`
}
func (c *DriveURLCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newDriveService(ctx, account)
if err != nil {
return err
}
for _, id := range c.FileIDs {
link, err := driveWebLink(ctx, svc, id)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
// collected below
} else {
u.Out().Printf("%s\t%s", id, link)
}
}
if outfmt.IsJSON(ctx) {
urls := make([]map[string]string, 0, len(c.FileIDs))
for _, id := range c.FileIDs {
link, err := driveWebLink(ctx, svc, id)
if err != nil {
return err
}
urls = append(urls, map[string]string{"id": id, "url": link})
}
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"urls": urls})
}
return nil
}
func buildDriveListQuery(folderID string, userQuery string) string {
q := strings.TrimSpace(userQuery)
parent := fmt.Sprintf("'%s' in parents", folderID)
@ -984,119 +480,6 @@ func formatDriveSize(bytes int64) string {
return fmt.Sprintf("%.1f %s", b, units[i])
}
func guessMimeType(path string) string {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case extPDF:
return mimePDF
case ".doc":
return "application/msword"
case extDocx:
return mimeDocx
case ".xls":
return "application/vnd.ms-excel"
case extXlsx:
return mimeXlsx
case ".ppt":
return "application/vnd.ms-powerpoint"
case extPptx:
return mimePptx
case extPNG:
return mimePNG
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
case extTXT:
return mimeTextPlain
case ".html":
return "text/html"
case ".css":
return "text/css"
case ".js":
return "application/javascript"
case ".json":
return "application/json"
case ".zip":
return "application/zip"
case ".csv":
return "text/csv"
case ".md":
return "text/markdown"
default:
return "application/octet-stream"
}
}
func downloadDriveFile(ctx context.Context, svc *drive.Service, meta *drive.File, destPath string, format string) (string, int64, error) {
isGoogleDoc := strings.HasPrefix(meta.MimeType, "application/vnd.google-apps.")
normalizedFormat := strings.ToLower(strings.TrimSpace(format))
if normalizedFormat == formatAuto {
normalizedFormat = ""
}
if !isGoogleDoc && normalizedFormat != "" {
return "", 0, fmt.Errorf("--format %q not supported for non-Google Workspace files (mimeType=%q); file can only be downloaded as-is", format, meta.MimeType)
}
if fileFormatErr := validateDriveDownloadFormatForFile(meta, format); fileFormatErr != nil {
return "", 0, fileFormatErr
}
var (
resp *http.Response
outPath string
err error
)
if isGoogleDoc {
var exportMimeType string
if normalizedFormat == "" {
exportMimeType = driveExportMimeType(meta.MimeType)
} else {
var mimeErr error
exportMimeType, mimeErr = driveExportMimeTypeForFormat(meta.MimeType, normalizedFormat)
if mimeErr != nil {
return "", 0, mimeErr
}
}
if isStdoutPath(destPath) {
outPath = stdoutPath
} else {
outPath = replaceExt(destPath, driveExportExtension(exportMimeType))
}
resp, err = driveExportDownload(ctx, svc, meta.Id, exportMimeType)
} else {
outPath = destPath
resp, err = driveDownload(ctx, svc, meta.Id)
}
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return "", 0, fmt.Errorf("download failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
if isStdoutPath(outPath) {
n, copyErr := io.Copy(os.Stdout, resp.Body)
return stdoutPath, n, copyErr
}
f, outPath, err := createUserOutputFile(outPath)
if err != nil {
return "", 0, err
}
defer f.Close()
n, err := io.Copy(f, resp.Body)
if err != nil {
return "", 0, err
}
return outPath, n, nil
}
func driveFilesListCallWithDriveSupport(call *drive.FilesListCall, allDrives bool, driveID string) *drive.FilesListCall {
// SupportsAllDrives must be set for shared drive file IDs to behave correctly.
call = call.SupportsAllDrives(true).IncludeItemsFromAllDrives(allDrives)
@ -1110,216 +493,3 @@ func driveFilesListCallWithDriveSupport(call *drive.FilesListCall, allDrives boo
}
return call
}
func validateDriveDownloadFormatFlag(format string) error {
format = strings.ToLower(strings.TrimSpace(format))
if format == "" {
return nil
}
switch format {
case "pdf", "csv", "xlsx", "pptx", "txt", "png", "docx", "md", "html":
return nil
default:
return usagef("invalid --format %q (use pdf|csv|xlsx|pptx|txt|png|docx|md|html)", format)
}
}
func validateDriveDownloadFormatForFile(meta *drive.File, format string) error {
if meta == nil {
return errors.New("missing file metadata")
}
isGoogleDoc := strings.HasPrefix(meta.MimeType, "application/vnd.google-apps.")
if isGoogleDoc {
return nil
}
if strings.TrimSpace(format) == "" {
return nil
}
return fmt.Errorf("--format %q not supported for non-Google Workspace files (mimeType=%q); file can only be downloaded as-is", format, meta.MimeType)
}
var driveDownload = func(ctx context.Context, svc *drive.Service, fileID string) (*http.Response, error) {
return svc.Files.Get(fileID).SupportsAllDrives(true).Context(ctx).Download()
}
var driveExportDownload = func(ctx context.Context, svc *drive.Service, fileID string, mimeType string) (*http.Response, error) {
return svc.Files.Export(fileID, mimeType).Context(ctx).Download()
}
func replaceExt(path string, ext string) string {
base := strings.TrimSuffix(path, filepath.Ext(path))
return base + ext
}
func driveExportMimeType(googleMimeType string) string {
switch googleMimeType {
case driveMimeGoogleDoc:
return mimePDF
case driveMimeGoogleSheet:
return mimeCSV
case driveMimeGoogleSlides:
return mimePDF
case driveMimeGoogleDrawing:
return mimePNG
default:
return mimePDF
}
}
func driveExportMimeTypeForFormat(googleMimeType string, format string) (string, error) {
format = strings.ToLower(strings.TrimSpace(format))
if format == "" || format == formatAuto {
return driveExportMimeType(googleMimeType), nil
}
switch googleMimeType {
case driveMimeGoogleDoc:
switch format {
case defaultExportFormat:
return mimePDF, nil
case "docx":
return mimeDocx, nil
case "txt":
return mimeTextPlain, nil
case "md":
return mimeTextMarkdown, nil
case "html":
return mimeHTML, nil
default:
return "", fmt.Errorf("invalid --format %q for Google Doc (use pdf|docx|txt|md|html)", format)
}
case driveMimeGoogleSheet:
switch format {
case defaultExportFormat:
return mimePDF, nil
case "csv":
return mimeCSV, nil
case "xlsx":
return mimeXlsx, nil
default:
return "", fmt.Errorf("invalid --format %q for Google Sheet (use pdf|csv|xlsx)", format)
}
case driveMimeGoogleSlides:
switch format {
case defaultExportFormat:
return mimePDF, nil
case "pptx":
return mimePptx, nil
default:
return "", fmt.Errorf("invalid --format %q for Google Slides (use pdf|pptx)", format)
}
case driveMimeGoogleDrawing:
switch format {
case "png":
return mimePNG, nil
case defaultExportFormat:
return mimePDF, nil
default:
return "", fmt.Errorf("invalid --format %q for Google Drawing (use png|pdf)", format)
}
default:
if format == defaultExportFormat {
return mimePDF, nil
}
return "", fmt.Errorf("invalid --format %q for file type %q (use pdf)", format, googleMimeType)
}
}
func driveExportExtension(mimeType string) string {
switch mimeType {
case mimePDF:
return extPDF
case mimeCSV:
return extCSV
case mimeXlsx:
return extXlsx
case mimeDocx:
return extDocx
case mimePptx:
return extPptx
case mimePNG:
return extPNG
case mimeTextPlain:
return extTXT
case mimeTextMarkdown:
return extMD
case mimeHTML:
return extHTML
default:
return extPDF
}
}
// googleConvertMimeType returns the Google-native MIME type for convertible
// Office/text formats. The boolean indicates whether the extension is supported.
func googleConvertMimeType(path string) (string, bool) {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case extDocx, ".doc":
return driveMimeGoogleDoc, true
case extXlsx, ".xls", extCSV:
return driveMimeGoogleSheet, true
case extPptx, ".ppt":
return driveMimeGoogleSlides, true
case extTXT, ".html", extMD:
return driveMimeGoogleDoc, true
default:
return "", false
}
}
func googleConvertTargetMimeType(target string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(target)) {
case "doc":
return driveMimeGoogleDoc, true
case "sheet":
return driveMimeGoogleSheet, true
case "slides":
return driveMimeGoogleSlides, true
default:
return "", false
}
}
func driveUploadConvertMimeType(path string, auto bool, target string) (string, bool, error) {
target = strings.TrimSpace(target)
if target != "" {
mimeType, ok := googleConvertTargetMimeType(target)
if !ok {
return "", false, fmt.Errorf("--convert-to: invalid value %q (use doc|sheet|slides)", target)
}
return mimeType, true, nil
}
if !auto {
return "", false, nil
}
mimeType, ok := googleConvertMimeType(path)
if !ok {
return "", false, fmt.Errorf("--convert: unsupported file type %q (supported: docx, xlsx, pptx, doc, xls, ppt, csv, txt, html, md)", filepath.Ext(path))
}
return mimeType, true, nil
}
// stripOfficeExt removes common Office extensions from a filename so
// the resulting Google Doc/Sheet/Slides has a clean name.
func stripOfficeExt(name string) string {
ext := strings.ToLower(filepath.Ext(name))
switch ext {
case extDocx, ".doc", extXlsx, ".xls", extPptx, ".ppt", extMD:
return strings.TrimSuffix(name, filepath.Ext(name))
default:
return name
}
}
func driveWebLink(ctx context.Context, svc *drive.Service, fileID string) (string, error) {
f, err := svc.Files.Get(fileID).SupportsAllDrives(true).Fields("webViewLink").Context(ctx).Do()
if err != nil {
return "", err
}
if f.WebViewLink != "" {
return f.WebViewLink, nil
}
return fmt.Sprintf("https://drive.google.com/file/d/%s/view", fileID), nil
}

View File

@ -0,0 +1,310 @@
package cmd
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"google.golang.org/api/drive/v3"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
type DriveDownloadCmd struct {
FileID string `arg:"" name:"fileId" help:"File ID"`
Output OutputPathFlag `embed:""`
Format string `name:"format" help:"Export format for Google Docs files: pdf|csv|xlsx|pptx|txt|png|docx|md (default: inferred)"`
Tab string `name:"tab" help:"(experimental) Export a specific tab by title or ID (Google Docs only; see 'gog docs list-tabs')"`
}
func (c *DriveDownloadCmd) Run(ctx context.Context, flags *RootFlags) error {
account, err := requireAccount(flags)
if err != nil {
return err
}
fileID := normalizeGoogleID(strings.TrimSpace(c.FileID))
if fileID == "" {
return usage("empty fileId")
}
if tab := strings.TrimSpace(c.Tab); tab != "" {
if f := c.Format; f != "" && f != formatAuto {
if _, fmtErr := tabExportFormatParam(f); fmtErr != nil {
return fmt.Errorf("--tab limits export formats (pdf|docx|txt|md|html); %q is not supported with --tab", f)
}
}
return runDocsTabExport(ctx, flags, tabExportParams{
DocID: fileID,
OutFlag: c.Output.Path,
Format: c.Format,
TabQuery: tab,
})
}
u := ui.FromContext(ctx)
if formatErr := validateDriveDownloadFormatFlag(c.Format); formatErr != nil {
return formatErr
}
svc, err := newDriveService(ctx, account)
if err != nil {
return err
}
meta, err := svc.Files.Get(fileID).
SupportsAllDrives(true).
Fields("id, name, mimeType").
Context(ctx).
Do()
if err != nil {
return err
}
if meta.Name == "" {
return errors.New("file has no name")
}
if fileFormatErr := validateDriveDownloadFormatForFile(meta, c.Format); fileFormatErr != nil {
return fileFormatErr
}
destPath, err := resolveDriveDownloadDestPath(meta, c.Output.Path)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) && isStdoutPath(destPath) {
return usage("can't combine --json with --out -")
}
downloadedPath, size, err := downloadDriveFile(ctx, svc, meta, destPath, c.Format)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"path": downloadedPath,
"size": size,
})
}
if isStdoutPath(downloadedPath) {
return nil
}
u.Out().Printf("path\t%s", downloadedPath)
u.Out().Printf("size\t%s", formatDriveSize(size))
return nil
}
func downloadDriveFile(ctx context.Context, svc *drive.Service, meta *drive.File, destPath string, format string) (string, int64, error) {
isGoogleDoc := strings.HasPrefix(meta.MimeType, "application/vnd.google-apps.")
normalizedFormat := strings.ToLower(strings.TrimSpace(format))
if normalizedFormat == formatAuto {
normalizedFormat = ""
}
if !isGoogleDoc && normalizedFormat != "" {
return "", 0, fmt.Errorf("--format %q not supported for non-Google Workspace files (mimeType=%q); file can only be downloaded as-is", format, meta.MimeType)
}
if fileFormatErr := validateDriveDownloadFormatForFile(meta, format); fileFormatErr != nil {
return "", 0, fileFormatErr
}
var (
resp *http.Response
outPath string
err error
)
if isGoogleDoc {
var exportMimeType string
if normalizedFormat == "" {
exportMimeType = driveExportMimeType(meta.MimeType)
} else {
var mimeErr error
exportMimeType, mimeErr = driveExportMimeTypeForFormat(meta.MimeType, normalizedFormat)
if mimeErr != nil {
return "", 0, mimeErr
}
}
if isStdoutPath(destPath) {
outPath = stdoutPath
} else {
outPath = replaceExt(destPath, driveExportExtension(exportMimeType))
}
resp, err = driveExportDownload(ctx, svc, meta.Id, exportMimeType)
} else {
outPath = destPath
resp, err = driveDownload(ctx, svc, meta.Id)
}
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return "", 0, fmt.Errorf("download failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
if isStdoutPath(outPath) {
n, copyErr := io.Copy(os.Stdout, resp.Body)
return stdoutPath, n, copyErr
}
f, outPath, err := createUserOutputFile(outPath)
if err != nil {
return "", 0, err
}
defer f.Close()
n, err := io.Copy(f, resp.Body)
if err != nil {
return "", 0, err
}
return outPath, n, nil
}
func validateDriveDownloadFormatFlag(format string) error {
format = strings.ToLower(strings.TrimSpace(format))
if format == "" {
return nil
}
switch format {
case "pdf", "csv", "xlsx", "pptx", "txt", "png", "docx", "md", "html":
return nil
default:
return usagef("invalid --format %q (use pdf|csv|xlsx|pptx|txt|png|docx|md|html)", format)
}
}
func validateDriveDownloadFormatForFile(meta *drive.File, format string) error {
if meta == nil {
return errors.New("missing file metadata")
}
isGoogleDoc := strings.HasPrefix(meta.MimeType, "application/vnd.google-apps.")
if isGoogleDoc {
return nil
}
if strings.TrimSpace(format) == "" {
return nil
}
return fmt.Errorf("--format %q not supported for non-Google Workspace files (mimeType=%q); file can only be downloaded as-is", format, meta.MimeType)
}
var driveDownload = func(ctx context.Context, svc *drive.Service, fileID string) (*http.Response, error) {
return svc.Files.Get(fileID).SupportsAllDrives(true).Context(ctx).Download()
}
var driveExportDownload = func(ctx context.Context, svc *drive.Service, fileID string, mimeType string) (*http.Response, error) {
return svc.Files.Export(fileID, mimeType).Context(ctx).Download()
}
func replaceExt(path string, ext string) string {
base := strings.TrimSuffix(path, filepath.Ext(path))
return base + ext
}
func driveExportMimeType(googleMimeType string) string {
switch googleMimeType {
case driveMimeGoogleDoc:
return mimePDF
case driveMimeGoogleSheet:
return mimeCSV
case driveMimeGoogleSlides:
return mimePDF
case driveMimeGoogleDrawing:
return mimePNG
default:
return mimePDF
}
}
func driveExportMimeTypeForFormat(googleMimeType string, format string) (string, error) {
format = strings.ToLower(strings.TrimSpace(format))
if format == "" || format == formatAuto {
return driveExportMimeType(googleMimeType), nil
}
switch googleMimeType {
case driveMimeGoogleDoc:
switch format {
case defaultExportFormat:
return mimePDF, nil
case "docx":
return mimeDocx, nil
case "txt":
return mimeTextPlain, nil
case "md":
return mimeTextMarkdown, nil
case "html":
return mimeHTML, nil
default:
return "", fmt.Errorf("invalid --format %q for Google Doc (use pdf|docx|txt|md|html)", format)
}
case driveMimeGoogleSheet:
switch format {
case defaultExportFormat:
return mimePDF, nil
case "csv":
return mimeCSV, nil
case "xlsx":
return mimeXlsx, nil
default:
return "", fmt.Errorf("invalid --format %q for Google Sheet (use pdf|csv|xlsx)", format)
}
case driveMimeGoogleSlides:
switch format {
case defaultExportFormat:
return mimePDF, nil
case "pptx":
return mimePptx, nil
default:
return "", fmt.Errorf("invalid --format %q for Google Slides (use pdf|pptx)", format)
}
case driveMimeGoogleDrawing:
switch format {
case "png":
return mimePNG, nil
case defaultExportFormat:
return mimePDF, nil
default:
return "", fmt.Errorf("invalid --format %q for Google Drawing (use png|pdf)", format)
}
default:
if format == defaultExportFormat {
return mimePDF, nil
}
return "", fmt.Errorf("invalid --format %q for file type %q (use pdf)", format, googleMimeType)
}
}
func driveExportExtension(mimeType string) string {
switch mimeType {
case mimePDF:
return extPDF
case mimeCSV:
return extCSV
case mimeXlsx:
return extXlsx
case mimeDocx:
return extDocx
case mimePptx:
return extPptx
case mimePNG:
return extPNG
case mimeTextPlain:
return extTXT
case mimeTextMarkdown:
return extMD
case mimeHTML:
return extHTML
default:
return extPDF
}
}

95
internal/cmd/drive_raw.go Normal file
View File

@ -0,0 +1,95 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"strings"
gapi "google.golang.org/api/googleapi"
)
// driveRawSensitiveFields is the set of top-level File fields redacted from
// `gog drive raw` output when the user did not name them via --fields. See
// docs/raw-audit.md for the rationale per field.
var driveRawSensitiveFields = []string{
"thumbnailLink",
"webContentLink",
"exportLinks",
"resourceKey",
"appProperties",
"properties",
}
// DriveRawCmd dumps the full Files.Get response as JSON. Uses fields=* by
// default to expose the entire File resource. When --fields is absent the
// command redacts a small set of capability/token-shaped fields (see
// driveRawSensitiveFields); when --fields is explicitly set the response is
// returned verbatim, honoring exactly what the user asked for. This means
// passing `--fields "id,name,thumbnailLink"` returns thumbnailLink as
// requested.
//
// REST reference: https://developers.google.com/drive/api/reference/rest/v3/files/get
// Go type: https://pkg.go.dev/google.golang.org/api/drive/v3#File
type DriveRawCmd struct {
FileID string `arg:"" name:"fileId" help:"File ID"`
Fields string `name:"fields" help:"Drive API field mask (default: * with sensitive fields redacted client-side). Set explicitly to disable redaction."`
Pretty bool `name:"pretty" help:"Pretty-print JSON (default: compact single-line)"`
}
func (c *DriveRawCmd) Run(ctx context.Context, flags *RootFlags) error {
fileID := strings.TrimSpace(c.FileID)
if fileID == "" {
return usage("empty fileId")
}
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newDriveService(ctx, account)
if err != nil {
return err
}
userSetFields := strings.TrimSpace(c.Fields) != ""
mask := "*"
if userSetFields {
mask = c.Fields
}
f, err := svc.Files.Get(fileID).
SupportsAllDrives(true).
Fields(gapi.Field(mask)).
Context(ctx).
Do()
if err != nil {
return err
}
f, err = requireRawResponse(f, "file not found")
if err != nil {
return err
}
raw, err := json.Marshal(f)
if err != nil {
return fmt.Errorf("marshal drive file: %w", err)
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return fmt.Errorf("unmarshal drive file: %w", err)
}
if !userSetFields {
for _, key := range driveRawSensitiveFields {
delete(m, key)
}
if hints, ok := m["contentHints"].(map[string]any); ok {
if thumb, ok := hints["thumbnail"].(map[string]any); ok {
delete(thumb, "image")
}
}
}
return writeRawJSON(ctx, m, c.Pretty)
}

View File

@ -1,7 +1,6 @@
package cmd
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@ -10,7 +9,6 @@ import (
"testing"
"google.golang.org/api/drive/v3"
"google.golang.org/api/option"
)
type driveRawHit struct {
@ -42,18 +40,8 @@ func newDriveRawTestServer(t *testing.T, status int, body map[string]any, hit *d
func installMockDriveService(t *testing.T, srv *httptest.Server) {
t.Helper()
orig := newDriveService
t.Cleanup(func() { newDriveService = orig })
svc, err := drive.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newDriveService = func(context.Context, string) (*drive.Service, error) { return svc, nil }
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", drive.NewService)
stubGoogleTestService(t, &newDriveService, svc)
}
// sensitiveDriveFile returns a File response containing every sensitive

View File

@ -0,0 +1,341 @@
package cmd
import (
"context"
"fmt"
"os"
"strings"
"google.golang.org/api/drive/v3"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
const (
driveShareToAnyone = "anyone"
driveShareToUser = "user"
driveShareToDomain = "domain"
// Drive sharing permission roles matching the Google Drive API roles.
// "commenter" allows view + comment access without edit rights.
drivePermRoleReader = "reader"
drivePermRoleWriter = "writer"
drivePermRoleCommenter = "commenter"
)
type DriveShareCmd struct {
FileID string `arg:"" name:"fileId" help:"File ID"`
To string `name:"to" help:"Share target: anyone|user|domain"`
Anyone bool `name:"anyone" hidden:"" help:"(deprecated) Use --to=anyone"`
Email string `name:"email" help:"User email (for --to=user)"`
Domain string `name:"domain" help:"Domain (for --to=domain; e.g. example.com)"`
Role string `name:"role" help:"Permission: reader|writer|commenter" default:"reader"`
Discoverable bool `name:"discoverable" help:"Allow file discovery in search (anyone/domain only)"`
}
type driveShareTarget struct {
to string
email string
domain string
}
func (c *DriveShareCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
fileID := strings.TrimSpace(c.FileID)
if fileID == "" {
return usage("empty fileId")
}
target, err := c.normalizeTarget()
if err != nil {
return err
}
role, err := normalizeDrivePermissionRole(c.Role)
if err != nil {
return err
}
if target.to == driveShareToAnyone {
if confirmErr := confirmDestructive(ctx, flags, fmt.Sprintf("share drive file %s with anyone (public)", fileID)); confirmErr != nil {
return confirmErr
}
}
svc, err := newDriveService(ctx, account)
if err != nil {
return err
}
perm := target.permission(role, c.Discoverable)
created, err := svc.Permissions.Create(fileID, perm).
SupportsAllDrives(true).
SendNotificationEmail(false).
Fields("id, type, role, emailAddress, domain, allowFileDiscovery").
Context(ctx).
Do()
if err != nil {
return err
}
link, err := driveWebLink(ctx, svc, fileID)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"link": link,
"permissionId": created.Id,
"permission": created,
})
}
u.Out().Printf("link\t%s", link)
u.Out().Printf("permission_id\t%s", created.Id)
return nil
}
func (c *DriveShareCmd) normalizeTarget() (driveShareTarget, error) {
to := strings.TrimSpace(c.To)
email := strings.TrimSpace(c.Email)
domain := strings.TrimSpace(c.Domain)
// Back-compat: allow legacy target flags without --to, but keep it unambiguous.
// New UX: prefer explicit --to + matching parameter.
if to == "" {
switch {
case c.Anyone && email == "" && domain == "":
to = driveShareToAnyone
case !c.Anyone && email != "" && domain == "":
to = driveShareToUser
case !c.Anyone && email == "" && domain != "":
to = driveShareToDomain
case !c.Anyone && email == "" && domain == "":
return driveShareTarget{}, usage("must specify --to (anyone|user|domain)")
default:
return driveShareTarget{}, usage("ambiguous share target (use --to=anyone|user|domain)")
}
}
switch to {
case driveShareToAnyone:
if email != "" || domain != "" {
return driveShareTarget{}, usage("--to=anyone cannot be combined with --email or --domain")
}
case driveShareToUser:
if email == "" {
return driveShareTarget{}, usage("missing --email for --to=user")
}
if domain != "" || c.Anyone {
return driveShareTarget{}, usage("--to=user cannot be combined with --anyone or --domain")
}
if c.Discoverable {
return driveShareTarget{}, usage("--discoverable is only valid for --to=anyone or --to=domain")
}
case driveShareToDomain:
if domain == "" {
return driveShareTarget{}, usage("missing --domain for --to=domain")
}
if email != "" || c.Anyone {
return driveShareTarget{}, usage("--to=domain cannot be combined with --anyone or --email")
}
default:
return driveShareTarget{}, usage("invalid --to (expected anyone|user|domain)")
}
return driveShareTarget{to: to, email: email, domain: domain}, nil
}
func (target driveShareTarget) permission(role string, discoverable bool) *drive.Permission {
perm := &drive.Permission{Role: role}
switch target.to {
case driveShareToAnyone:
perm.Type = "anyone"
perm.AllowFileDiscovery = discoverable
case driveShareToDomain:
perm.Type = "domain"
perm.Domain = target.domain
perm.AllowFileDiscovery = discoverable
default:
perm.Type = "user"
perm.EmailAddress = target.email
}
return perm
}
func normalizeDrivePermissionRole(role string) (string, error) {
role = strings.TrimSpace(role)
if role == "" {
return drivePermRoleReader, nil
}
switch role {
case drivePermRoleReader, drivePermRoleWriter, drivePermRoleCommenter:
return role, nil
default:
return "", usage("invalid --role (expected reader|writer|commenter)")
}
}
type DriveUnshareCmd struct {
FileID string `arg:"" name:"fileId" help:"File ID"`
PermissionID string `arg:"" name:"permissionId" help:"Permission ID"`
}
func (c *DriveUnshareCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
fileID := strings.TrimSpace(c.FileID)
permissionID := strings.TrimSpace(c.PermissionID)
if fileID == "" {
return usage("empty fileId")
}
if permissionID == "" {
return usage("empty permissionId")
}
if confirmErr := confirmDestructive(ctx, flags, fmt.Sprintf("remove permission %s from drive file %s", permissionID, fileID)); confirmErr != nil {
return confirmErr
}
svc, err := newDriveService(ctx, account)
if err != nil {
return err
}
if err := svc.Permissions.Delete(fileID, permissionID).SupportsAllDrives(true).Context(ctx).Do(); err != nil {
return err
}
return writeResult(ctx, u,
kv("removed", true),
kv("fileId", fileID),
kv("permissionId", permissionID),
)
}
type DrivePermissionsCmd struct {
FileID string `arg:"" name:"fileId" help:"File ID"`
Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"`
Page string `name:"page" aliases:"cursor" help:"Page token"`
}
func (c *DrivePermissionsCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
fileID := strings.TrimSpace(c.FileID)
if fileID == "" {
return usage("empty fileId")
}
svc, err := newDriveService(ctx, account)
if err != nil {
return err
}
call := svc.Permissions.List(fileID).
SupportsAllDrives(true).
Fields("nextPageToken, permissions(id, type, role, emailAddress, domain)").
Context(ctx)
if c.Max > 0 {
call = call.PageSize(c.Max)
}
if strings.TrimSpace(c.Page) != "" {
call = call.PageToken(c.Page)
}
resp, err := call.Do()
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"fileId": fileID,
"permissions": resp.Permissions,
"permissionCount": len(resp.Permissions),
"nextPageToken": resp.NextPageToken,
})
}
if len(resp.Permissions) == 0 {
u.Err().Println("No permissions")
return nil
}
w, flush := tableWriter(ctx)
defer flush()
fmt.Fprintln(w, "ID\tTYPE\tROLE\tEMAIL")
for _, p := range resp.Permissions {
email := p.EmailAddress
if email == "" && p.Domain != "" {
email = p.Domain
}
if email == "" {
email = "-"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", p.Id, p.Type, p.Role, email)
}
printNextPageHint(u, resp.NextPageToken)
return nil
}
type DriveURLCmd struct {
FileIDs []string `arg:"" name:"fileId" help:"File IDs"`
}
func (c *DriveURLCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newDriveService(ctx, account)
if err != nil {
return err
}
for _, id := range c.FileIDs {
link, err := driveWebLink(ctx, svc, id)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
// collected below
} else {
u.Out().Printf("%s\t%s", id, link)
}
}
if outfmt.IsJSON(ctx) {
urls := make([]map[string]string, 0, len(c.FileIDs))
for _, id := range c.FileIDs {
link, err := driveWebLink(ctx, svc, id)
if err != nil {
return err
}
urls = append(urls, map[string]string{"id": id, "url": link})
}
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"urls": urls})
}
return nil
}
func driveWebLink(ctx context.Context, svc *drive.Service, fileID string) (string, error) {
f, err := svc.Files.Get(fileID).SupportsAllDrives(true).Fields("webViewLink").Context(ctx).Do()
if err != nil {
return "", err
}
if f.WebViewLink != "" {
return f.WebViewLink, nil
}
return fmt.Sprintf("https://drive.google.com/file/d/%s/view", fileID), nil
}

View File

@ -5,28 +5,16 @@ import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"google.golang.org/api/drive/v3"
"google.golang.org/api/option"
)
func newDriveTestService(t *testing.T, h http.Handler) (*drive.Service, func()) {
t.Helper()
srv := httptest.NewServer(h)
svc, err := drive.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
return svc, srv.Close
return newGoogleTestService(t, h, drive.NewService)
}
func stubDriveService(svc *drive.Service) func(context.Context, string) (*drive.Service, error) {
@ -35,9 +23,7 @@ func stubDriveService(svc *drive.Service) func(context.Context, string) (*drive.
func stubDriveServiceForTest(t *testing.T, svc *drive.Service) {
t.Helper()
origNew := newDriveService
t.Cleanup(func() { newDriveService = origNew })
newDriveService = stubDriveService(svc)
stubGoogleTestService(t, &newDriveService, svc)
}
func newDriveMetadataTestService(t *testing.T, mimeType string) (*drive.Service, func()) {

View File

@ -17,6 +17,18 @@ import (
"github.com/steipete/gogcli/internal/ui"
)
type DriveUploadCmd struct {
LocalPath string `arg:"" name:"localPath" help:"Path to local file"`
Name string `name:"name" help:"Override filename (create) or rename target (replace)"`
Parent string `name:"parent" help:"Destination folder ID (create only)"`
ReplaceFileID string `name:"replace" help:"Replace the content of an existing Drive file ID (preserves shared link/permissions)"`
MimeType string `name:"mime-type" help:"Override MIME type inference"`
KeepRevisionForever bool `name:"keep-revision-forever" help:"Keep the new head revision forever (binary files only)"`
Convert bool `name:"convert" help:"Auto-convert to native Google format based on file extension (create only)"`
ConvertTo string `name:"convert-to" help:"Convert to a specific Google format: doc|sheet|slides (create only)"`
KeepFrontmatter bool `name:"keep-frontmatter" help:"Keep YAML frontmatter (---) in Markdown when converting to a Google Doc (--convert or --convert-to doc; default: strip)"`
}
type driveUploadOptions struct {
localPath string
fileName string
@ -30,6 +42,113 @@ type driveUploadOptions struct {
size int64
}
func guessMimeType(path string) string {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case extPDF:
return mimePDF
case ".doc":
return "application/msword"
case extDocx:
return mimeDocx
case ".xls":
return "application/vnd.ms-excel"
case extXlsx:
return mimeXlsx
case ".ppt":
return "application/vnd.ms-powerpoint"
case extPptx:
return mimePptx
case extPNG:
return mimePNG
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
case extTXT:
return mimeTextPlain
case ".html":
return "text/html"
case ".css":
return "text/css"
case ".js":
return "application/javascript"
case ".json":
return "application/json"
case ".zip":
return "application/zip"
case ".csv":
return "text/csv"
case ".md":
return "text/markdown"
default:
return "application/octet-stream"
}
}
// googleConvertMimeType returns the Google-native MIME type for convertible
// Office/text formats. The boolean indicates whether the extension is supported.
func googleConvertMimeType(path string) (string, bool) {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case extDocx, ".doc":
return driveMimeGoogleDoc, true
case extXlsx, ".xls", extCSV:
return driveMimeGoogleSheet, true
case extPptx, ".ppt":
return driveMimeGoogleSlides, true
case extTXT, ".html", extMD:
return driveMimeGoogleDoc, true
default:
return "", false
}
}
func googleConvertTargetMimeType(target string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(target)) {
case "doc":
return driveMimeGoogleDoc, true
case "sheet":
return driveMimeGoogleSheet, true
case "slides":
return driveMimeGoogleSlides, true
default:
return "", false
}
}
func driveUploadConvertMimeType(path string, auto bool, target string) (string, bool, error) {
target = strings.TrimSpace(target)
if target != "" {
mimeType, ok := googleConvertTargetMimeType(target)
if !ok {
return "", false, fmt.Errorf("--convert-to: invalid value %q (use doc|sheet|slides)", target)
}
return mimeType, true, nil
}
if !auto {
return "", false, nil
}
mimeType, ok := googleConvertMimeType(path)
if !ok {
return "", false, fmt.Errorf("--convert: unsupported file type %q (supported: docx, xlsx, pptx, doc, xls, ppt, csv, txt, html, md)", filepath.Ext(path))
}
return mimeType, true, nil
}
// stripOfficeExt removes common Office extensions from a filename so
// the resulting Google Doc/Sheet/Slides has a clean name.
func stripOfficeExt(name string) string {
ext := strings.ToLower(filepath.Ext(name))
switch ext {
case extDocx, ".doc", extXlsx, ".xls", extPptx, ".ppt", extMD:
return strings.TrimSuffix(name, filepath.Ext(name))
default:
return name
}
}
func (c *DriveUploadCmd) Run(ctx context.Context, flags *RootFlags) error {
opts, err := prepareDriveUpload(c)
if err != nil {

View File

@ -2,11 +2,7 @@ package cmd
import (
"context"
"errors"
"os"
"strings"
"github.com/steipete/gogcli/internal/outfmt"
)
// FormsRawCmd dumps the full Forms.Get response as JSON.
@ -37,9 +33,10 @@ func (c *FormsRawCmd) Run(ctx context.Context, flags *RootFlags) error {
if err != nil {
return err
}
if form == nil {
return errors.New("form not found")
form, err = requireRawResponse(form, "form not found")
if err != nil {
return err
}
return outfmt.WriteRaw(ctx, os.Stdout, form, outfmt.RawOptions{Pretty: c.Pretty})
return writeRawJSON(ctx, form, c.Pretty)
}

View File

@ -1,7 +1,6 @@
package cmd
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@ -9,7 +8,6 @@ import (
"testing"
formsapi "google.golang.org/api/forms/v1"
"google.golang.org/api/option"
)
func newFormsRawTestServer(t *testing.T, status int, body map[string]any) *httptest.Server {
@ -33,18 +31,8 @@ func newFormsRawTestServer(t *testing.T, status int, body map[string]any) *httpt
func installMockFormsService(t *testing.T, srv *httptest.Server) {
t.Helper()
orig := newFormsService
t.Cleanup(func() { newFormsService = orig })
svc, err := formsapi.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newFormsService = func(context.Context, string) (*formsapi.Service, error) { return svc, nil }
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", formsapi.NewService)
stubGoogleTestService(t, &newFormsService, svc)
}
func fullFormResponse(id string) map[string]any {

View File

@ -43,28 +43,17 @@ func (c *GmailHistoryCmd) Run(ctx context.Context, flags *RootFlags) error {
if strings.TrimSpace(pageToken) != "" {
call = call.PageToken(pageToken)
}
resp, err := call.Context(ctx).Do()
if err != nil {
return nil, "", err
resp, callErr := call.Context(ctx).Do()
if callErr != nil {
return nil, "", callErr
}
historyID = formatHistoryID(resp.HistoryId)
historyIDs := collectHistoryMessageIDs(resp)
return historyIDs.FetchIDs, resp.NextPageToken, nil
}
var ids []string
nextPageToken := ""
if c.All {
all, err := collectAllPages(c.Page, fetch)
if err != nil {
return err
}
ids = all
} else {
var err error
ids, nextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
ids, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{

View File

@ -2,12 +2,8 @@ package cmd
import (
"context"
"errors"
"fmt"
"os"
"strings"
"github.com/steipete/gogcli/internal/outfmt"
)
// GmailRawCmd dumps the full Users.Messages.Get response as JSON. Note the
@ -56,9 +52,10 @@ func (c *GmailRawCmd) Run(ctx context.Context, flags *RootFlags) error {
if err != nil {
return err
}
if msg == nil {
return errors.New("message not found")
msg, err = requireRawResponse(msg, "message not found")
if err != nil {
return err
}
return outfmt.WriteRaw(ctx, os.Stdout, msg, outfmt.RawOptions{Pretty: c.Pretty})
return writeRawJSON(ctx, msg, c.Pretty)
}

View File

@ -1,7 +1,6 @@
package cmd
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@ -10,7 +9,6 @@ import (
"testing"
"google.golang.org/api/gmail/v1"
"google.golang.org/api/option"
)
type gmailRawHit struct {
@ -41,18 +39,8 @@ func newGmailRawTestServer(t *testing.T, status int, body map[string]any, hit *g
func installMockGmailService(t *testing.T, srv *httptest.Server) {
t.Helper()
orig := newGmailService
t.Cleanup(func() { newGmailService = orig })
svc, err := gmail.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", gmail.NewService)
stubGoogleTestService(t, &newGmailService, svc)
}
func fullGmailMessageResponse(id string) map[string]any {

View File

@ -1,34 +1,19 @@
package cmd
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"google.golang.org/api/gmail/v1"
"google.golang.org/api/option"
)
func newGmailServiceForTest(t *testing.T, h http.HandlerFunc) (*gmail.Service, func()) {
t.Helper()
srv := httptest.NewServer(h)
svc, err := gmail.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
srv.Close()
t.Fatalf("NewService: %v", err)
}
return svc, srv.Close
return newGoogleTestService(t, h, gmail.NewService)
}
func stubGmailServiceForTest(t *testing.T, svc *gmail.Service) {
t.Helper()
origNew := newGmailService
t.Cleanup(func() { newGmailService = origNew })
newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
stubGoogleTestService(t, &newGmailService, svc)
}

View File

@ -0,0 +1,48 @@
package cmd
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"google.golang.org/api/option"
)
type googleTestServiceFactory[T any] func(context.Context, ...option.ClientOption) (*T, error)
func newGoogleTestService[T any](t *testing.T, h http.Handler, factory googleTestServiceFactory[T]) (*T, func()) {
t.Helper()
srv := httptest.NewServer(h)
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", factory)
return svc, srv.Close
}
func newGoogleTestServiceWithEndpoint[T any](
t *testing.T,
client *http.Client,
endpoint string,
factory googleTestServiceFactory[T],
) *T {
t.Helper()
svc, err := factory(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(client),
option.WithEndpoint(endpoint),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
return svc
}
func stubGoogleTestService[T any](t *testing.T, target *func(context.Context, string) (*T, error), svc *T) {
t.Helper()
orig := *target
t.Cleanup(func() { *target = orig })
//nolint:unparam // test stub must match production service constructor signature.
*target = func(context.Context, string) (*T, error) { return svc, nil }
}

View File

@ -60,27 +60,16 @@ func (c *GroupsListCmd) Run(ctx context.Context, flags *RootFlags) error {
if strings.TrimSpace(pageToken) != "" {
call = call.PageToken(pageToken)
}
resp, err := call.Do()
if err != nil {
return nil, "", wrapCloudIdentityError(err, account)
resp, callErr := call.Do()
if callErr != nil {
return nil, "", wrapCloudIdentityError(callErr, account)
}
return resp.Memberships, resp.NextPageToken, nil
}
var memberships []*cloudidentity.GroupRelation
nextPageToken := ""
if c.All {
all, err := collectAllPages(c.Page, fetch)
if err != nil {
return err
}
memberships = all
} else {
var err error
memberships, nextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
memberships, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
@ -212,27 +201,16 @@ func (c *GroupsMembersCmd) Run(ctx context.Context, flags *RootFlags) error {
if strings.TrimSpace(pageToken) != "" {
call = call.PageToken(pageToken)
}
resp, err := call.Do()
if err != nil {
return nil, "", fmt.Errorf("failed to list members: %w", err)
resp, callErr := call.Do()
if callErr != nil {
return nil, "", fmt.Errorf("failed to list members: %w", callErr)
}
return resp.Memberships, resp.NextPageToken, nil
}
var memberships []*cloudidentity.Membership
nextPageToken := ""
if c.All {
all, err := collectAllPages(c.Page, fetch)
if err != nil {
return err
}
memberships = all
} else {
var err error
memberships, nextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
memberships, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {

View File

@ -60,20 +60,9 @@ func (c *KeepListCmd) Run(ctx context.Context, flags *RootFlags, keep *KeepCmd)
return resp.Notes, resp.NextPageToken, nil
}
var notes []*keepapi.Note
nextPageToken := ""
if c.All {
all, err := collectAllPages(c.Page, fetch)
if err != nil {
return err
}
notes = all
} else {
var err error
notes, nextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
notes, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {

View File

@ -102,27 +102,16 @@ func (c *PeopleSearchCmd) Run(ctx context.Context, flags *RootFlags) error {
if strings.TrimSpace(pageToken) != "" {
call = call.PageToken(pageToken)
}
resp, err := call.Do()
if err != nil {
return nil, "", wrapPeopleAPIError(err)
resp, callErr := call.Do()
if callErr != nil {
return nil, "", wrapPeopleAPIError(callErr)
}
return resp.People, resp.NextPageToken, nil
}
var peopleList []*people.Person
nextPageToken := ""
if c.All {
all, err := collectAllPages(c.Page, fetch)
if err != nil {
return err
}
peopleList = all
} else {
var err error
peopleList, nextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
peopleList, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {

View File

@ -2,11 +2,7 @@ package cmd
import (
"context"
"errors"
"os"
"strings"
"github.com/steipete/gogcli/internal/outfmt"
)
// defaultPeopleRawMask is the field mask used when the user does not
@ -68,9 +64,10 @@ func runPeopleRaw(ctx context.Context, flags *RootFlags, id, fields string, pret
if err != nil {
return wrapPeopleAPIError(err)
}
if person == nil {
return errors.New("person not found")
person, err = requireRawResponse(person, "person not found")
if err != nil {
return err
}
return outfmt.WriteRaw(ctx, os.Stdout, person, outfmt.RawOptions{Pretty: pretty})
return writeRawJSON(ctx, person, pretty)
}

View File

@ -1,14 +1,12 @@
package cmd
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"google.golang.org/api/option"
"google.golang.org/api/people/v1"
)
@ -33,18 +31,8 @@ func newPeopleRawTestServer(t *testing.T, status int, body map[string]any) *http
func installMockPeopleContactsService(t *testing.T, srv *httptest.Server) {
t.Helper()
orig := newPeopleContactsService
t.Cleanup(func() { newPeopleContactsService = orig })
svc, err := people.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newPeopleContactsService = func(context.Context, string) (*people.Service, error) { return svc, nil }
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", people.NewService)
stubGoogleTestService(t, &newPeopleContactsService, svc)
}
func fullPersonResponse(id string) map[string]any {

View File

@ -0,0 +1,20 @@
package cmd
import (
"context"
"errors"
"os"
"github.com/steipete/gogcli/internal/outfmt"
)
func requireRawResponse[T any](response *T, notFoundMessage string) (*T, error) {
if response == nil {
return nil, errors.New(notFoundMessage)
}
return response, nil
}
func writeRawJSON(ctx context.Context, value any, pretty bool) error {
return outfmt.WriteRaw(ctx, os.Stdout, value, outfmt.RawOptions{Pretty: pretty})
}

View File

@ -3,7 +3,6 @@ package cmd
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
@ -458,15 +457,16 @@ func (c *SheetsRawCmd) Run(ctx context.Context, flags *RootFlags) error {
if err != nil {
return err
}
if resp == nil {
return errors.New("spreadsheet not found")
resp, err = requireRawResponse(resp, "spreadsheet not found")
if err != nil {
return err
}
if len(resp.DeveloperMetadata) > 0 {
u.Err().Println("warning: response contains developerMetadata which may hold third-party app secrets")
}
return outfmt.WriteRaw(ctx, os.Stdout, resp, outfmt.RawOptions{Pretty: c.Pretty})
return writeRawJSON(ctx, resp, c.Pretty)
}
type SheetsMetadataCmd struct {

View File

@ -11,7 +11,6 @@ import (
"sync/atomic"
"testing"
"google.golang.org/api/option"
"google.golang.org/api/sheets/v4"
"github.com/steipete/gogcli/internal/ui"
@ -47,18 +46,8 @@ func newSheetsRawTestServer(t *testing.T, status int, body map[string]any, hit *
func installMockSheetsService(t *testing.T, srv *httptest.Server) {
t.Helper()
orig := newSheetsService
t.Cleanup(func() { newSheetsService = orig })
svc, err := sheets.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newSheetsService = func(context.Context, string) (*sheets.Service, error) { return svc, nil }
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", sheets.NewService)
stubGoogleTestService(t, &newSheetsService, svc)
}
func fullSheetResponse(id string) map[string]any {

View File

@ -69,11 +69,12 @@ func (c *SlidesRawCmd) Run(ctx context.Context, flags *RootFlags) error {
if err != nil {
return err
}
if pres == nil {
return errors.New("presentation not found")
pres, err = requireRawResponse(pres, "presentation not found")
if err != nil {
return err
}
return outfmt.WriteRaw(ctx, os.Stdout, pres, outfmt.RawOptions{Pretty: c.Pretty})
return writeRawJSON(ctx, pres, c.Pretty)
}
type SlidesExportCmd struct {

View File

@ -1,14 +1,12 @@
package cmd
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"google.golang.org/api/option"
"google.golang.org/api/slides/v1"
)
@ -35,18 +33,8 @@ func newSlidesRawTestServer(t *testing.T, status int, body map[string]any) *http
func installMockSlidesService(t *testing.T, srv *httptest.Server) {
t.Helper()
orig := newSlidesService
t.Cleanup(func() { newSlidesService = orig })
svc, err := slides.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newSlidesService = func(context.Context, string) (*slides.Service, error) { return svc, nil }
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", slides.NewService)
stubGoogleTestService(t, &newSlidesService, svc)
}
func fullPresentationResponse(id string) map[string]any {

View File

@ -82,27 +82,16 @@ func (c *TasksListCmd) Run(ctx context.Context, flags *RootFlags) error {
call = call.UpdatedMin(strings.TrimSpace(c.UpdatedMin))
}
resp, err := call.Context(ctx).Do()
if err != nil {
return nil, "", err
resp, callErr := call.Context(ctx).Do()
if callErr != nil {
return nil, "", callErr
}
return resp.Items, resp.NextPageToken, nil
}
var items []*tasks.Task
nextPageToken := ""
if c.All {
all, err := collectAllPages(c.Page, fetch)
if err != nil {
return err
}
items = all
} else {
var err error
items, nextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
items, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {

View File

@ -41,27 +41,16 @@ func (c *TasksListsListCmd) Run(ctx context.Context, flags *RootFlags) error {
if strings.TrimSpace(pageToken) != "" {
call = call.PageToken(pageToken)
}
resp, err := call.Do()
if err != nil {
return nil, "", err
resp, callErr := call.Do()
if callErr != nil {
return nil, "", callErr
}
return resp.Items, resp.NextPageToken, nil
}
var items []*tasks.TaskList
nextPageToken := ""
if c.All {
all, err := collectAllPages(c.Page, fetch)
if err != nil {
return err
}
items = all
} else {
var err error
items, nextPageToken, err = fetch(c.Page)
if err != nil {
return err
}
items, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {

View File

@ -2,11 +2,7 @@ package cmd
import (
"context"
"errors"
"os"
"strings"
"github.com/steipete/gogcli/internal/outfmt"
)
// TasksRawCmd dumps the full Tasks.Get response as JSON.
@ -46,9 +42,10 @@ func (c *TasksRawCmd) Run(ctx context.Context, flags *RootFlags) error {
if err != nil {
return err
}
if task == nil {
return errors.New("task not found")
task, err = requireRawResponse(task, "task not found")
if err != nil {
return err
}
return outfmt.WriteRaw(ctx, os.Stdout, task, outfmt.RawOptions{Pretty: c.Pretty})
return writeRawJSON(ctx, task, c.Pretty)
}

View File

@ -1,14 +1,12 @@
package cmd
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"google.golang.org/api/option"
"google.golang.org/api/tasks/v1"
)
@ -39,18 +37,8 @@ func newTasksRawTestServer(t *testing.T, status int, body map[string]any) *httpt
func installMockTasksService(t *testing.T, srv *httptest.Server) {
t.Helper()
orig := newTasksService
t.Cleanup(func() { newTasksService = orig })
svc, err := tasks.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newTasksService = func(context.Context, string) (*tasks.Service, error) { return svc, nil }
svc := newGoogleTestServiceWithEndpoint(t, srv.Client(), srv.URL+"/", tasks.NewService)
stubGoogleTestService(t, &newTasksService, svc)
}
func fullTaskResponse(id string) map[string]any {

View File

@ -2,7 +2,6 @@ package googleapi
import (
"context"
"fmt"
admin "google.golang.org/api/admin/directory/v1"
@ -12,11 +11,5 @@ import (
// NewAdminDirectory creates an Admin SDK Directory service for user and group management.
// This API requires domain-wide delegation with a service account to manage Workspace users.
func NewAdminDirectory(ctx context.Context, email string) (*admin.Service, error) {
if opts, err := optionsForAccount(ctx, googleauth.ServiceAdmin, email); err != nil {
return nil, fmt.Errorf("admin directory options: %w", err)
} else if svc, err := admin.NewService(ctx, opts...); err != nil {
return nil, fmt.Errorf("create admin directory service: %w", err)
} else {
return svc, nil
}
return newGoogleServiceForAccount(ctx, email, googleauth.ServiceAdmin, "admin directory", admin.NewService)
}

View File

@ -2,7 +2,6 @@ package googleapi
import (
"context"
"fmt"
"google.golang.org/api/script/v1"
@ -10,11 +9,5 @@ import (
)
func NewAppScript(ctx context.Context, email string) (*script.Service, error) {
if opts, err := optionsForAccount(ctx, googleauth.ServiceAppScript, email); err != nil {
return nil, fmt.Errorf("appscript options: %w", err)
} else if svc, err := script.NewService(ctx, opts...); err != nil {
return nil, fmt.Errorf("create appscript service: %w", err)
} else {
return svc, nil
}
return newGoogleServiceForAccount(ctx, email, googleauth.ServiceAppScript, "appscript", script.NewService)
}

View File

@ -2,7 +2,6 @@ package googleapi
import (
"context"
"fmt"
"google.golang.org/api/calendar/v3"
@ -10,11 +9,5 @@ import (
)
func NewCalendar(ctx context.Context, email string) (*calendar.Service, error) {
if opts, err := optionsForAccount(ctx, googleauth.ServiceCalendar, email); err != nil {
return nil, fmt.Errorf("calendar options: %w", err)
} else if svc, err := calendar.NewService(ctx, opts...); err != nil {
return nil, fmt.Errorf("create calendar service: %w", err)
} else {
return svc, nil
}
return newGoogleServiceForAccount(ctx, email, googleauth.ServiceCalendar, "calendar", calendar.NewService)
}

View File

@ -2,7 +2,6 @@ package googleapi
import (
"context"
"fmt"
"google.golang.org/api/chat/v1"
)
@ -17,11 +16,5 @@ const (
)
func NewChat(ctx context.Context, email string) (*chat.Service, error) {
if opts, err := optionsForAccountScopes(ctx, "chat", email, []string{scopeChatSpaces, scopeChatMessages, scopeChatMemberships, scopeChatReadStateRO, scopeChatReactionsCreate, scopeChatReactionsRO}); err != nil {
return nil, fmt.Errorf("chat options: %w", err)
} else if svc, err := chat.NewService(ctx, opts...); err != nil {
return nil, fmt.Errorf("create chat service: %w", err)
} else {
return svc, nil
}
return newGoogleServiceForScopes(ctx, email, "chat", "chat", []string{scopeChatSpaces, scopeChatMessages, scopeChatMemberships, scopeChatReadStateRO, scopeChatReactionsCreate, scopeChatReactionsRO}, chat.NewService)
}

View File

@ -2,7 +2,6 @@ package googleapi
import (
"context"
"fmt"
"google.golang.org/api/classroom/v1"
@ -10,11 +9,5 @@ import (
)
func NewClassroom(ctx context.Context, email string) (*classroom.Service, error) {
if opts, err := optionsForAccount(ctx, googleauth.ServiceClassroom, email); err != nil {
return nil, fmt.Errorf("classroom options: %w", err)
} else if svc, err := classroom.NewService(ctx, opts...); err != nil {
return nil, fmt.Errorf("create classroom service: %w", err)
} else {
return svc, nil
}
return newGoogleServiceForAccount(ctx, email, googleauth.ServiceClassroom, "classroom", classroom.NewService)
}

View File

@ -41,6 +41,53 @@ func optionsForAccount(ctx context.Context, service googleauth.Service, email st
return optionsForAccountScopes(ctx, string(service), email, scopes)
}
type googleServiceFactory[T any] func(context.Context, ...option.ClientOption) (*T, error)
func newGoogleServiceForAccount[T any](
ctx context.Context,
email string,
service googleauth.Service,
label string,
factory googleServiceFactory[T],
) (*T, error) {
opts, err := optionsForAccount(ctx, service, email)
if err != nil {
return nil, fmt.Errorf("%s options: %w", label, err)
}
return newGoogleService(ctx, label, opts, factory)
}
func newGoogleServiceForScopes[T any](
ctx context.Context,
email string,
serviceLabel string,
errorLabel string,
scopes []string,
factory googleServiceFactory[T],
) (*T, error) {
opts, err := optionsForAccountScopes(ctx, serviceLabel, email, scopes)
if err != nil {
return nil, fmt.Errorf("%s options: %w", errorLabel, err)
}
return newGoogleService(ctx, errorLabel, opts, factory)
}
func newGoogleService[T any](
ctx context.Context,
label string,
opts []option.ClientOption,
factory googleServiceFactory[T],
) (*T, error) {
svc, err := factory(ctx, opts...)
if err != nil {
return nil, fmt.Errorf("create %s service: %w", label, err)
}
return svc, nil
}
// IsADCMode reports whether Application Default Credentials mode is active.
// When GOG_AUTH_MODE=adc, the CLI authenticates using the ambient credentials
// (e.g. GKE Workload Identity, GOOGLE_APPLICATION_CREDENTIALS, or gcloud ADC)

View File

@ -2,7 +2,6 @@ package googleapi
import (
"context"
"fmt"
"google.golang.org/api/cloudidentity/v1"
)
@ -14,11 +13,5 @@ const (
// NewCloudIdentityGroups creates a Cloud Identity service for reading groups.
// This API allows non-admin users to list groups they belong to and view group members.
func NewCloudIdentityGroups(ctx context.Context, email string) (*cloudidentity.Service, error) {
if opts, err := optionsForAccountScopes(ctx, "cloudidentity", email, []string{scopeCloudIdentityGroupsRO}); err != nil {
return nil, fmt.Errorf("cloudidentity options: %w", err)
} else if svc, err := cloudidentity.NewService(ctx, opts...); err != nil {
return nil, fmt.Errorf("create cloudidentity service: %w", err)
} else {
return svc, nil
}
return newGoogleServiceForScopes(ctx, email, "cloudidentity", "cloudidentity", []string{scopeCloudIdentityGroupsRO}, cloudidentity.NewService)
}

View File

@ -2,7 +2,6 @@ package googleapi
import (
"context"
"fmt"
"google.golang.org/api/docs/v1"
@ -10,11 +9,5 @@ import (
)
func NewDocs(ctx context.Context, email string) (*docs.Service, error) {
if opts, err := optionsForAccount(ctx, googleauth.ServiceDocs, email); err != nil {
return nil, fmt.Errorf("docs options: %w", err)
} else if svc, err := docs.NewService(ctx, opts...); err != nil {
return nil, fmt.Errorf("create docs service: %w", err)
} else {
return svc, nil
}
return newGoogleServiceForAccount(ctx, email, googleauth.ServiceDocs, "docs", docs.NewService)
}

View File

@ -2,7 +2,6 @@ package googleapi
import (
"context"
"fmt"
"google.golang.org/api/drive/v3"
@ -10,11 +9,5 @@ import (
)
func NewDrive(ctx context.Context, email string) (*drive.Service, error) {
if opts, err := optionsForAccount(ctx, googleauth.ServiceDrive, email); err != nil {
return nil, fmt.Errorf("drive options: %w", err)
} else if svc, err := drive.NewService(ctx, opts...); err != nil {
return nil, fmt.Errorf("create drive service: %w", err)
} else {
return svc, nil
}
return newGoogleServiceForAccount(ctx, email, googleauth.ServiceDrive, "drive", drive.NewService)
}

View File

@ -2,7 +2,6 @@ package googleapi
import (
"context"
"fmt"
"google.golang.org/api/forms/v1"
@ -10,11 +9,5 @@ import (
)
func NewForms(ctx context.Context, email string) (*forms.Service, error) {
if opts, err := optionsForAccount(ctx, googleauth.ServiceForms, email); err != nil {
return nil, fmt.Errorf("forms options: %w", err)
} else if svc, err := forms.NewService(ctx, opts...); err != nil {
return nil, fmt.Errorf("create forms service: %w", err)
} else {
return svc, nil
}
return newGoogleServiceForAccount(ctx, email, googleauth.ServiceForms, "forms", forms.NewService)
}

View File

@ -2,7 +2,6 @@ package googleapi
import (
"context"
"fmt"
"google.golang.org/api/gmail/v1"
@ -10,11 +9,5 @@ import (
)
func NewGmail(ctx context.Context, email string) (*gmail.Service, error) {
if opts, err := optionsForAccount(ctx, googleauth.ServiceGmail, email); err != nil {
return nil, fmt.Errorf("gmail options: %w", err)
} else if svc, err := gmail.NewService(ctx, opts...); err != nil {
return nil, fmt.Errorf("create gmail service: %w", err)
} else {
return svc, nil
}
return newGoogleServiceForAccount(ctx, email, googleauth.ServiceGmail, "gmail", gmail.NewService)
}

View File

@ -13,13 +13,7 @@ import (
)
func NewKeep(ctx context.Context, email string) (*keep.Service, error) {
if opts, err := optionsForAccount(ctx, googleauth.ServiceKeep, email); err != nil {
return nil, fmt.Errorf("keep options: %w", err)
} else if svc, err := keep.NewService(ctx, opts...); err != nil {
return nil, fmt.Errorf("create keep service: %w", err)
} else {
return svc, nil
}
return newGoogleServiceForAccount(ctx, email, googleauth.ServiceKeep, "keep", keep.NewService)
}
func NewKeepWithServiceAccount(ctx context.Context, serviceAccountPath, impersonateEmail string) (*keep.Service, error) {

View File

@ -2,7 +2,6 @@ package googleapi
import (
"context"
"fmt"
"google.golang.org/api/people/v1"
)
@ -14,31 +13,13 @@ const (
)
func NewPeopleContacts(ctx context.Context, email string) (*people.Service, error) {
if opts, err := optionsForAccountScopes(ctx, "contacts", email, []string{scopeContactsWrite}); err != nil {
return nil, fmt.Errorf("contacts options: %w", err)
} else if svc, err := people.NewService(ctx, opts...); err != nil {
return nil, fmt.Errorf("create contacts service: %w", err)
} else {
return svc, nil
}
return newGoogleServiceForScopes(ctx, email, "contacts", "contacts", []string{scopeContactsWrite}, people.NewService)
}
func NewPeopleOtherContacts(ctx context.Context, email string) (*people.Service, error) {
if opts, err := optionsForAccountScopes(ctx, "contacts", email, []string{scopeContactsOtherRO}); err != nil {
return nil, fmt.Errorf("contacts options: %w", err)
} else if svc, err := people.NewService(ctx, opts...); err != nil {
return nil, fmt.Errorf("create contacts service: %w", err)
} else {
return svc, nil
}
return newGoogleServiceForScopes(ctx, email, "contacts", "contacts", []string{scopeContactsOtherRO}, people.NewService)
}
func NewPeopleDirectory(ctx context.Context, email string) (*people.Service, error) {
if opts, err := optionsForAccountScopes(ctx, "contacts", email, []string{scopeDirectoryRO}); err != nil {
return nil, fmt.Errorf("contacts options: %w", err)
} else if svc, err := people.NewService(ctx, opts...); err != nil {
return nil, fmt.Errorf("create contacts service: %w", err)
} else {
return svc, nil
}
return newGoogleServiceForScopes(ctx, email, "contacts", "contacts", []string{scopeDirectoryRO}, people.NewService)
}

View File

@ -2,7 +2,6 @@ package googleapi
import (
"context"
"fmt"
"log/slog"
"google.golang.org/api/sheets/v4"
@ -13,15 +12,10 @@ import (
func NewSheets(ctx context.Context, email string) (*sheets.Service, error) {
slog.Debug("creating sheets service", "email", email)
opts, err := optionsForAccount(ctx, googleauth.ServiceSheets, email)
if err != nil {
return nil, fmt.Errorf("sheets options: %w", err)
}
svc, err := sheets.NewService(ctx, opts...)
svc, err := newGoogleServiceForAccount(ctx, email, googleauth.ServiceSheets, "sheets", sheets.NewService)
if err != nil {
slog.Error("failed to create sheets service", "email", email, "error", err)
return nil, fmt.Errorf("create sheets service: %w", err)
return nil, err
}
slog.Debug("sheets service created successfully", "email", email)

View File

@ -2,7 +2,6 @@ package googleapi
import (
"context"
"fmt"
"google.golang.org/api/slides/v1"
@ -10,11 +9,5 @@ import (
)
func NewSlides(ctx context.Context, email string) (*slides.Service, error) {
if opts, err := optionsForAccount(ctx, googleauth.ServiceSlides, email); err != nil {
return nil, fmt.Errorf("slides options: %w", err)
} else if svc, err := slides.NewService(ctx, opts...); err != nil {
return nil, fmt.Errorf("create slides service: %w", err)
} else {
return svc, nil
}
return newGoogleServiceForAccount(ctx, email, googleauth.ServiceSlides, "slides", slides.NewService)
}

View File

@ -2,7 +2,6 @@ package googleapi
import (
"context"
"fmt"
"google.golang.org/api/tasks/v1"
@ -10,11 +9,5 @@ import (
)
func NewTasks(ctx context.Context, email string) (*tasks.Service, error) {
if opts, err := optionsForAccount(ctx, googleauth.ServiceTasks, email); err != nil {
return nil, fmt.Errorf("tasks options: %w", err)
} else if svc, err := tasks.NewService(ctx, opts...); err != nil {
return nil, fmt.Errorf("create tasks service: %w", err)
} else {
return svc, nil
}
return newGoogleServiceForAccount(ctx, email, googleauth.ServiceTasks, "tasks", tasks.NewService)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,139 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const bin = process.env.GOG_BIN || path.join(root, "bin", "gog");
const docsDir = path.join(root, "docs");
const commandsDir = path.join(docsDir, "commands");
const requiredFeatureDocs = [
"install.md",
"quickstart.md",
"auth-clients.md",
"safety-profiles.md",
"raw-api.md",
"raw-audit.md",
"gmail-workflows.md",
"watch.md",
"email-tracking.md",
"drive-audits.md",
"contacts-dedupe.md",
"contacts-json-update.md",
"docs-editing.md",
"sheets-tables.md",
"sheets-formatting.md",
"slides-markdown.md",
"slides-template-replacement.md",
"backup.md",
"dates.md",
];
const schema = JSON.parse(execFileSync(bin, ["schema", "--json"], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }));
const commands = Array.from(walk(schema.command || {}));
const seenSlugs = new Set();
const missingCommandPages = [];
for (const command of commands) {
const base = commandSlug(command);
let slug = base;
let suffix = 2;
while (seenSlugs.has(slug)) {
slug = `${base}-${suffix}`;
suffix += 1;
}
seenSlugs.add(slug);
const page = path.join(commandsDir, `${slug}.md`);
if (!fs.existsSync(page)) {
missingCommandPages.push(path.relative(root, page));
}
}
const navSourcePath = path.join(root, "scripts", "build-docs-site.mjs");
const navSource = fs.readFileSync(navSourcePath, "utf8");
const missingFeaturePages = [];
const unlinkedFeaturePages = [];
const brokenLinks = checkMarkdownLinks(docsDir);
for (const rel of requiredFeatureDocs) {
const page = path.join(docsDir, rel);
if (!fs.existsSync(page)) {
missingFeaturePages.push(`docs/${rel}`);
continue;
}
if (!navSource.includes(`"${rel}"`)) {
unlinkedFeaturePages.push(`docs/${rel}`);
}
}
if (missingCommandPages.length || missingFeaturePages.length || unlinkedFeaturePages.length || brokenLinks.length) {
for (const name of missingCommandPages) console.error(`missing command doc: ${name}`);
for (const name of missingFeaturePages) console.error(`missing feature doc: ${name}`);
for (const name of unlinkedFeaturePages) console.error(`feature doc not in scripts/build-docs-site.mjs sidebar: ${name}`);
for (const item of brokenLinks) console.error(`broken docs link: ${item}`);
process.exit(1);
}
console.log(`docs coverage ok: ${commands.length} command pages, ${requiredFeatureDocs.length} feature pages`);
function* walk(command) {
yield command;
for (const child of command.subcommands || []) {
yield* walk(child);
}
}
function canonicalTokens(commandPath) {
return (commandPath || "")
.split(/\s+/)
.filter((part) => part && !(part.startsWith("(") && part.endsWith(")")));
}
function canonicalPath(command) {
return canonicalTokens(command.path || command.name || "").join(" ");
}
function commandSlug(command) {
const slug = canonicalPath(command)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return slug || "gog";
}
function checkMarkdownLinks(dir) {
const broken = [];
for (const file of allMarkdown(dir)) {
const markdown = fs.readFileSync(file, "utf8");
const linkPattern = /!?\[[^\]]*\]\(([^)]+)\)/g;
let match;
while ((match = linkPattern.exec(markdown)) !== null) {
const rawTarget = match[1].trim().replace(/^<|>$/g, "");
if (!rawTarget || rawTarget.startsWith("#")) continue;
if (/^[a-z][a-z0-9+.-]*:/i.test(rawTarget)) continue;
const targetWithoutTitle = rawTarget.split(/\s+["'][^"']*["']\s*$/)[0];
const targetPath = targetWithoutTitle.split("#")[0];
if (!targetPath) continue;
if (/^(url|path|file)$/i.test(targetPath)) continue;
const resolved = path.resolve(path.dirname(file), targetPath);
if (!fs.existsSync(resolved)) {
broken.push(`${path.relative(root, file)} -> ${targetPath}`);
}
}
}
return broken;
}
function allMarkdown(dir) {
return fs
.readdirSync(dir, { withFileTypes: true })
.flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return allMarkdown(full);
return entry.name.endsWith(".md") ? [full] : [];
});
}

23
scripts/codesign-macos.sh Executable file
View File

@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
BIN="${1:-}"
if [[ -z "$BIN" ]]; then
echo "usage: $0 <path-to-binary>" >&2
exit 2
fi
if [[ "$(uname -s)" != "Darwin" ]]; then
exit 0
fi
IDENTITY="${GOG_CODESIGN_IDENTITY:-${CODESIGN_IDENTITY:-}}"
if [[ -z "$IDENTITY" ]]; then
echo "codesign: skipped (set GOG_CODESIGN_IDENTITY or CODESIGN_IDENTITY)" >&2
exit 0
fi
ID="com.steipete.gogcli.gog"
codesign --force --sign "$IDENTITY" --timestamp --options runtime --identifier "$ID" "$BIN"
codesign --verify --deep --strict --verbose=2 "$BIN"

View File

@ -0,0 +1,291 @@
export function css() {
return `
:root{
--ink:#0f1115;
--text:#1f2328;
--muted:#6b7280;
--subtle:#9aa1ab;
--bg:#fafafa;
--paper:#ffffff;
--accent:#1a73e8;
--accent-soft:rgba(26,115,232,.09);
--accent-strong:#1558b9;
--g-blue:#4285f4;--g-red:#ea4335;--g-yellow:#fbbc04;--g-green:#34a853;
--line:#e5e7eb;
--line-soft:#eef0f3;
--code-bg:#0f172a;
--code-fg:#e6edf3;
--code-inline-fg:#1c2128;
--code-border:#1f2937;
--pill-border:#dbe2eb;
--shadow-card:0 4px 14px rgba(15,17,21,.08);
--scrollbar:#cbd5e1;
--hl-keyword:#7aa2ff;
--hl-string:#9ece6a;
--hl-number:#e0a96d;
--hl-comment:#7c8597;
--hl-flag:#c4a4ff;
--hl-meta:#f08aa0;
--hl-prompt:#64748b;
}
:root[data-theme="dark"]{
--ink:#f3f5f9;
--text:#cbd2dc;
--muted:#8d96a4;
--subtle:#5d6371;
--bg:#0c0e14;
--paper:#171a23;
--accent:#5294ff;
--accent-soft:rgba(82,148,255,.16);
--accent-strong:#7caeff;
--line:#262a36;
--line-soft:#1d2029;
--code-bg:#06080d;
--code-fg:#e6edf3;
--code-inline-fg:#e6edf3;
--code-border:#1c2030;
--pill-border:#2a2f3c;
--shadow-card:0 4px 18px rgba(0,0,0,.45);
--scrollbar:#3a4154;
--hl-keyword:#7aa2ff;
--hl-string:#a6e3a1;
--hl-number:#f0a868;
--hl-comment:#6b7388;
--hl-flag:#c4a4ff;
--hl-meta:#ff8aa0;
--hl-prompt:#7e8ba3;
}
:root{color-scheme:light}
:root[data-theme="dark"]{color-scheme:dark}
*{box-sizing:border-box}
html{scroll-behavior:smooth;scroll-padding-top:24px}
body{margin:0;background:var(--bg);color:var(--text);font-family:"Inter",ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif;line-height:1.65;overflow-x:hidden;-webkit-font-smoothing:antialiased;font-feature-settings:"cv02","cv03","cv04","cv11";transition:background-color .18s,color .18s}
::selection{background:var(--accent);color:#fff}
a{color:var(--accent);text-decoration:none;transition:color .12s}
a:hover{text-decoration:underline;text-underline-offset:.2em}
.shell{display:grid;grid-template-columns:268px minmax(0,1fr);min-height:100vh}
.sidebar{position:sticky;top:0;height:100vh;overflow:auto;padding:24px 22px;background:var(--paper);border-right:1px solid var(--line);scrollbar-width:thin;scrollbar-color:var(--line) transparent;transition:background-color .18s,border-color .18s}
.sidebar::-webkit-scrollbar{width:6px}
.sidebar::-webkit-scrollbar-thumb{background:var(--line);border-radius:6px}
.sidebar-head{display:flex;align-items:center;gap:10px;margin-bottom:24px}
.brand{display:flex;align-items:center;gap:11px;color:var(--ink);text-decoration:none;flex:1;min-width:0}
.brand:hover{text-decoration:none}
.brand .mark{display:grid;grid-template-columns:repeat(2,12px);grid-template-rows:repeat(2,12px);gap:3px;flex:0 0 27px}
.brand .mark i{display:block;border-radius:3px}
.brand .mark i:nth-child(1){background:var(--g-blue)}
.brand .mark i:nth-child(2){background:var(--g-red)}
.brand .mark i:nth-child(3){background:var(--g-yellow)}
.brand .mark i:nth-child(4){background:var(--g-green)}
.brand strong{display:block;font-size:1.05rem;line-height:1.1;font-weight:600;letter-spacing:0;color:var(--ink)}
.brand small{display:block;color:var(--muted);font-size:.74rem;margin-top:3px;font-weight:400}
.theme-toggle{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:34px;height:34px;border-radius:8px;border:1px solid var(--line);background:var(--paper);color:var(--muted);cursor:pointer;padding:0;transition:border-color .15s,color .15s,background-color .15s,transform .12s}
.theme-toggle:hover{border-color:var(--ink);color:var(--ink)}
.theme-toggle:active{transform:scale(.94)}
.theme-toggle svg{width:16px;height:16px;display:block}
.theme-icon-sun{display:none}
:root[data-theme="dark"] .theme-icon-sun{display:block}
:root[data-theme="dark"] .theme-icon-moon{display:none}
.search{display:block;margin:0 0 22px}
.search span{display:block;color:var(--muted);font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:0;margin-bottom:7px}
.search input{width:100%;border:1px solid var(--line);background:var(--paper);border-radius:8px;padding:9px 12px;font:inherit;font-size:.9rem;color:var(--text);outline:none;transition:border-color .15s,box-shadow .15s,background-color .18s}
.search input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
nav section{margin:0 0 18px}
nav h2{font-size:.68rem;color:var(--muted);text-transform:uppercase;letter-spacing:0;margin:0 0 6px;font-weight:600}
.nav-link{display:block;color:var(--text);text-decoration:none;border-radius:6px;padding:5px 10px;margin:1px 0;font-size:.9rem;line-height:1.4;transition:background .12s,color .12s}
.nav-link:hover{background:var(--line-soft);color:var(--ink);text-decoration:none}
.nav-link.active{background:var(--accent-soft);color:var(--accent);font-weight:600}
main{min-width:0;padding:32px clamp(20px,4.5vw,56px) 80px;max-width:1180px;margin:0 auto;width:100%}
.hero{display:flex;align-items:flex-end;justify-content:space-between;gap:22px;border-bottom:1px solid var(--line);padding:8px 0 22px;margin-bottom:8px;flex-wrap:wrap}
.hero-text{min-width:0;flex:1 1 320px}
.eyebrow{margin:0 0 8px;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:0;font-size:.7rem}
.hero h1{font-size:2.25rem;line-height:1.1;letter-spacing:0;margin:0;font-weight:700;color:var(--ink)}
.hero-meta{display:flex;gap:8px;flex:0 0 auto;flex-wrap:wrap}
.repo,.edit,.btn-ghost{border:1px solid var(--line);color:var(--text);text-decoration:none;border-radius:7px;padding:6px 11px;font-weight:500;font-size:.83rem;background:var(--paper);transition:border-color .15s,color .15s,background .15s}
.repo:hover,.edit:hover,.btn-ghost:hover{border-color:var(--ink);color:var(--ink);text-decoration:none}
.edit{color:var(--muted)}
.home-hero{padding:14px 0 28px;margin-bottom:8px;border-bottom:1px solid var(--line)}
.home-hero h1{font-size:3.25rem;line-height:1.04;letter-spacing:0;margin:0 0 .35em;font-weight:700;color:var(--ink)}
.home-hero .lede{font-size:1.18rem;line-height:1.55;color:var(--text);margin:0 0 1.2em;max-width:60ch}
.home-cta{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin:0 0 18px}
.home-cta .btn{display:inline-flex;align-items:center;gap:7px;border-radius:8px;padding:10px 16px;font-weight:600;font-size:.92rem;text-decoration:none;transition:background .15s,border-color .15s,color .15s,transform .12s}
.home-cta .btn-primary{background:var(--accent);color:#fff;border:1px solid var(--accent)}
.home-cta .btn-primary:hover{background:var(--accent-strong);border-color:var(--accent-strong);text-decoration:none}
.home-cta .btn-ghost{padding:10px 16px}
.home-install{display:flex;align-items:center;gap:12px;background:var(--code-bg);color:var(--code-fg);border-radius:8px;padding:10px 10px 10px 16px;font:500 .9rem/1.2 "JetBrains Mono","SF Mono",ui-monospace,monospace;max-width:32em;border:1px solid #1f2937}
.home-install .prompt{color:#64748b;user-select:none;flex:0 0 auto}
.home-install code{flex:1;background:transparent;border:0;color:var(--code-fg);font:inherit;padding:0;white-space:pre;overflow:hidden;text-overflow:ellipsis}
.home-install .copy{flex:0 0 auto;background:rgba(255,255,255,.08);color:var(--code-fg);border:1px solid rgba(255,255,255,.16);border-radius:6px;padding:5px 11px;font:500 .72rem/1 "Inter",sans-serif;cursor:pointer;transition:background .15s,border-color .15s}
.home-install .copy:hover{background:rgba(255,255,255,.16)}
.home-install .copy.copied{background:var(--accent);border-color:var(--accent)}
.home-services{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0 18px}
.home-services span{display:inline-block;padding:3px 9px;border:1px solid var(--line);border-radius:999px;font-size:.78rem;color:var(--muted);background:var(--paper)}
.doc-grid{display:grid;grid-template-columns:minmax(0,1fr);gap:48px;margin-top:24px}
.doc-grid-home{margin-top:8px}
@media(min-width:1180px){.doc-grid{grid-template-columns:minmax(0,72ch) 200px;justify-content:start}.doc-grid-home{grid-template-columns:minmax(0,76ch);justify-content:start}}
.doc{min-width:0;max-width:72ch;overflow-wrap:break-word}
.doc-home{max-width:76ch}
.doc h1{font-size:2.6rem;line-height:1.08;letter-spacing:0;margin:0 0 .4em;font-weight:700;color:var(--ink)}
body:not(.home) .doc>h1:first-child{display:none}
.doc h2{font-size:1.45rem;line-height:1.2;margin:2em 0 .5em;font-weight:600;letter-spacing:0;color:var(--ink);position:relative}
.doc h3{font-size:1.1rem;margin:1.7em 0 .35em;position:relative;font-weight:600;color:var(--ink);letter-spacing:0}
.doc h4{font-size:.98rem;margin:1.4em 0 .25em;color:var(--ink);position:relative;font-weight:600}
.doc h2:first-child,.doc h3:first-child,.doc h4:first-child{margin-top:.2em}
.doc :is(h2,h3,h4) .anchor{position:absolute;left:-1.05em;top:0;color:var(--subtle);opacity:0;text-decoration:none;font-weight:400;padding-right:.3em;transition:opacity .12s,color .12s}
.doc :is(h2,h3,h4):hover .anchor{opacity:.7}
.doc :is(h2,h3,h4) .anchor:hover{opacity:1;color:var(--accent);text-decoration:none}
.doc p{margin:0 0 1.05em}
.doc ul,.doc ol{padding-left:1.3rem;margin:0 0 1.15em}
.doc li{margin:.25em 0}
.doc li>p{margin:0 0 .4em}
.doc strong{font-weight:600;color:var(--ink)}
.doc em{font-style:italic}
.doc code{font-family:"JetBrains Mono","SF Mono",ui-monospace,monospace;font-size:.84em;background:var(--line-soft);border:1px solid var(--line);border-radius:5px;padding:.08em .35em;color:var(--code-inline-fg)}
.doc pre{position:relative;overflow:auto;background:var(--code-bg);color:var(--code-fg);border-radius:8px;padding:14px 18px;margin:1.3em 0;font-size:.85em;line-height:1.6;scrollbar-width:thin;scrollbar-color:#334155 transparent;border:1px solid var(--code-border)}
.doc pre::-webkit-scrollbar{height:8px;width:8px}
.doc pre::-webkit-scrollbar-thumb{background:#334155;border-radius:8px}
.doc pre code{display:block;background:transparent;border:0;color:inherit;padding:0;font-size:1em;white-space:pre}
.doc pre .copy{position:absolute;top:8px;right:8px;background:rgba(255,255,255,.06);color:var(--code-fg);border:1px solid rgba(255,255,255,.16);border-radius:6px;padding:3px 9px;font:500 .7rem/1 "Inter",sans-serif;cursor:pointer;opacity:0;transition:opacity .15s,background .15s,border-color .15s}
.doc pre:hover .copy,.doc pre .copy:focus{opacity:1}
.doc pre .copy:hover{background:rgba(255,255,255,.12)}
.doc pre .copy.copied{background:var(--accent);border-color:var(--accent);opacity:1}
.doc pre .hl-c{color:var(--hl-comment);font-style:italic}
.doc pre .hl-s{color:var(--hl-string)}
.doc pre .hl-n{color:var(--hl-number)}
.doc pre .hl-k{color:var(--hl-keyword);font-weight:600}
.doc pre .hl-f{color:var(--hl-flag)}
.doc pre .hl-m{color:var(--hl-meta);font-weight:600}
.doc pre .hl-p{color:var(--hl-prompt);user-select:none}
.doc pre .hl-cmd{color:var(--hl-keyword);font-weight:600}
.doc blockquote{margin:1.4em 0;padding:10px 16px;border-left:3px solid var(--accent);background:var(--accent-soft);border-radius:0 8px 8px 0;color:var(--text)}
.doc blockquote p:last-child{margin-bottom:0}
.doc table{width:100%;border-collapse:collapse;margin:1.2em 0;font-size:.92em}
.doc th,.doc td{border-bottom:1px solid var(--line);padding:9px 10px;text-align:left;vertical-align:top}
.doc th{font-weight:600;color:var(--ink);background:var(--line-soft);border-bottom:1px solid var(--line)}
.doc hr{border:0;border-top:1px solid var(--line);margin:2.2em 0}
.toc{position:sticky;top:24px;align-self:start;font-size:.84rem;padding-left:14px;border-left:1px solid var(--line);max-height:calc(100vh - 48px);overflow:auto;scrollbar-width:thin;scrollbar-color:var(--line) transparent}
.toc::-webkit-scrollbar{width:5px}
.toc::-webkit-scrollbar-thumb{background:var(--line);border-radius:5px}
.toc h2{font-size:.66rem;color:var(--muted);text-transform:uppercase;letter-spacing:0;margin:0 0 10px;font-weight:600}
.toc a{display:block;color:var(--muted);text-decoration:none;padding:4px 0 4px 10px;line-height:1.35;border-left:2px solid transparent;margin-left:-12px;transition:color .12s,border-color .12s}
.toc a:hover{color:var(--ink);text-decoration:none}
.toc a.active{color:var(--accent);border-left-color:var(--accent);font-weight:500}
.toc-l3{padding-left:22px!important;font-size:.94em}
@media(max-width:1179px){.toc{display:none}}
.page-nav{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:48px;border-top:1px solid var(--line);padding-top:20px}
.page-nav>a{display:block;border:1px solid var(--line);background:var(--paper);border-radius:9px;padding:13px 16px;text-decoration:none;color:var(--text);transition:border-color .15s,transform .15s,box-shadow .15s,background-color .18s}
.page-nav>a:hover{border-color:var(--accent);text-decoration:none;color:var(--ink)}
.page-nav small{display:block;color:var(--muted);font-size:.7rem;text-transform:uppercase;letter-spacing:0;margin-bottom:5px;font-weight:600}
.page-nav span{display:block;font-weight:600;line-height:1.3;color:var(--ink)}
.page-nav-prev{text-align:left}
.page-nav-next{text-align:right;grid-column:2}
.page-nav-prev:only-child{grid-column:1}
.nav-toggle{display:none;position:fixed;top:14px;right:14px;top:calc(14px + env(safe-area-inset-top, 0px));right:calc(14px + env(safe-area-inset-right, 0px));z-index:20;width:40px;height:40px;border-radius:9px;background:var(--paper);border:1px solid var(--line);color:var(--ink);cursor:pointer;padding:10px 9px;flex-direction:column;align-items:stretch;justify-content:space-between;box-shadow:var(--shadow-card)}
.nav-toggle span{display:block;width:100%;height:2px;flex:0 0 2px;background:currentColor;border-radius:2px;transition:transform .2s,opacity .2s}
.nav-toggle[aria-expanded="true"] span:nth-child(1){transform:translateY(8px) rotate(45deg)}
.nav-toggle[aria-expanded="true"] span:nth-child(2){opacity:0}
.nav-toggle[aria-expanded="true"] span:nth-child(3){transform:translateY(-8px) rotate(-45deg)}
@media(max-width:900px){
.shell{display:block}
.sidebar{position:fixed;inset:0 30% 0 0;max-width:320px;height:100vh;z-index:15;transform:translateX(-100%);transition:transform .25s ease,background-color .18s,border-color .18s;box-shadow:0 18px 40px rgba(0,0,0,.18);background:var(--paper);pointer-events:none}
.sidebar.open{transform:translateX(0);pointer-events:auto}
.nav-toggle{display:flex}
main{padding:64px 18px 56px}
.hero{padding-top:6px}
.hero h1{font-size:1.8rem}
.home-hero h1{font-size:2.45rem}
.doc h1{font-size:2.1rem}
.hero-meta{width:100%;justify-content:flex-start}
.home-hero{padding-top:8px}
.doc{padding:0}
.doc-grid{margin-top:18px;gap:24px}
.doc :is(h2,h3,h4) .anchor{display:none}
}
@media(max-width:520px){
main{padding:60px 14px 48px}
.doc pre{margin-left:-14px;margin-right:-14px;border-radius:0;border-left:0;border-right:0}
.home-install{flex-wrap:wrap}
}
`;
}
export function js() {
return `
const themeRoot=document.documentElement;
function applyTheme(mode){themeRoot.dataset.theme=mode;document.querySelectorAll('[data-theme-toggle]').forEach(b=>b.setAttribute('aria-pressed',mode==='dark'?'true':'false'))}
function storedTheme(){try{return localStorage.getItem('theme')}catch(e){return null}}
function persistTheme(mode){try{localStorage.setItem('theme',mode)}catch(e){}}
applyTheme(themeRoot.dataset.theme==='dark'?'dark':'light');
document.querySelectorAll('[data-theme-toggle]').forEach(btn=>{btn.addEventListener('click',()=>{const next=themeRoot.dataset.theme==='dark'?'light':'dark';applyTheme(next);persistTheme(next)})});
const systemDark=window.matchMedia&&matchMedia('(prefers-color-scheme: dark)');
function onSystemChange(e){if(storedTheme())return;applyTheme(e.matches?'dark':'light')}
if(systemDark){if(systemDark.addEventListener)systemDark.addEventListener('change',onSystemChange);else if(systemDark.addListener)systemDark.addListener(onSystemChange)}
const sidebar=document.querySelector('.sidebar');
const toggle=document.querySelector('.nav-toggle');
const mobileNav=window.matchMedia('(max-width: 900px)');
const sidebarFocusable='a[href],button,input,select,textarea,[tabindex]';
function setSidebarFocusable(enabled){
sidebar?.querySelectorAll(sidebarFocusable).forEach((el)=>{
if(enabled){
if(el.dataset.sidebarTabindex!==undefined){
if(el.dataset.sidebarTabindex)el.setAttribute('tabindex',el.dataset.sidebarTabindex);
else el.removeAttribute('tabindex');
delete el.dataset.sidebarTabindex;
}
}else if(el.dataset.sidebarTabindex===undefined){
el.dataset.sidebarTabindex=el.getAttribute('tabindex')??'';
el.setAttribute('tabindex','-1');
}
});
}
function setSidebarOpen(open){
if(!sidebar||!toggle)return;
sidebar.classList.toggle('open',open);
toggle.setAttribute('aria-expanded',open?'true':'false');
if(mobileNav.matches){
sidebar.inert=!open;
if(open)sidebar.removeAttribute('aria-hidden');
else sidebar.setAttribute('aria-hidden','true');
setSidebarFocusable(open);
}else{
sidebar.inert=false;
sidebar.removeAttribute('aria-hidden');
setSidebarFocusable(true);
}
}
setSidebarOpen(false);
toggle?.addEventListener('click',()=>setSidebarOpen(!sidebar?.classList.contains('open')));
document.addEventListener('click',(e)=>{if(!sidebar?.classList.contains('open'))return;if(sidebar.contains(e.target)||toggle?.contains(e.target))return;setSidebarOpen(false)});
document.addEventListener('keydown',(e)=>{if(e.key==='Escape')setSidebarOpen(false)});
const syncSidebarForViewport=()=>setSidebarOpen(sidebar?.classList.contains('open')??false);
if(mobileNav.addEventListener)mobileNav.addEventListener('change',syncSidebarForViewport);
else mobileNav.addListener?.(syncSidebarForViewport);
const input=document.getElementById('doc-search');
input?.addEventListener('input',()=>{const q=input.value.trim().toLowerCase();document.querySelectorAll('nav section').forEach(sec=>{let any=false;sec.querySelectorAll('.nav-link').forEach(a=>{const m=!q||a.textContent.toLowerCase().includes(q);a.style.display=m?'block':'none';if(m)any=true});sec.style.display=any?'block':'none'})});
function attachCopy(target,getText){const btn=document.createElement('button');btn.type='button';btn.className='copy';btn.textContent='Copy';btn.addEventListener('click',async()=>{try{await navigator.clipboard.writeText(getText());btn.textContent='Copied';btn.classList.add('copied');setTimeout(()=>{btn.textContent='Copy';btn.classList.remove('copied')},1400)}catch{btn.textContent='Failed';setTimeout(()=>{btn.textContent='Copy'},1400)}});target.appendChild(btn)}
document.querySelectorAll('.doc pre').forEach(pre=>attachCopy(pre,()=>pre.querySelector('code')?.textContent??''));
document.querySelectorAll('.home-install').forEach(el=>attachCopy(el,()=>el.querySelector('code')?.textContent??''));
const tocLinks=document.querySelectorAll('.toc a');
if(tocLinks.length){const map=new Map();tocLinks.forEach(a=>{const id=a.getAttribute('href').slice(1);const el=document.getElementById(id);if(el)map.set(el,a)});const setActive=l=>{tocLinks.forEach(x=>x.classList.remove('active'));l.classList.add('active')};const obs=new IntersectionObserver(entries=>{const visible=entries.filter(e=>e.isIntersecting).sort((a,b)=>a.boundingClientRect.top-b.boundingClientRect.top);if(visible.length){const link=map.get(visible[0].target);if(link)setActive(link)}},{rootMargin:'-15% 0px -65% 0px',threshold:0});map.forEach((_,el)=>obs.observe(el))}
`;
}
export function preThemeScript() {
return `(function(){var s;try{s=localStorage.getItem('theme')}catch(e){}var d=window.matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches;document.documentElement.dataset.theme=s||(d?'dark':'light')})();`;
}
export function themeToggleHtml() {
return `<button class="theme-toggle" type="button" aria-label="Toggle dark mode" aria-pressed="false" data-theme-toggle>
<svg class="theme-icon-moon" viewBox="0 0 20 20" aria-hidden="true"><path d="M14.6 12.1A6.5 6.5 0 0 1 7.4 2.7a6.5 6.5 0 1 0 7.2 9.4z" fill="currentColor"/></svg>
<svg class="theme-icon-sun" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="3.4" fill="currentColor"/><g stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="10" y1="2" x2="10" y2="4"/><line x1="10" y1="16" x2="10" y2="18"/><line x1="2" y1="10" x2="4" y2="10"/><line x1="16" y1="10" x2="18" y2="10"/><line x1="4.2" y1="4.2" x2="5.6" y2="5.6"/><line x1="14.4" y1="14.4" x2="15.8" y2="15.8"/><line x1="4.2" y1="15.8" x2="5.6" y2="14.4"/><line x1="14.4" y1="5.6" x2="15.8" y2="4.2"/></g></svg>
</button>`;
}
export function faviconSvg() {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="gog">
<rect width="64" height="64" rx="12" fill="#0f1115"/>
<rect x="14" y="14" width="14" height="14" rx="3" fill="#4285f4"/>
<rect x="36" y="14" width="14" height="14" rx="3" fill="#ea4335"/>
<rect x="14" y="36" width="14" height="14" rx="3" fill="#fbbc04"/>
<rect x="36" y="36" width="14" height="14" rx="3" fill="#34a853"/>
</svg>`;
}

View File

@ -44,13 +44,13 @@ if [[ "$assets_count" -eq 0 ]]; then
exit 2
fi
release_run_id="$(gh run list -L 20 --workflow release.yml --json databaseId,conclusion,headBranch -q ".[] | select(.headBranch==\"v$version\") | select(.conclusion==\"success\") | .databaseId" | head -n1)"
release_run_id="$(gh api repos/steipete/gogcli/actions/runs --jq ".workflow_runs[] | select(.name==\"release\") | select(.head_branch==\"v$version\") | select(.conclusion==\"success\") | .id" | head -n1)"
if [[ -z "$release_run_id" ]]; then
echo "release workflow not green for v$version" >&2
exit 2
fi
ci_ok="$(gh run list -L 1 --workflow ci --branch main --json conclusion -q '.[0].conclusion')"
ci_ok="$(gh api repos/steipete/gogcli/actions/runs --jq '.workflow_runs[] | select(.name=="ci") | select(.head_branch=="main") | .conclusion // ""' | head -n1)"
if [[ "$ci_ok" != "success" ]]; then
echo "CI not green for main" >&2
exit 2