google-analytics-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. ga_cli/__init__.py +8 -0
  2. ga_cli/api/__init__.py +0 -0
  3. ga_cli/api/client.py +142 -0
  4. ga_cli/auth/__init__.py +35 -0
  5. ga_cli/auth/credentials.py +126 -0
  6. ga_cli/auth/oauth.py +322 -0
  7. ga_cli/auth/service_account.py +155 -0
  8. ga_cli/commands/__init__.py +0 -0
  9. ga_cli/commands/access_bindings.py +254 -0
  10. ga_cli/commands/access_reports.py +201 -0
  11. ga_cli/commands/account_summaries.py +68 -0
  12. ga_cli/commands/accounts.py +297 -0
  13. ga_cli/commands/agent_cmd.py +776 -0
  14. ga_cli/commands/annotations.py +264 -0
  15. ga_cli/commands/audiences.py +223 -0
  16. ga_cli/commands/auth_cmd.py +205 -0
  17. ga_cli/commands/bigquery_links.py +309 -0
  18. ga_cli/commands/calculated_metrics.py +312 -0
  19. ga_cli/commands/channel_groups.py +223 -0
  20. ga_cli/commands/completions_cmd.py +55 -0
  21. ga_cli/commands/config_cmd.py +113 -0
  22. ga_cli/commands/custom_dimensions.py +272 -0
  23. ga_cli/commands/custom_metrics.py +305 -0
  24. ga_cli/commands/data_retention.py +153 -0
  25. ga_cli/commands/data_streams.py +277 -0
  26. ga_cli/commands/event_create_rules.py +250 -0
  27. ga_cli/commands/event_edit_rules.py +292 -0
  28. ga_cli/commands/firebase_links.py +142 -0
  29. ga_cli/commands/google_ads_links.py +225 -0
  30. ga_cli/commands/key_events.py +269 -0
  31. ga_cli/commands/mp_secrets.py +265 -0
  32. ga_cli/commands/properties.py +330 -0
  33. ga_cli/commands/property_settings.py +287 -0
  34. ga_cli/commands/reports.py +726 -0
  35. ga_cli/commands/upgrade_cmd.py +148 -0
  36. ga_cli/config/__init__.py +0 -0
  37. ga_cli/config/constants.py +61 -0
  38. ga_cli/config/store.py +115 -0
  39. ga_cli/main.py +110 -0
  40. ga_cli/utils/__init__.py +20 -0
  41. ga_cli/utils/describe.py +129 -0
  42. ga_cli/utils/dry_run.py +40 -0
  43. ga_cli/utils/errors.py +150 -0
  44. ga_cli/utils/output.py +209 -0
  45. ga_cli/utils/pagination.py +93 -0
  46. google_analytics_cli-0.1.0.dist-info/METADATA +321 -0
  47. google_analytics_cli-0.1.0.dist-info/RECORD +49 -0
  48. google_analytics_cli-0.1.0.dist-info/WHEEL +4 -0
  49. google_analytics_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,264 @@
1
+ """Reporting data annotation management commands."""
2
+
3
+ from typing import Optional
4
+
5
+ import questionary
6
+ import typer
7
+
8
+ from ..api.client import get_admin_alpha_client
9
+ from ..config.store import get_effective_value
10
+ from ..utils import (
11
+ handle_dry_run,
12
+ handle_error,
13
+ info,
14
+ output,
15
+ require_options,
16
+ resolve_output_format,
17
+ success,
18
+ )
19
+ from ..utils.pagination import paginate_all
20
+
21
+ annotations_app = typer.Typer(
22
+ name="annotations",
23
+ help="Manage reporting data annotations",
24
+ no_args_is_help=True,
25
+ )
26
+
27
+
28
+ @annotations_app.command("list")
29
+ def list_cmd(
30
+ property_id: Optional[str] = typer.Option(
31
+ None, "--property-id", "-p", help="Property ID (numeric)"
32
+ ),
33
+ output_format: Optional[str] = typer.Option(
34
+ None, "--output", "-o", help="Output format (json, table, compact)"
35
+ ),
36
+ ):
37
+ """List reporting data annotations for a property."""
38
+ try:
39
+ effective_property = get_effective_value(property_id, "default_property_id")
40
+ require_options({"property_id": effective_property}, ["property_id"])
41
+ effective_format = resolve_output_format(output_format)
42
+
43
+ admin = get_admin_alpha_client()
44
+ annotations = paginate_all(
45
+ lambda **kw: admin.properties()
46
+ .reportingDataAnnotations()
47
+ .list(parent=f"properties/{effective_property}", **kw)
48
+ .execute(),
49
+ "reportingDataAnnotations",
50
+ pageSize=200,
51
+ )
52
+
53
+ output(
54
+ annotations,
55
+ effective_format,
56
+ columns=[
57
+ "name",
58
+ "title",
59
+ "annotationDate",
60
+ "description",
61
+ "color",
62
+ ],
63
+ headers=[
64
+ "Resource Name",
65
+ "Title",
66
+ "Date",
67
+ "Description",
68
+ "Color",
69
+ ],
70
+ )
71
+ except Exception as e:
72
+ handle_error(e)
73
+
74
+
75
+ @annotations_app.command("get")
76
+ def get_cmd(
77
+ property_id: Optional[str] = typer.Option(
78
+ None, "--property-id", "-p", help="Property ID (numeric)"
79
+ ),
80
+ annotation_id: str = typer.Option(
81
+ ..., "--annotation-id", "-a", help="Annotation ID"
82
+ ),
83
+ output_format: Optional[str] = typer.Option(
84
+ None, "--output", "-o", help="Output format (json, table, compact)"
85
+ ),
86
+ ):
87
+ """Get details for a reporting data annotation."""
88
+ try:
89
+ effective_property = get_effective_value(property_id, "default_property_id")
90
+ require_options({"property_id": effective_property}, ["property_id"])
91
+ effective_format = resolve_output_format(output_format)
92
+
93
+ admin = get_admin_alpha_client()
94
+ annotation = (
95
+ admin.properties()
96
+ .reportingDataAnnotations()
97
+ .get(
98
+ name=f"properties/{effective_property}/reportingDataAnnotations/{annotation_id}"
99
+ )
100
+ .execute()
101
+ )
102
+ output(annotation, effective_format)
103
+ except Exception as e:
104
+ handle_error(e)
105
+
106
+
107
+ @annotations_app.command("create")
108
+ def create_cmd(
109
+ property_id: Optional[str] = typer.Option(
110
+ None, "--property-id", "-p", help="Property ID (numeric)"
111
+ ),
112
+ title: str = typer.Option(..., "--title", help="Annotation title"),
113
+ annotation_date: str = typer.Option(
114
+ ..., "--annotation-date", help="Date in YYYY-MM-DD format"
115
+ ),
116
+ description: str = typer.Option("", "--description", help="Annotation description"),
117
+ color: Optional[str] = typer.Option(None, "--color", help="Annotation color"),
118
+ dry_run: bool = typer.Option(
119
+ False, "--dry-run", help="Preview the request without executing"
120
+ ),
121
+ output_format: Optional[str] = typer.Option(
122
+ None, "--output", "-o", help="Output format (json, table, compact)"
123
+ ),
124
+ ):
125
+ """Create a reporting data annotation."""
126
+ try:
127
+ effective_property = get_effective_value(property_id, "default_property_id")
128
+ require_options({"property_id": effective_property}, ["property_id"])
129
+ effective_format = resolve_output_format(output_format)
130
+
131
+ body = {
132
+ "title": title,
133
+ "annotationDate": annotation_date,
134
+ "description": description,
135
+ }
136
+ if color is not None:
137
+ body["color"] = color
138
+
139
+ if dry_run:
140
+ handle_dry_run("create", "POST", f"properties/{effective_property}", body)
141
+
142
+ admin = get_admin_alpha_client()
143
+ annotation = (
144
+ admin.properties()
145
+ .reportingDataAnnotations()
146
+ .create(parent=f"properties/{effective_property}", body=body)
147
+ .execute()
148
+ )
149
+ output(annotation, effective_format)
150
+ except typer.Exit:
151
+ raise
152
+ except Exception as e:
153
+ handle_error(e)
154
+
155
+
156
+ @annotations_app.command("update")
157
+ def update_cmd(
158
+ property_id: Optional[str] = typer.Option(
159
+ None, "--property-id", "-p", help="Property ID (numeric)"
160
+ ),
161
+ annotation_id: str = typer.Option(
162
+ ..., "--annotation-id", "-a", help="Annotation ID"
163
+ ),
164
+ title: Optional[str] = typer.Option(None, "--title", help="New title"),
165
+ description: Optional[str] = typer.Option(None, "--description", help="New description"),
166
+ color: Optional[str] = typer.Option(None, "--color", help="New color"),
167
+ dry_run: bool = typer.Option(
168
+ False, "--dry-run", help="Preview the request without executing"
169
+ ),
170
+ output_format: Optional[str] = typer.Option(
171
+ None, "--output", "-o", help="Output format (json, table, compact)"
172
+ ),
173
+ ):
174
+ """Update a reporting data annotation."""
175
+ try:
176
+ effective_property = get_effective_value(property_id, "default_property_id")
177
+ require_options({"property_id": effective_property}, ["property_id"])
178
+ effective_format = resolve_output_format(output_format)
179
+
180
+ body = {}
181
+ mask_fields = []
182
+ if title is not None:
183
+ body["title"] = title
184
+ mask_fields.append("title")
185
+ if description is not None:
186
+ body["description"] = description
187
+ mask_fields.append("description")
188
+ if color is not None:
189
+ body["color"] = color
190
+ mask_fields.append("color")
191
+
192
+ if not mask_fields:
193
+ raise typer.BadParameter(
194
+ "At least one field must be specified: --title, --description, --color"
195
+ )
196
+
197
+ resource_name = f"properties/{effective_property}/reportingDataAnnotations/{annotation_id}"
198
+ if dry_run:
199
+ handle_dry_run(
200
+ "update", "PATCH", resource_name,
201
+ body, update_mask=",".join(mask_fields),
202
+ )
203
+
204
+ admin = get_admin_alpha_client()
205
+ annotation = (
206
+ admin.properties()
207
+ .reportingDataAnnotations()
208
+ .patch(
209
+ name=resource_name,
210
+ body=body,
211
+ updateMask=",".join(mask_fields),
212
+ )
213
+ .execute()
214
+ )
215
+ output(annotation, effective_format)
216
+ except (typer.BadParameter, typer.Exit):
217
+ raise
218
+ except Exception as e:
219
+ handle_error(e)
220
+
221
+
222
+ @annotations_app.command("delete")
223
+ def delete_cmd(
224
+ property_id: Optional[str] = typer.Option(
225
+ None, "--property-id", "-p", help="Property ID (numeric)"
226
+ ),
227
+ annotation_id: str = typer.Option(
228
+ ..., "--annotation-id", "-a", help="Annotation ID"
229
+ ),
230
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
231
+ dry_run: bool = typer.Option(
232
+ False, "--dry-run", help="Preview the request without executing"
233
+ ),
234
+ ):
235
+ """Delete a reporting data annotation."""
236
+ try:
237
+ effective_property = get_effective_value(property_id, "default_property_id")
238
+ require_options({"property_id": effective_property}, ["property_id"])
239
+
240
+ if dry_run:
241
+ handle_dry_run(
242
+ "delete", "DELETE",
243
+ f"properties/{effective_property}/reportingDataAnnotations/{annotation_id}",
244
+ None,
245
+ )
246
+
247
+ if not yes:
248
+ confirmed = questionary.confirm(
249
+ f"Delete annotation {annotation_id}? This cannot be undone."
250
+ ).ask()
251
+ if not confirmed:
252
+ info("Cancelled.")
253
+ raise typer.Exit()
254
+
255
+ admin = get_admin_alpha_client()
256
+ resource_name = f"properties/{effective_property}/reportingDataAnnotations/{annotation_id}"
257
+ admin.properties().reportingDataAnnotations().delete(
258
+ name=resource_name
259
+ ).execute()
260
+ success(f"Annotation {annotation_id} deleted.")
261
+ except typer.Exit:
262
+ raise
263
+ except Exception as e:
264
+ handle_error(e)
@@ -0,0 +1,223 @@
1
+ """Audience management commands."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import questionary
8
+ import typer
9
+
10
+ from ..api.client import get_admin_alpha_client
11
+ from ..config.store import get_effective_value
12
+ from ..utils import handle_error, info, output, require_options, resolve_output_format, success
13
+ from ..utils.pagination import paginate_all
14
+
15
+ audiences_app = typer.Typer(
16
+ name="audiences",
17
+ help="Manage audiences",
18
+ no_args_is_help=True,
19
+ )
20
+
21
+
22
+ def _load_json_config(config_file: str) -> dict:
23
+ """Load and parse a JSON config file."""
24
+ config_path = Path(config_file)
25
+ if not config_path.exists():
26
+ raise typer.BadParameter(f"Config file not found: {config_file}")
27
+ try:
28
+ return json.loads(config_path.read_text())
29
+ except json.JSONDecodeError as exc:
30
+ raise typer.BadParameter(f"Invalid JSON in config file: {exc}")
31
+
32
+
33
+ @audiences_app.command("list")
34
+ def list_cmd(
35
+ property_id: Optional[str] = typer.Option(
36
+ None, "--property-id", "-p", help="Property ID (numeric)"
37
+ ),
38
+ output_format: Optional[str] = typer.Option(
39
+ None, "--output", "-o", help="Output format (json, table, compact)"
40
+ ),
41
+ ):
42
+ """List audiences for a property."""
43
+ try:
44
+ effective_property = get_effective_value(property_id, "default_property_id")
45
+ require_options({"property_id": effective_property}, ["property_id"])
46
+ effective_format = resolve_output_format(output_format)
47
+
48
+ admin = get_admin_alpha_client()
49
+ audiences = paginate_all(
50
+ lambda **kw: admin.properties()
51
+ .audiences()
52
+ .list(parent=f"properties/{effective_property}", **kw)
53
+ .execute(),
54
+ "audiences",
55
+ pageSize=200,
56
+ )
57
+
58
+ output(
59
+ audiences,
60
+ effective_format,
61
+ columns=[
62
+ "name",
63
+ "displayName",
64
+ "description",
65
+ "membershipDurationDays",
66
+ ],
67
+ headers=[
68
+ "Resource Name",
69
+ "Display Name",
70
+ "Description",
71
+ "Membership Days",
72
+ ],
73
+ )
74
+ except Exception as e:
75
+ handle_error(e)
76
+
77
+
78
+ @audiences_app.command("get")
79
+ def get_cmd(
80
+ property_id: Optional[str] = typer.Option(
81
+ None, "--property-id", "-p", help="Property ID (numeric)"
82
+ ),
83
+ audience_id: str = typer.Option(
84
+ ..., "--audience-id", "-a", help="Audience ID"
85
+ ),
86
+ output_format: Optional[str] = typer.Option(
87
+ None, "--output", "-o", help="Output format (json, table, compact)"
88
+ ),
89
+ ):
90
+ """Get details for an audience."""
91
+ try:
92
+ effective_property = get_effective_value(property_id, "default_property_id")
93
+ require_options({"property_id": effective_property}, ["property_id"])
94
+ effective_format = resolve_output_format(output_format)
95
+
96
+ admin = get_admin_alpha_client()
97
+ audience = (
98
+ admin.properties()
99
+ .audiences()
100
+ .get(
101
+ name=f"properties/{effective_property}/audiences/{audience_id}"
102
+ )
103
+ .execute()
104
+ )
105
+ output(audience, effective_format)
106
+ except Exception as e:
107
+ handle_error(e)
108
+
109
+
110
+ @audiences_app.command("create")
111
+ def create_cmd(
112
+ property_id: Optional[str] = typer.Option(
113
+ None, "--property-id", "-p", help="Property ID (numeric)"
114
+ ),
115
+ config_file: str = typer.Option(
116
+ ..., "--config", "-c", help="Path to JSON audience config file"
117
+ ),
118
+ output_format: Optional[str] = typer.Option(
119
+ None, "--output", "-o", help="Output format (json, table, compact)"
120
+ ),
121
+ ):
122
+ """Create an audience from a JSON config file."""
123
+ try:
124
+ effective_property = get_effective_value(property_id, "default_property_id")
125
+ require_options({"property_id": effective_property}, ["property_id"])
126
+ effective_format = resolve_output_format(output_format)
127
+
128
+ body = _load_json_config(config_file)
129
+
130
+ admin = get_admin_alpha_client()
131
+ audience = (
132
+ admin.properties()
133
+ .audiences()
134
+ .create(parent=f"properties/{effective_property}", body=body)
135
+ .execute()
136
+ )
137
+ output(audience, effective_format)
138
+ except typer.BadParameter:
139
+ raise
140
+ except Exception as e:
141
+ handle_error(e)
142
+
143
+
144
+ @audiences_app.command("update")
145
+ def update_cmd(
146
+ property_id: Optional[str] = typer.Option(
147
+ None, "--property-id", "-p", help="Property ID (numeric)"
148
+ ),
149
+ audience_id: str = typer.Option(
150
+ ..., "--audience-id", "-a", help="Audience ID"
151
+ ),
152
+ config_file: str = typer.Option(
153
+ ..., "--config", "-c", help="Path to JSON file with fields to update"
154
+ ),
155
+ output_format: Optional[str] = typer.Option(
156
+ None, "--output", "-o", help="Output format (json, table, compact)"
157
+ ),
158
+ ):
159
+ """Update an audience from a JSON config file."""
160
+ try:
161
+ effective_property = get_effective_value(property_id, "default_property_id")
162
+ require_options({"property_id": effective_property}, ["property_id"])
163
+ effective_format = resolve_output_format(output_format)
164
+
165
+ body = _load_json_config(config_file)
166
+
167
+ if not body:
168
+ raise typer.BadParameter("Config file must contain at least one field to update.")
169
+
170
+ update_mask = ",".join(body.keys())
171
+
172
+ admin = get_admin_alpha_client()
173
+ resource_name = f"properties/{effective_property}/audiences/{audience_id}"
174
+ audience = (
175
+ admin.properties()
176
+ .audiences()
177
+ .patch(
178
+ name=resource_name,
179
+ body=body,
180
+ updateMask=update_mask,
181
+ )
182
+ .execute()
183
+ )
184
+ output(audience, effective_format)
185
+ except typer.BadParameter:
186
+ raise
187
+ except Exception as e:
188
+ handle_error(e)
189
+
190
+
191
+ @audiences_app.command("archive")
192
+ def archive_cmd(
193
+ property_id: Optional[str] = typer.Option(
194
+ None, "--property-id", "-p", help="Property ID (numeric)"
195
+ ),
196
+ audience_id: str = typer.Option(
197
+ ..., "--audience-id", "-a", help="Audience ID"
198
+ ),
199
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
200
+ ):
201
+ """Archive an audience."""
202
+ try:
203
+ effective_property = get_effective_value(property_id, "default_property_id")
204
+ require_options({"property_id": effective_property}, ["property_id"])
205
+
206
+ if not yes:
207
+ confirmed = questionary.confirm(
208
+ f"Archive audience {audience_id}? This cannot be undone."
209
+ ).ask()
210
+ if not confirmed:
211
+ info("Cancelled.")
212
+ raise typer.Exit()
213
+
214
+ admin = get_admin_alpha_client()
215
+ resource_name = f"properties/{effective_property}/audiences/{audience_id}"
216
+ admin.properties().audiences().archive(
217
+ name=resource_name, body={}
218
+ ).execute()
219
+ success(f"Audience {audience_id} archived.")
220
+ except typer.Exit:
221
+ raise
222
+ except Exception as e:
223
+ handle_error(e)
@@ -0,0 +1,205 @@
1
+ """Authentication commands: login, logout, status.
2
+
3
+ Equivalent to GTM CLI's commands/auth.ts.
4
+ """
5
+
6
+ from typing import Optional
7
+
8
+ import typer
9
+
10
+ from ..api.client import clear_client_cache
11
+ from ..auth.oauth import get_auth_status, login, logout
12
+ from ..auth.service_account import (
13
+ clear_auth_method,
14
+ load_auth_method,
15
+ login_with_service_account,
16
+ )
17
+ from ..utils import error, info, output, success
18
+
19
+ auth_app = typer.Typer(name="auth", help="Manage authentication", no_args_is_help=True)
20
+
21
+ _SETUP_GUIDE = r"""# GA CLI — OAuth Credential Setup
22
+
23
+ GA CLI requires your own Google Cloud Platform OAuth credentials.
24
+ Follow these steps to create them.
25
+
26
+ ## Step 1: Create or Select a GCP Project
27
+
28
+ 1. Go to the Google Cloud Console: https://console.cloud.google.com/
29
+ 2. Create a new project or select an existing one
30
+ 3. Note your project ID
31
+
32
+ ## Step 2: Enable Required APIs
33
+
34
+ In the GCP Console, go to APIs & Services > Library and enable:
35
+ - Google Analytics Admin API
36
+ - Google Analytics Data API
37
+
38
+ Or via gcloud CLI:
39
+
40
+ gcloud services enable analyticsadmin.googleapis.com analyticsdata.googleapis.com
41
+
42
+ ## Step 3: Configure OAuth Consent Screen
43
+
44
+ 1. Go to APIs & Services > OAuth consent screen
45
+ 2. Choose "External" user type (or "Internal" if using Google Workspace)
46
+ 3. Fill in the required fields: app name, user support email, developer contact
47
+ 4. No scopes need to be added manually — GA CLI requests them at login time
48
+ 5. For personal use, leave the app in "Testing" mode — it works for the
49
+ project owner and up to 100 added test users without Google verification
50
+
51
+ ## Step 4: Create OAuth Client ID
52
+
53
+ 1. Go to APIs & Services > Credentials
54
+ 2. Click "Create Credentials" > "OAuth client ID"
55
+ 3. Choose "Desktop app" as the application type
56
+ 4. Give it a name (e.g., "GA CLI")
57
+ 5. Click "Create" and download the JSON file
58
+
59
+ ## Step 5: Provide Credentials to GA CLI
60
+
61
+ Option A — Place the downloaded JSON file (recommended):
62
+
63
+ mkdir -p ~/.config/ga-cli
64
+ cp /path/to/downloaded/client_secret_*.json ~/.config/ga-cli/client_secret.json
65
+
66
+ Option B — Set environment variables:
67
+
68
+ export GA_CLI_CLIENT_ID="your-client-id.apps.googleusercontent.com"
69
+ export GA_CLI_CLIENT_SECRET="your-client-secret"
70
+
71
+ ## Step 6: Authenticate
72
+
73
+ ga auth login
74
+
75
+ This opens your browser for Google OAuth consent and stores the token
76
+ locally at ~/.config/ga-cli/credentials.json.
77
+
78
+ ## Verification
79
+
80
+ ga auth status # Check authentication state
81
+ ga accounts list # Verify API access
82
+
83
+ ## Notes
84
+
85
+ - "Testing" mode is sufficient for personal use — no Google verification needed
86
+ - For team use, publish the consent screen to "Production" within your GCP project
87
+ - Service account auth (ga auth login --service-account /path/key.json) does not
88
+ require OAuth credentials and works independently
89
+ """
90
+
91
+
92
+ @auth_app.command("setup")
93
+ def setup_cmd():
94
+ """Show step-by-step instructions for obtaining GCP OAuth credentials."""
95
+ print(_SETUP_GUIDE)
96
+
97
+
98
+ @auth_app.command("login")
99
+ def login_cmd(
100
+ service_account: Optional[str] = typer.Option(
101
+ None, "--service-account", "-s",
102
+ help="Path to service account key JSON file",
103
+ ),
104
+ ):
105
+ """Authenticate with Google Analytics."""
106
+ try:
107
+ if service_account:
108
+ info(f"Authenticating with service account: {service_account}")
109
+ email = login_with_service_account(service_account)
110
+ success(f"Authenticated as {email}")
111
+ info("Service account credentials are now active.")
112
+ return
113
+
114
+ # OAuth (default)
115
+ status = get_auth_status()
116
+ if status.get("authenticated") and status.get("email"):
117
+ info(f"Already authenticated as {status['email']}")
118
+ info("Run 'ga auth logout' first, or 'ga auth status' to view details.")
119
+ return
120
+
121
+ info("Opening browser for authentication...")
122
+ login()
123
+ clear_client_cache()
124
+
125
+ # Fetch user info to display
126
+ status = get_auth_status()
127
+ email = status.get("email", "unknown")
128
+ success(f"Authenticated as {email}")
129
+ except Exception as e:
130
+ error(f"Authentication failed: {e}")
131
+ raise typer.Exit(1)
132
+
133
+
134
+ @auth_app.command("logout")
135
+ def logout_cmd():
136
+ """Sign out and revoke access tokens."""
137
+ try:
138
+ auth_method = load_auth_method()
139
+ status = get_auth_status()
140
+
141
+ if not auth_method and not status.get("authenticated"):
142
+ info("Not currently authenticated.")
143
+ return
144
+
145
+ if auth_method and auth_method.get("method") == "service-account":
146
+ clear_auth_method()
147
+ email = auth_method.get("service_account_email", "")
148
+ success(f"Cleared service account configuration ({email})")
149
+ info("The service account key file was not deleted.")
150
+
151
+ if status.get("authenticated"):
152
+ logout()
153
+ clear_client_cache()
154
+ success("Logged out from OAuth session.")
155
+
156
+ except Exception as e:
157
+ error(f"Logout failed: {e}")
158
+ raise typer.Exit(1)
159
+
160
+
161
+ @auth_app.command("status")
162
+ def status_cmd(
163
+ output_format: str = typer.Option("table", "--output", "-o", help="Output format"),
164
+ ):
165
+ """Show current authentication status."""
166
+ try:
167
+ import os
168
+
169
+ # Check for env var override (same priority as GTM CLI)
170
+ env_key = os.environ.get("GA_CLI_SERVICE_ACCOUNT") or os.environ.get(
171
+ "GOOGLE_APPLICATION_CREDENTIALS"
172
+ )
173
+ if env_key:
174
+ data = {
175
+ "authenticated": True,
176
+ "method": "service-account",
177
+ "source": "environment variable",
178
+ "key_path": env_key,
179
+ }
180
+ output(data, output_format)
181
+ return
182
+
183
+ # Check saved auth method
184
+ auth_method = load_auth_method()
185
+ if auth_method and auth_method.get("method") == "service-account":
186
+ data = {
187
+ "authenticated": True,
188
+ "method": "service-account",
189
+ "email": auth_method.get("service_account_email"),
190
+ "key_path": auth_method.get("service_account_path"),
191
+ }
192
+ output(data, output_format)
193
+ return
194
+
195
+ # Fall back to OAuth
196
+ auth_status = get_auth_status()
197
+ auth_status["method"] = "oauth"
198
+ output(auth_status, output_format)
199
+
200
+ if not auth_status.get("authenticated"):
201
+ info("Run 'ga auth login' to authenticate.")
202
+
203
+ except Exception as e:
204
+ error(f"Failed to get status: {e}")
205
+ raise typer.Exit(1)