@adgine/geo-cli 1.2.8
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.
- package/README.md +93 -0
- package/bin/adgine-geo.js +224 -0
- package/package.json +38 -0
- package/skills/.env.example +11 -0
- package/skills/README.md +247 -0
- package/skills/SKILL.md +117 -0
- package/skills/VERSION +1 -0
- package/skills/adgine-geo-aiagent/SKILL.md +200 -0
- package/skills/adgine-geo-aiagent/scripts/_client.py +341 -0
- package/skills/adgine-geo-aiagent/scripts/bot_traffic.py +278 -0
- package/skills/adgine-geo-aiagent/scripts/human_traffic.py +357 -0
- package/skills/adgine-geo-aiagent/scripts/page_analytics.py +317 -0
- package/skills/adgine-geo-aiagent/scripts/page_detail.py +273 -0
- package/skills/adgine-geo-analytics/API.md +89 -0
- package/skills/adgine-geo-analytics/SKILL.md +191 -0
- package/skills/adgine-geo-analytics/scripts/_client.py +341 -0
- package/skills/adgine-geo-analytics/scripts/get_overview.py +129 -0
- package/skills/adgine-geo-billing/SKILL.md +118 -0
- package/skills/adgine-geo-billing/scripts/_client.py +341 -0
- package/skills/adgine-geo-billing/scripts/get_credits.py +51 -0
- package/skills/adgine-geo-billing/scripts/get_credits_pricing.py +52 -0
- package/skills/adgine-geo-billing/scripts/get_subscription.py +48 -0
- package/skills/adgine-geo-billing/scripts/list_plans.py +66 -0
- package/skills/adgine-geo-brand/SKILL.md +193 -0
- package/skills/adgine-geo-brand/WORKFLOW.md +66 -0
- package/skills/adgine-geo-brand/scripts/_client.py +341 -0
- package/skills/adgine-geo-brand/scripts/generate_brand.py +95 -0
- package/skills/adgine-geo-brand/scripts/get_brand.py +67 -0
- package/skills/adgine-geo-brand/scripts/list_jobs.py +153 -0
- package/skills/adgine-geo-brand/scripts/update_brand.py +61 -0
- package/skills/adgine-geo-citation/SKILL.md +220 -0
- package/skills/adgine-geo-citation/scripts/_client.py +341 -0
- package/skills/adgine-geo-citation/scripts/create_tests.py +62 -0
- package/skills/adgine-geo-citation/scripts/get_aggregate.py +108 -0
- package/skills/adgine-geo-citation/scripts/get_results.py +179 -0
- package/skills/adgine-geo-content/SKILL.md +224 -0
- package/skills/adgine-geo-content/WORKFLOW.md +100 -0
- package/skills/adgine-geo-content/scripts/_client.py +341 -0
- package/skills/adgine-geo-content/scripts/generate_article.py +108 -0
- package/skills/adgine-geo-content/scripts/generate_outline.py +113 -0
- package/skills/adgine-geo-content/scripts/generate_titles.py +51 -0
- package/skills/adgine-geo-content/scripts/list_content.py +65 -0
- package/skills/adgine-geo-content/scripts/manage_content.py +90 -0
- package/skills/adgine-geo-content/scripts/manage_jobs.py +184 -0
- package/skills/adgine-geo-dashboard/SKILL.md +181 -0
- package/skills/adgine-geo-dashboard/scripts/_client.py +341 -0
- package/skills/adgine-geo-dashboard/scripts/check_integrations.py +139 -0
- package/skills/adgine-geo-dashboard/scripts/get_overview.py +155 -0
- package/skills/adgine-geo-dashboard/scripts/get_visibility_overview.py +93 -0
- package/skills/adgine-geo-docs/SKILL.md +60 -0
- package/skills/adgine-geo-domains/SKILL.md +223 -0
- package/skills/adgine-geo-domains/scripts/_client.py +341 -0
- package/skills/adgine-geo-domains/scripts/get_domain.py +75 -0
- package/skills/adgine-geo-domains/scripts/list_domains.py +66 -0
- package/skills/adgine-geo-domains/scripts/search_domains.py +85 -0
- package/skills/adgine-geo-integrations/SKILL.md +188 -0
- package/skills/adgine-geo-integrations/scripts/_client.py +341 -0
- package/skills/adgine-geo-integrations/scripts/cloudflare_connect.py +155 -0
- package/skills/adgine-geo-integrations/scripts/cloudflare_worker.py +212 -0
- package/skills/adgine-geo-integrations/scripts/ga4_data.py +192 -0
- package/skills/adgine-geo-integrations/scripts/ga4_setup.py +120 -0
- package/skills/adgine-geo-opportunities/SKILL.md +162 -0
- package/skills/adgine-geo-opportunities/scripts/_client.py +341 -0
- package/skills/adgine-geo-opportunities/scripts/get_opportunity.py +90 -0
- package/skills/adgine-geo-opportunities/scripts/list_opportunities.py +92 -0
- package/skills/adgine-geo-performance/SKILL.md +177 -0
- package/skills/adgine-geo-performance/scripts/_client.py +341 -0
- package/skills/adgine-geo-performance/scripts/analyze_page.py +100 -0
- package/skills/adgine-geo-projects/SKILL.md +279 -0
- package/skills/adgine-geo-projects/scripts/_client.py +341 -0
- package/skills/adgine-geo-projects/scripts/check_auth.py +45 -0
- package/skills/adgine-geo-projects/scripts/list_projects.py +47 -0
- package/skills/adgine-geo-projects/scripts/manage_competitors.py +122 -0
- package/skills/adgine-geo-projects/scripts/manage_project.py +88 -0
- package/skills/adgine-geo-saas/SKILL.md +144 -0
- package/skills/adgine-geo-saas/scripts/_client.py +341 -0
- package/skills/adgine-geo-saas/scripts/check_domain.py +43 -0
- package/skills/adgine-geo-saas/scripts/create_website.py +56 -0
- package/skills/adgine-geo-saas/scripts/get_task.py +94 -0
- package/skills/adgine-geo-site-audit/README.md +281 -0
- package/skills/adgine-geo-site-audit/SKILL.md +449 -0
- package/skills/adgine-geo-site-audit/requirements.txt +5 -0
- package/skills/adgine-geo-site-audit/scripts/_client.py +341 -0
- package/skills/adgine-geo-site-audit/scripts/_geo_notice.py +55 -0
- package/skills/adgine-geo-site-audit/scripts/export_skill_package.py +197 -0
- package/skills/adgine-geo-site-audit/scripts/geo_collect.py +2443 -0
- package/skills/adgine-geo-site-audit/scripts/geo_score.py +490 -0
- package/skills/adgine-geo-site-audit/scripts/geo_timing.py +399 -0
- package/skills/adgine-geo-site-audit/scripts/geo_visibility.py +718 -0
- package/skills/adgine-geo-site-audit/scripts/render_report_pdf.py +1502 -0
- package/skills/adgine-geo-topics/SKILL.md +232 -0
- package/skills/adgine-geo-topics/scripts/_client.py +341 -0
- package/skills/adgine-geo-topics/scripts/generate_prompts.py +140 -0
- package/skills/adgine-geo-topics/scripts/manage_prompts.py +153 -0
- package/skills/adgine-geo-topics/scripts/manage_topics.py +145 -0
- package/skills/adgine-geo-visibility/SKILL.md +168 -0
- package/skills/adgine-geo-visibility/scripts/_client.py +341 -0
- package/skills/adgine-geo-visibility/scripts/get_execution.py +151 -0
- package/skills/adgine-geo-visibility/scripts/get_matrix.py +111 -0
- package/skills/adgine-geo-visibility/scripts/get_prompt_metrics.py +104 -0
- package/skills/adgine-geo-visibility/scripts/get_topic_metrics.py +201 -0
- package/skills/adgine-geo-visibility/scripts/get_visibility.py +166 -0
- package/skills/adgine-geo-wordpress/SKILL.md +151 -0
- package/skills/adgine-geo-wordpress/scripts/_client.py +341 -0
- package/skills/adgine-geo-wordpress/scripts/list_publishable.py +62 -0
- package/skills/adgine-geo-wordpress/scripts/manage_credentials.py +139 -0
- package/skills/adgine-geo-wordpress/scripts/manage_publishes.py +108 -0
- package/skills/adgine-geo-wordpress/scripts/publish.py +79 -0
- package/skills/scripts/check_version.py +290 -0
- package/skills/setup.py +248 -0
- package/src/config.js +126 -0
- package/src/doctor.js +137 -0
- package/src/install.js +90 -0
- package/src/manifest.js +99 -0
- package/src/runner.js +159 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: adgine/geo-integrations
|
|
3
|
+
description: Connects, configures, and syncs GA4 and Cloudflare data sources for a
|
|
4
|
+
GEO project — OAuth setup, property/zone selection, data sync triggers, and
|
|
5
|
+
Cloudflare Worker deployment for AI crawler tracking. Also queries the
|
|
6
|
+
integration-specific traffic data (GA4 sessions/sources, CF bot events). Use
|
|
7
|
+
when the user wants to connect GA4 (连接 GA4 / bind GA4 / GA4 OAuth / Google
|
|
8
|
+
Analytics 集成), connect Cloudflare (连接 Cloudflare / bind CF API token /
|
|
9
|
+
接入 CF), sync data (同步 GA4 / 同步 Cloudflare / 拉取数据), or deploy
|
|
10
|
+
the Cloudflare Worker (部署 Worker / Worker 部署状态).
|
|
11
|
+
NOT for high-level project traffic summary (no GA4/CF context) — use
|
|
12
|
+
adgine-geo-analytics. NOT for per-bot crawl drill-downs — use
|
|
13
|
+
adgine-geo-aiagent. For WordPress — use adgine-geo-wordpress.
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# GEO Integrations
|
|
17
|
+
|
|
18
|
+
Unified skill for the two analytics integrations (GA4 + Cloudflare). Four
|
|
19
|
+
scripts cover OAuth setup, data sync, traffic queries, and Cloudflare Worker
|
|
20
|
+
management for AI traffic tracking.
|
|
21
|
+
|
|
22
|
+
> WordPress integration lives in the separate **adgine-geo-wordpress** skill
|
|
23
|
+
> (publishing-focused, very different workflow).
|
|
24
|
+
|
|
25
|
+
## 触发条件
|
|
26
|
+
|
|
27
|
+
当用户说出以下意图时使用本 skill:
|
|
28
|
+
- 连接/绑定/接入 GA4 或 Cloudflare
|
|
29
|
+
- GA4 OAuth 授权 / 选择 GA4 媒体资源 / 选择 Cloudflare Zone
|
|
30
|
+
- 同步数据 / 拉取数据 / 手动同步
|
|
31
|
+
- 查询 GA4 流量来源 / 查询 Cloudflare bot 事件
|
|
32
|
+
- 部署/检查/删除 Cloudflare Worker
|
|
33
|
+
|
|
34
|
+
**⛔ 以下意图不属于本 skill:**
|
|
35
|
+
- “流量概览” / “项目流量汇总”(不涉及连接/同步操作)→ **adgine-geo-analytics**
|
|
36
|
+
- “哪些 AI bot 爬了我的网站” / “GPTBot 访问明细” → **adgine-geo-aiagent**
|
|
37
|
+
- “发布到 WordPress” → **adgine-geo-wordpress**
|
|
38
|
+
|
|
39
|
+
## Output rules — IDs (apply to every reply)
|
|
40
|
+
|
|
41
|
+
These rules apply to **every list, table, and confirmation message** in this skill. Their goal: keep user-facing output friendly while preserving the IDs the agent needs internally.
|
|
42
|
+
|
|
43
|
+
1. **Lists & tables — never show raw UUIDs in cells.** Use a 1-based `#` index column instead. Keep a private mental mapping of `#N → actual UUID` so that follow-up commands like *"delete #3"*, *"run citation test on #1 #2"*, *"show details of the 2nd one"* resolve to the right entity.
|
|
44
|
+
- Index numbers restart from 1 in each new list — they are not stable across calls.
|
|
45
|
+
- If the user references *"the topic about X"* / *"that Poki vs CrazyGames prompt"*, match by visible content (name / title / domain / prompt text), not by ID.
|
|
46
|
+
|
|
47
|
+
2. **Single-item operations — prefer a human name over an ID.**
|
|
48
|
+
- ✅ *"Project **Poki vs Competitors** deleted."*
|
|
49
|
+
- ✅ *"Topic **Brand mentions in 2024** updated — name → 'Brand mentions 2025'."*
|
|
50
|
+
- ❌ *"Project `a4305b57-1c79-4cec-a17c-16eb1d959ea6` deleted."*
|
|
51
|
+
- If the entity has **no human-readable name** (e.g. an anonymous prompt or a job), use a short 8-character prefix: *"Prompt `2a2a8f4f…` deleted."* Never paste the full UUID.
|
|
52
|
+
|
|
53
|
+
3. **Always exception: `--json` mode.** When the user passes `--json` to a script or explicitly asks for raw JSON / debug output, print the script output verbatim — do not strip IDs.
|
|
54
|
+
|
|
55
|
+
4. **Internally, the agent still uses full UUIDs** for every API call (`--project-id`, `--topic-id`, `--prompt-id`, etc.). The display rules only affect what is shown back to the user.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Step 1: Make sure GEO_API_KEY is configured
|
|
60
|
+
|
|
61
|
+
Scripts auto-load `GEO_API_KEY` from `<skills-root>/.env` on import — **no `export` needed, no shell restart needed**. To check the configuration, run any script (it prints the exact `.env` path if the key is missing).
|
|
62
|
+
|
|
63
|
+
- ✅ Key already in `<skills-root>/.env` → proceed.
|
|
64
|
+
- ❌ Key missing, or user just gave you a new key → go to the **adgine-geo-projects** skill, **Step 0**, which runs `python3 <skills-root>/setup.py <KEY>` to write the key into the correct `.env` file. **Never** write the key to `~/.zshrc`, `~/.bashrc`, Hermes global config, or any user-secrets store.
|
|
65
|
+
|
|
66
|
+
> ⚠️ **IMPORTANT:** In all shell/exec commands, always reference the key as `$GEO_API_KEY` (the environment variable). Never hardcode the literal value.
|
|
67
|
+
|
|
68
|
+
## Project selection
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
export GEO_PROJECT_ID=<project-id>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Scripts
|
|
75
|
+
|
|
76
|
+
### GA4 setup (OAuth + property selection)
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
python3 scripts/ga4_setup.py auth-url # show OAuth URL
|
|
80
|
+
python3 scripts/ga4_setup.py connect --code <auth_code> # manual code exchange (debug)
|
|
81
|
+
python3 scripts/ga4_setup.py properties # list available GA4 properties
|
|
82
|
+
python3 scripts/ga4_setup.py select --property-id <id> # bind property (triggers 7-day backfill)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### GA4 data (sync + queries)
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
python3 scripts/ga4_data.py sync # pull latest data into local DB
|
|
89
|
+
python3 scripts/ga4_data.py overview [--start ...] [--end ...] # sessions/users/pageviews
|
|
90
|
+
python3 scripts/ga4_data.py ai-referrals [--start ...] [--end ...] # AI platform breakdown
|
|
91
|
+
python3 scripts/ga4_data.py pages [--page 1] [--limit 20] # top pages
|
|
92
|
+
python3 scripts/ga4_data.py sources [--start ...] [--end ...] # channel split
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Cloudflare connection (token + zones + sync)
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
python3 scripts/cloudflare_connect.py list-zones --token <api_token> # zones the token can access
|
|
99
|
+
python3 scripts/cloudflare_connect.py connect --token <api_token> # bind (auto-matches zone)
|
|
100
|
+
python3 scripts/cloudflare_connect.py sync # pull latest CF data
|
|
101
|
+
python3 scripts/cloudflare_connect.py overview [--start ...] [--end ...]
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Cloudflare Worker (AI traffic tracking)
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
python3 scripts/cloudflare_worker.py config # get Worker JS code + keys
|
|
108
|
+
python3 scripts/cloudflare_worker.py deploy [--zone-id <id>] # one-click deploy
|
|
109
|
+
python3 scripts/cloudflare_worker.py deploy-status # is Worker deployed?
|
|
110
|
+
python3 scripts/cloudflare_worker.py overview [--start ...] [--end ...]
|
|
111
|
+
python3 scripts/cloudflare_worker.py pages [--page 1] [--limit 20]
|
|
112
|
+
python3 scripts/cloudflare_worker.py undeploy [--keep-script] --yes # DESTRUCTIVE
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
> The Cloudflare API token must include **Workers** permissions for deploy /
|
|
116
|
+
> undeploy / Worker analytics. Without it, `deploy-status` returns `deployed=false`
|
|
117
|
+
> (it does not throw).
|
|
118
|
+
|
|
119
|
+
## Output Format
|
|
120
|
+
|
|
121
|
+
ASCII tables only inside fenced code blocks. Status vocabulary:
|
|
122
|
+
`Connected` / `Disconnected` / `Pending` / `Yes` / `No`.
|
|
123
|
+
|
|
124
|
+
## Workflow narrative
|
|
125
|
+
|
|
126
|
+
### GA4 first-time setup
|
|
127
|
+
1. `ga4_setup.py auth-url` → user opens the URL → Google asks for permission.
|
|
128
|
+
2. After Google callback the GEO platform stores tokens automatically.
|
|
129
|
+
3. `ga4_setup.py properties` → list options.
|
|
130
|
+
4. `ga4_setup.py select --property-id <id>` → bind property.
|
|
131
|
+
5. Backfill runs automatically; check progress with `ga4_data.py overview`.
|
|
132
|
+
|
|
133
|
+
### Cloudflare first-time setup
|
|
134
|
+
1. User creates a Cloudflare API token (with Zone:Read + Analytics:Read +
|
|
135
|
+
optionally Workers:Edit).
|
|
136
|
+
2. `cloudflare_connect.py list-zones --token <t>` to verify access.
|
|
137
|
+
3. `cloudflare_connect.py connect --token <t>` to bind.
|
|
138
|
+
4. `cloudflare_connect.py sync` to pull initial data.
|
|
139
|
+
|
|
140
|
+
### Cloudflare Worker (optional, for AI tracking)
|
|
141
|
+
1. `cloudflare_worker.py config` → grab Worker JS + receiver URL.
|
|
142
|
+
2. `cloudflare_worker.py deploy` → one-click deploy.
|
|
143
|
+
3. `cloudflare_worker.py deploy-status` → verify route is live.
|
|
144
|
+
4. After traffic accrues: `cloudflare_worker.py overview` / `pages`.
|
|
145
|
+
|
|
146
|
+
## Related endpoints
|
|
147
|
+
|
|
148
|
+
| Method | Path |
|
|
149
|
+
|---|---|
|
|
150
|
+
| GET | `/api/projects/{id}/integrations/ga4/auth-url` |
|
|
151
|
+
| POST | `/api/projects/{id}/integrations/ga4/connect` |
|
|
152
|
+
| GET | `/api/projects/{id}/integrations/ga4/properties` |
|
|
153
|
+
| POST | `/api/projects/{id}/integrations/ga4/select-property` |
|
|
154
|
+
| POST | `/api/projects/{id}/integrations/ga4/sync` |
|
|
155
|
+
| GET | `/api/projects/{id}/integrations/ga4/overview` |
|
|
156
|
+
| GET | `/api/projects/{id}/integrations/ga4/ai-referrals` |
|
|
157
|
+
| GET | `/api/projects/{id}/integrations/ga4/pages` |
|
|
158
|
+
| GET | `/api/projects/{id}/integrations/ga4/sources` |
|
|
159
|
+
| POST | `/api/projects/{id}/integrations/cloudflare/list-zones` |
|
|
160
|
+
| POST | `/api/projects/{id}/integrations/cloudflare/connect` |
|
|
161
|
+
| POST | `/api/projects/{id}/integrations/cloudflare/sync` |
|
|
162
|
+
| GET | `/api/projects/{id}/integrations/cloudflare/overview` |
|
|
163
|
+
| GET | `/api/projects/{id}/integrations/cloudflare/worker-config` |
|
|
164
|
+
| POST | `/api/projects/{id}/integrations/cloudflare/worker/deploy` |
|
|
165
|
+
| DELETE | `/api/projects/{id}/integrations/cloudflare/worker/deploy` |
|
|
166
|
+
| GET | `/api/projects/{id}/integrations/cloudflare/worker/deploy-status` |
|
|
167
|
+
| GET | `/api/projects/{id}/integrations/cloudflare/worker/overview` |
|
|
168
|
+
| GET | `/api/projects/{id}/integrations/cloudflare/worker/pages` |
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Post-task recommendations
|
|
173
|
+
|
|
174
|
+
After connecting or syncing integrations, suggest the logical next step:
|
|
175
|
+
|
|
176
|
+
| You just… | → use skill (agent-internal) |
|
|
177
|
+
|---|---|
|
|
178
|
+
| Connected GA4 / Cloudflare | 查看流量概览,确认数据已流入 *(→ adgine-geo-analytics)*|
|
|
179
|
+
| Synced data | 查看 AI 爬虫追踪数据 *(→ adgine-geo-aiagent)*|
|
|
180
|
+
| Deployed Cloudflare Worker | 检查 AI bot traffic 是否开始上报 *(→ adgine-geo-aiagent)*|
|
|
181
|
+
| Checked integration status | 查看项目总览,确认所有集成健康 *(→ adgine-geo-dashboard)*|
|
|
182
|
+
| Disconnected a service | 重新连接以恢复数据流 *(→ adgine-geo-integrations)*|
|
|
183
|
+
|
|
184
|
+
**⚠️ Output rule:** Do NOT write skill names (e.g. `adgine-geo-xxx`) in user-facing suggestions. Each suggestion must be phrased as a natural-language prompt the user can copy and send directly to the agent.
|
|
185
|
+
|
|
186
|
+
> 💡 **建议下一步:**
|
|
187
|
+
> 1. **[行动标题]** — *"[可直接发送给 AI 的自然语言提示词]"*
|
|
188
|
+
> 2. **[行动标题]** — *"[可直接发送给 AI 的自然语言提示词]"*
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared HTTP client utilities for geo-skills scripts.
|
|
3
|
+
|
|
4
|
+
Used by all scripts in this skill. Uses Python stdlib only — no external dependencies.
|
|
5
|
+
|
|
6
|
+
This file is the canonical source. It must be copied verbatim into every
|
|
7
|
+
adgine-geo-* skill folder's scripts/ directory. The sync linter at
|
|
8
|
+
scripts/sync-skills/sync-skills.sh (in the GEOAgent repo) flags any drift.
|
|
9
|
+
"""
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
import atexit
|
|
15
|
+
import unicodedata
|
|
16
|
+
import urllib.request as _req
|
|
17
|
+
import urllib.error as _uerr
|
|
18
|
+
import urllib.parse as _up
|
|
19
|
+
|
|
20
|
+
def _load_dot_env():
|
|
21
|
+
"""Load .env from the repo root (adgine-geo-skills/) into os.environ.
|
|
22
|
+
|
|
23
|
+
Resolved relative to this file's location, so it works regardless of the
|
|
24
|
+
agent's working directory (e.g. Hermes, OpenClaw, or any other runtime).
|
|
25
|
+
Existing environment variables are never overwritten.
|
|
26
|
+
"""
|
|
27
|
+
if os.environ.get("GEO_API_KEY"):
|
|
28
|
+
return
|
|
29
|
+
# _client.py is at <repo_root>/adgine-geo-<skill>/scripts/_client.py
|
|
30
|
+
repo_root = os.path.dirname(
|
|
31
|
+
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
32
|
+
)
|
|
33
|
+
env_path = os.path.join(repo_root, ".env")
|
|
34
|
+
if not os.path.isfile(env_path):
|
|
35
|
+
return
|
|
36
|
+
with open(env_path) as _f:
|
|
37
|
+
for _line in _f:
|
|
38
|
+
_line = _line.strip()
|
|
39
|
+
if not _line or _line.startswith("#") or "=" not in _line:
|
|
40
|
+
continue
|
|
41
|
+
_k, _, _v = _line.partition("=")
|
|
42
|
+
_k = _k.strip()
|
|
43
|
+
_v = _v.strip()
|
|
44
|
+
if _k and _k not in os.environ:
|
|
45
|
+
os.environ[_k] = _v
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_load_dot_env()
|
|
49
|
+
|
|
50
|
+
_VERSION_UNCHECKED = object()
|
|
51
|
+
_version_state = _VERSION_UNCHECKED
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _repo_root():
|
|
55
|
+
return os.path.dirname(
|
|
56
|
+
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _load_check_version_module():
|
|
61
|
+
import importlib.util
|
|
62
|
+
path = os.path.join(_repo_root(), "scripts", "check_version.py")
|
|
63
|
+
if not os.path.isfile(path):
|
|
64
|
+
return None
|
|
65
|
+
spec = importlib.util.spec_from_file_location("geo_check_version", path)
|
|
66
|
+
mod = importlib.util.module_from_spec(spec)
|
|
67
|
+
spec.loader.exec_module(mod)
|
|
68
|
+
return mod
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _load_version_state():
|
|
72
|
+
global _version_state
|
|
73
|
+
if _version_state is not _VERSION_UNCHECKED:
|
|
74
|
+
return _version_state
|
|
75
|
+
_version_state = None
|
|
76
|
+
try:
|
|
77
|
+
mod = _load_check_version_module()
|
|
78
|
+
if mod is not None:
|
|
79
|
+
_version_state = mod.get_state()
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
return _version_state
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _print_version_notice():
|
|
86
|
+
"""Emit machine + human hints at import if a newer version exists."""
|
|
87
|
+
state = _load_version_state()
|
|
88
|
+
if not state or not state.get("update_available"):
|
|
89
|
+
return
|
|
90
|
+
cur, lat = state["current"], state["latest"]
|
|
91
|
+
try:
|
|
92
|
+
mod = _load_check_version_module()
|
|
93
|
+
if mod is not None:
|
|
94
|
+
inline = mod.format_user_inline(state)
|
|
95
|
+
if inline:
|
|
96
|
+
sys.stdout.write(inline + "\n\n")
|
|
97
|
+
sys.stdout.flush()
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
if state.get("install_type") == "git":
|
|
101
|
+
msg = (f"adgine-geo-skills {lat} available (current {cur}). "
|
|
102
|
+
"Tell me: 请帮我更新 adgine-geo-skills 到最新版本")
|
|
103
|
+
else:
|
|
104
|
+
msg = (f"adgine-geo-skills {lat} available (current {cur}). "
|
|
105
|
+
f"Download: {state.get('release_url', '')}")
|
|
106
|
+
notice = {"update": {"current": cur, "latest": lat, "message": msg}}
|
|
107
|
+
try:
|
|
108
|
+
line = f"_notice: {json.dumps(notice, ensure_ascii=False)}\n\n"
|
|
109
|
+
sys.stdout.write(line)
|
|
110
|
+
sys.stdout.flush()
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _print_version_footer():
|
|
116
|
+
"""Emit human-readable footer at process exit (WorkBuddy-compatible)."""
|
|
117
|
+
state = _load_version_state()
|
|
118
|
+
if not state or not state.get("update_available"):
|
|
119
|
+
return
|
|
120
|
+
try:
|
|
121
|
+
mod = _load_check_version_module()
|
|
122
|
+
if mod is None:
|
|
123
|
+
return
|
|
124
|
+
footer = mod.format_user_footer(state)
|
|
125
|
+
if footer:
|
|
126
|
+
sys.stdout.write("\n" + footer + "\n")
|
|
127
|
+
sys.stdout.flush()
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
_print_version_notice()
|
|
133
|
+
atexit.register(_print_version_footer)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def get_api_config():
|
|
137
|
+
"""Read GEO_API_KEY and GEO_API_BASE_URL from environment.
|
|
138
|
+
|
|
139
|
+
Exits with a helpful message if GEO_API_KEY is not set.
|
|
140
|
+
Returns (key, base_url).
|
|
141
|
+
"""
|
|
142
|
+
key = os.environ.get("GEO_API_KEY", "")
|
|
143
|
+
base = os.environ.get("GEO_API_BASE_URL", "https://platform.adgine.ai").rstrip("/")
|
|
144
|
+
if not key:
|
|
145
|
+
repo_root = os.path.dirname(
|
|
146
|
+
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
147
|
+
)
|
|
148
|
+
env_path = os.path.join(repo_root, ".env")
|
|
149
|
+
example_path = os.path.join(repo_root, ".env.example")
|
|
150
|
+
setup_path = os.path.join(repo_root, "setup.py")
|
|
151
|
+
print("ERROR: GEO_API_KEY is not set.")
|
|
152
|
+
print(f" Expected location: {env_path}")
|
|
153
|
+
print()
|
|
154
|
+
print(" One-shot fix (recommended):")
|
|
155
|
+
print(f" python3 {setup_path} <YOUR_KEY>")
|
|
156
|
+
print()
|
|
157
|
+
print(" Or set it manually:")
|
|
158
|
+
if not os.path.isfile(env_path):
|
|
159
|
+
print(f" cp {example_path} {env_path}")
|
|
160
|
+
print(f" edit {env_path} and set GEO_API_KEY=geo_sk_live_xxx")
|
|
161
|
+
print()
|
|
162
|
+
print(" Get your key at: https://platform.adgine.ai")
|
|
163
|
+
print(" The .env file is gitignored — your key stays local and private.")
|
|
164
|
+
print()
|
|
165
|
+
print(" NOTE: adgine-geo-site-audit does NOT require an API key.")
|
|
166
|
+
print(" Run directly: python3 adgine-geo-site-audit/scripts/geo_collect.py <url>")
|
|
167
|
+
sys.exit(1)
|
|
168
|
+
return key, base
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def get_project_id(arg_value=None):
|
|
172
|
+
"""Resolve project ID from argument or GEO_PROJECT_ID env var.
|
|
173
|
+
|
|
174
|
+
Exits with instructions if neither is set.
|
|
175
|
+
"""
|
|
176
|
+
pid = arg_value or os.environ.get("GEO_PROJECT_ID", "")
|
|
177
|
+
if not pid:
|
|
178
|
+
print("ERROR: Project ID is required but not set.")
|
|
179
|
+
print(" Option 1: export GEO_PROJECT_ID=<project-id>")
|
|
180
|
+
print(" Option 2: pass --project-id <id> to this script")
|
|
181
|
+
print(" Run list_projects.py from the adgine-geo-projects skill to see your available projects.")
|
|
182
|
+
sys.exit(1)
|
|
183
|
+
return pid
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _do_request(method, url, key, body=None, timeout=30):
|
|
187
|
+
"""Execute an HTTP request and return parsed JSON.
|
|
188
|
+
|
|
189
|
+
Exits with an error message on HTTP errors or network failures.
|
|
190
|
+
"""
|
|
191
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
192
|
+
headers = {
|
|
193
|
+
"Authorization": f"Bearer {key}",
|
|
194
|
+
"Content-Type": "application/json",
|
|
195
|
+
"Accept": "application/json",
|
|
196
|
+
"User-Agent": "geo-skills/1.0",
|
|
197
|
+
}
|
|
198
|
+
request = _req.Request(url, data=data, headers=headers, method=method)
|
|
199
|
+
try:
|
|
200
|
+
with _req.urlopen(request, timeout=timeout) as resp:
|
|
201
|
+
raw = resp.read().decode("utf-8")
|
|
202
|
+
return json.loads(raw) if raw else {}
|
|
203
|
+
except _uerr.HTTPError as e:
|
|
204
|
+
raw = e.read().decode("utf-8")[:400]
|
|
205
|
+
try:
|
|
206
|
+
err_data = json.loads(raw)
|
|
207
|
+
msg = err_data.get("message") or err_data.get("detail") or raw
|
|
208
|
+
except Exception:
|
|
209
|
+
msg = raw
|
|
210
|
+
if e.code == 401:
|
|
211
|
+
print("ERROR: Unauthorized — API key is invalid or revoked.")
|
|
212
|
+
print(" Generate a new key: https://platform.adgine.ai")
|
|
213
|
+
elif e.code == 403:
|
|
214
|
+
print("ERROR: Forbidden — insufficient permissions for this operation.")
|
|
215
|
+
elif e.code == 404:
|
|
216
|
+
print(f"ERROR: Not found — {msg}")
|
|
217
|
+
else:
|
|
218
|
+
print(f"ERROR: HTTP {e.code} — {msg}")
|
|
219
|
+
sys.exit(1)
|
|
220
|
+
except Exception as e:
|
|
221
|
+
print(f"ERROR: Request failed — {e}")
|
|
222
|
+
sys.exit(1)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def api_get(path, key, base, params=None, timeout=30):
|
|
226
|
+
url = f"{base}{path}"
|
|
227
|
+
if params:
|
|
228
|
+
clean = {k: str(v) for k, v in params.items() if v is not None}
|
|
229
|
+
if clean:
|
|
230
|
+
url += "?" + _up.urlencode(clean)
|
|
231
|
+
return _do_request("GET", url, key, timeout=timeout)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def api_post(path, key, base, body=None, timeout=30):
|
|
235
|
+
return _do_request("POST", f"{base}{path}", key, body, timeout=timeout)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def api_patch(path, key, base, body=None, timeout=30):
|
|
239
|
+
return _do_request("PATCH", f"{base}{path}", key, body, timeout=timeout)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def api_put(path, key, base, body=None, timeout=30):
|
|
243
|
+
return _do_request("PUT", f"{base}{path}", key, body, timeout=timeout)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def api_delete(path, key, base, timeout=30):
|
|
247
|
+
return _do_request("DELETE", f"{base}{path}", key, timeout=timeout)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def extract_data(result):
|
|
251
|
+
"""Extract the .data field from a standard geo-api envelope response.
|
|
252
|
+
|
|
253
|
+
On success the API returns {"code": 0, "data": <payload>, "message": "ok"}.
|
|
254
|
+
On non-zero code, this prints the message and exits(1).
|
|
255
|
+
"""
|
|
256
|
+
if not isinstance(result, dict):
|
|
257
|
+
return result
|
|
258
|
+
if "code" in result and result.get("code") not in (0, None):
|
|
259
|
+
msg = result.get("message") or result.get("detail") or "request failed"
|
|
260
|
+
print(f"ERROR: API returned code={result.get('code')} — {msg}")
|
|
261
|
+
sys.exit(1)
|
|
262
|
+
return result.get("data", result)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def poll_job(status_path, key, base, interval=3, max_wait=300,
|
|
266
|
+
terminal=("completed", "failed", "done", "success", "error")):
|
|
267
|
+
"""Poll a job/task endpoint until it reaches a terminal state.
|
|
268
|
+
|
|
269
|
+
Prints an inline spinner with the current phase. Returns the final job dict.
|
|
270
|
+
"""
|
|
271
|
+
elapsed = 0
|
|
272
|
+
frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
273
|
+
idx = 0
|
|
274
|
+
last_job = {}
|
|
275
|
+
while elapsed < max_wait:
|
|
276
|
+
result = api_get(status_path, key, base)
|
|
277
|
+
last_job = extract_data(result) or {}
|
|
278
|
+
status = (last_job.get("status") or "").lower()
|
|
279
|
+
phase = last_job.get("current_phase") or last_job.get("phase") or status or "pending"
|
|
280
|
+
print(f"\r {frames[idx % len(frames)]} {phase}... ({elapsed}s)", end="", flush=True)
|
|
281
|
+
idx += 1
|
|
282
|
+
if status in terminal:
|
|
283
|
+
print() # newline after spinner
|
|
284
|
+
return last_job
|
|
285
|
+
time.sleep(interval)
|
|
286
|
+
elapsed += interval
|
|
287
|
+
print()
|
|
288
|
+
print(f"WARNING: Job is still running after {max_wait}s. Check status manually.")
|
|
289
|
+
return last_job
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def print_json(data):
|
|
293
|
+
"""Print data as formatted JSON (UTF-8 safe)."""
|
|
294
|
+
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def fmt_change(value):
|
|
298
|
+
"""Format a numeric change indicator per CONVENTIONS.md §4.
|
|
299
|
+
|
|
300
|
+
Returns: '+N' / '-N' / '0' / '--' (None becomes '--').
|
|
301
|
+
"""
|
|
302
|
+
if value is None:
|
|
303
|
+
return "--"
|
|
304
|
+
try:
|
|
305
|
+
n = float(value)
|
|
306
|
+
except (TypeError, ValueError):
|
|
307
|
+
return "--"
|
|
308
|
+
if n == 0:
|
|
309
|
+
return "0"
|
|
310
|
+
sign = "+" if n > 0 else ""
|
|
311
|
+
if n == int(n):
|
|
312
|
+
return f"{sign}{int(n):,}"
|
|
313
|
+
return f"{sign}{n:,.1f}"
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def truncate(text, n=60, ellipsis="…"):
|
|
317
|
+
"""Truncate text to n characters, adding ellipsis if cut. ASCII-safe for tables."""
|
|
318
|
+
if text is None:
|
|
319
|
+
return "--"
|
|
320
|
+
s = str(text)
|
|
321
|
+
if len(s) <= n:
|
|
322
|
+
return s
|
|
323
|
+
return s[: max(0, n - len(ellipsis))] + ellipsis
|
|
324
|
+
|
|
325
|
+
def display_width(s):
|
|
326
|
+
"""Return the display width of a string (CJK chars count as 2 columns)."""
|
|
327
|
+
w = 0
|
|
328
|
+
for c in str(s):
|
|
329
|
+
eaw = unicodedata.east_asian_width(c)
|
|
330
|
+
w += 2 if eaw in ('W', 'F') else 1
|
|
331
|
+
return w
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def pad(s, width, align='left'):
|
|
335
|
+
"""Pad string to display width for CJK-aware table alignment.
|
|
336
|
+
Replaces f'{s:<N}' (align='left') and f'{s:>N}' (align='right').
|
|
337
|
+
"""
|
|
338
|
+
s = str(s)
|
|
339
|
+
dw = display_width(s)
|
|
340
|
+
spaces = max(0, width - dw) * ' '
|
|
341
|
+
return spaces + s if align == 'right' else s + spaces
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Cloudflare: token connect, zone discovery, traffic sync & overview.
|
|
3
|
+
|
|
4
|
+
Subcommands:
|
|
5
|
+
list-zones --token <api_token> — list zones accessible by a given API token
|
|
6
|
+
connect --token <api_token> — connect Cloudflare (saves token, auto-matches zone)
|
|
7
|
+
sync — pull latest Cloudflare analytics into local DB
|
|
8
|
+
overview [--start <>] [--end <>] — Cloudflare traffic overview + daily trend
|
|
9
|
+
|
|
10
|
+
Worker management (AI-traffic tracking) lives in cloudflare_worker.py.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
python3 scripts/cloudflare_connect.py list-zones --token <cf_api_token>
|
|
14
|
+
python3 scripts/cloudflare_connect.py connect --token <cf_api_token>
|
|
15
|
+
python3 scripts/cloudflare_connect.py sync
|
|
16
|
+
python3 scripts/cloudflare_connect.py overview --start 2025-11-01
|
|
17
|
+
"""
|
|
18
|
+
import sys
|
|
19
|
+
import os
|
|
20
|
+
import argparse
|
|
21
|
+
|
|
22
|
+
sys.path.insert(0, os.path.dirname(__file__))
|
|
23
|
+
from _client import (
|
|
24
|
+
get_api_config, get_project_id,
|
|
25
|
+
api_get, api_post,
|
|
26
|
+
extract_data, print_json, truncate,
|
|
27
|
+
pad,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def _fmt_num(n):
|
|
31
|
+
if n is None:
|
|
32
|
+
return "--"
|
|
33
|
+
try:
|
|
34
|
+
f = float(n)
|
|
35
|
+
if f == int(f):
|
|
36
|
+
return f"{int(f):,}"
|
|
37
|
+
return f"{f:,.1f}"
|
|
38
|
+
except (TypeError, ValueError):
|
|
39
|
+
return str(n)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cmd_list_zones(args, key, base, pid):
|
|
43
|
+
result = api_post(f"/api/projects/{pid}/integrations/cloudflare/list-zones", key, base,
|
|
44
|
+
body={"api_token": args.token})
|
|
45
|
+
data = extract_data(result)
|
|
46
|
+
items = data if isinstance(data, list) else (data or {}).get("zones", [])
|
|
47
|
+
if args.json:
|
|
48
|
+
print_json(items)
|
|
49
|
+
return
|
|
50
|
+
if not items:
|
|
51
|
+
print("No zones accessible with that token.")
|
|
52
|
+
return
|
|
53
|
+
print(f"Cloudflare zones ({len(items)})")
|
|
54
|
+
print()
|
|
55
|
+
print("```")
|
|
56
|
+
print("┌──────────────────────┬────────────────────────────┐")
|
|
57
|
+
print("│ Zone ID │ Name │")
|
|
58
|
+
print("├──────────────────────┼────────────────────────────┤")
|
|
59
|
+
for z in items:
|
|
60
|
+
zid = truncate(z.get("id") or z.get("zone_id"), 20)
|
|
61
|
+
name = truncate(z.get("name") or z.get("domain"), 26)
|
|
62
|
+
print(f"│ {pad(zid, 20)} │ {pad(name, 26)} │")
|
|
63
|
+
print("└──────────────────────┴────────────────────────────┘")
|
|
64
|
+
print("```")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cmd_connect(args, key, base, pid):
|
|
68
|
+
result = api_post(f"/api/projects/{pid}/integrations/cloudflare/connect", key, base,
|
|
69
|
+
body={"api_token": args.token})
|
|
70
|
+
data = extract_data(result) or {}
|
|
71
|
+
if args.json:
|
|
72
|
+
print_json(data)
|
|
73
|
+
return
|
|
74
|
+
print("Cloudflare connected.")
|
|
75
|
+
for k in ("zone_id", "zone_name", "account_id"):
|
|
76
|
+
if k in data:
|
|
77
|
+
print(f" {k}: {data.get(k)}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cmd_sync(args, key, base, pid):
|
|
81
|
+
result = api_post(f"/api/projects/{pid}/integrations/cloudflare/sync", key, base)
|
|
82
|
+
data = extract_data(result) or {}
|
|
83
|
+
if args.json:
|
|
84
|
+
print_json(data)
|
|
85
|
+
return
|
|
86
|
+
print("Cloudflare sync started.")
|
|
87
|
+
for k in ("status", "rows_synced", "started_at"):
|
|
88
|
+
if k in data:
|
|
89
|
+
print(f" {k}: {data.get(k)}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def cmd_overview(args, key, base, pid):
|
|
93
|
+
params = {}
|
|
94
|
+
if args.start:
|
|
95
|
+
params["start_date"] = args.start
|
|
96
|
+
if args.end:
|
|
97
|
+
params["end_date"] = args.end
|
|
98
|
+
result = api_get(f"/api/projects/{pid}/integrations/cloudflare/overview", key, base,
|
|
99
|
+
params=params or None)
|
|
100
|
+
data = extract_data(result) or {}
|
|
101
|
+
if args.json:
|
|
102
|
+
print_json(data)
|
|
103
|
+
return
|
|
104
|
+
print("Cloudflare Overview")
|
|
105
|
+
print()
|
|
106
|
+
print("```")
|
|
107
|
+
print("┌────────────────────┬──────────────┐")
|
|
108
|
+
print("│ Metric │ Value │")
|
|
109
|
+
print("├────────────────────┼──────────────┤")
|
|
110
|
+
for k, label in [
|
|
111
|
+
("total_requests", "Total requests"),
|
|
112
|
+
("bandwidth", "Bandwidth"),
|
|
113
|
+
("page_views", "Page views"),
|
|
114
|
+
("unique_visitors", "Unique visitors"),
|
|
115
|
+
("threats_blocked", "Threats blocked"),
|
|
116
|
+
]:
|
|
117
|
+
if k in data:
|
|
118
|
+
print(f"│ {pad(label, 18)} │ {_fmt_num(data.get(k)):>12} │")
|
|
119
|
+
print("└────────────────────┴──────────────┘")
|
|
120
|
+
print("```")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def main():
|
|
124
|
+
parser = argparse.ArgumentParser(description="Cloudflare connection & traffic queries")
|
|
125
|
+
parser.add_argument("--project-id", help="Project ID (or set GEO_PROJECT_ID env var)")
|
|
126
|
+
parser.add_argument("--json", action="store_true", help="Output raw JSON")
|
|
127
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
128
|
+
|
|
129
|
+
p_lz = sub.add_parser("list-zones")
|
|
130
|
+
p_lz.add_argument("--token", required=True, help="Cloudflare API token")
|
|
131
|
+
|
|
132
|
+
p_c = sub.add_parser("connect")
|
|
133
|
+
p_c.add_argument("--token", required=True, help="Cloudflare API token")
|
|
134
|
+
|
|
135
|
+
sub.add_parser("sync")
|
|
136
|
+
|
|
137
|
+
p_o = sub.add_parser("overview")
|
|
138
|
+
p_o.add_argument("--start", help="Start date YYYY-MM-DD")
|
|
139
|
+
p_o.add_argument("--end", help="End date YYYY-MM-DD")
|
|
140
|
+
|
|
141
|
+
args = parser.parse_args()
|
|
142
|
+
key, base = get_api_config()
|
|
143
|
+
pid = get_project_id(args.project_id)
|
|
144
|
+
|
|
145
|
+
handlers = {
|
|
146
|
+
"list-zones": cmd_list_zones,
|
|
147
|
+
"connect": cmd_connect,
|
|
148
|
+
"sync": cmd_sync,
|
|
149
|
+
"overview": cmd_overview,
|
|
150
|
+
}
|
|
151
|
+
handlers[args.command](args, key, base, pid)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
main()
|