diff --git a/tools/REGISTRY.md b/tools/REGISTRY.md index 6f81b9e..cc79f68 100644 --- a/tools/REGISTRY.md +++ b/tools/REGISTRY.md @@ -14,32 +14,32 @@ Quick reference for AI agents to discover tool capabilities and integration meth | Tool | Category | API | MCP | CLI | SDK | Guide | |------|----------|:---:|:---:|:---:|:---:|-------| -| ga4 | Analytics | ✓ | ✓ | - | ✓ | [ga4.md](integrations/ga4.md) | -| mixpanel | Analytics | ✓ | - | - | ✓ | [mixpanel.md](integrations/mixpanel.md) | -| amplitude | Analytics | ✓ | - | - | ✓ | [amplitude.md](integrations/amplitude.md) | +| ga4 | Analytics | ✓ | ✓ | [✓](clis/ga4.js) | ✓ | [ga4.md](integrations/ga4.md) | +| mixpanel | Analytics | ✓ | - | [✓](clis/mixpanel.js) | ✓ | [mixpanel.md](integrations/mixpanel.md) | +| amplitude | Analytics | ✓ | - | [✓](clis/amplitude.js) | ✓ | [amplitude.md](integrations/amplitude.md) | | posthog | Analytics | ✓ | - | ✓ | ✓ | [posthog.md](integrations/posthog.md) | -| segment | Analytics | ✓ | - | - | ✓ | [segment.md](integrations/segment.md) | -| adobe-analytics | Analytics | ✓ | - | - | ✓ | [adobe-analytics.md](integrations/adobe-analytics.md) | -| google-search-console | SEO | ✓ | - | - | ✓ | [google-search-console.md](integrations/google-search-console.md) | -| semrush | SEO | ✓ | - | - | - | [semrush.md](integrations/semrush.md) | -| ahrefs | SEO | ✓ | - | - | - | [ahrefs.md](integrations/ahrefs.md) | +| segment | Analytics | ✓ | - | [✓](clis/segment.js) | ✓ | [segment.md](integrations/segment.md) | +| adobe-analytics | Analytics | ✓ | - | [✓](clis/adobe-analytics.js) | ✓ | [adobe-analytics.md](integrations/adobe-analytics.md) | +| google-search-console | SEO | ✓ | - | [✓](clis/google-search-console.js) | ✓ | [google-search-console.md](integrations/google-search-console.md) | +| semrush | SEO | ✓ | - | [✓](clis/semrush.js) | - | [semrush.md](integrations/semrush.md) | +| ahrefs | SEO | ✓ | - | [✓](clis/ahrefs.js) | - | [ahrefs.md](integrations/ahrefs.md) | | hubspot | CRM | ✓ | - | ✓ | ✓ | [hubspot.md](integrations/hubspot.md) | | salesforce | CRM | ✓ | - | ✓ | ✓ | [salesforce.md](integrations/salesforce.md) | | stripe | Payments | ✓ | ✓ | ✓ | ✓ | [stripe.md](integrations/stripe.md) | -| rewardful | Referral | ✓ | - | - | - | [rewardful.md](integrations/rewardful.md) | -| tolt | Referral | ✓ | - | - | - | [tolt.md](integrations/tolt.md) | -| dub-co | Links | ✓ | - | - | ✓ | [dub-co.md](integrations/dub-co.md) | -| mention-me | Referral | ✓ | - | - | - | [mention-me.md](integrations/mention-me.md) | -| mailchimp | Email | ✓ | ✓ | - | ✓ | [mailchimp.md](integrations/mailchimp.md) | -| customer-io | Email | ✓ | - | - | ✓ | [customer-io.md](integrations/customer-io.md) | -| sendgrid | Email | ✓ | - | - | ✓ | [sendgrid.md](integrations/sendgrid.md) | -| resend | Email | ✓ | ✓ | - | ✓ | [resend.md](integrations/resend.md) | -| kit | Email | ✓ | - | - | ✓ | [kit.md](integrations/kit.md) | -| google-ads | Ads | ✓ | ✓ | - | ✓ | [google-ads.md](integrations/google-ads.md) | -| meta-ads | Ads | ✓ | - | - | ✓ | [meta-ads.md](integrations/meta-ads.md) | -| linkedin-ads | Ads | ✓ | - | - | - | [linkedin-ads.md](integrations/linkedin-ads.md) | -| tiktok-ads | Ads | ✓ | - | - | ✓ | [tiktok-ads.md](integrations/tiktok-ads.md) | -| zapier | Automation | ✓ | ✓ | - | - | [zapier.md](integrations/zapier.md) | +| rewardful | Referral | ✓ | - | [✓](clis/rewardful.js) | - | [rewardful.md](integrations/rewardful.md) | +| tolt | Referral | ✓ | - | [✓](clis/tolt.js) | - | [tolt.md](integrations/tolt.md) | +| dub-co | Links | ✓ | - | [✓](clis/dub.js) | ✓ | [dub-co.md](integrations/dub-co.md) | +| mention-me | Referral | ✓ | - | [✓](clis/mention-me.js) | - | [mention-me.md](integrations/mention-me.md) | +| mailchimp | Email | ✓ | ✓ | [✓](clis/mailchimp.js) | ✓ | [mailchimp.md](integrations/mailchimp.md) | +| customer-io | Email | ✓ | - | [✓](clis/customer-io.js) | ✓ | [customer-io.md](integrations/customer-io.md) | +| sendgrid | Email | ✓ | - | [✓](clis/sendgrid.js) | ✓ | [sendgrid.md](integrations/sendgrid.md) | +| resend | Email | ✓ | ✓ | [✓](clis/resend.js) | ✓ | [resend.md](integrations/resend.md) | +| kit | Email | ✓ | - | [✓](clis/kit.js) | ✓ | [kit.md](integrations/kit.md) | +| google-ads | Ads | ✓ | ✓ | [✓](clis/google-ads.js) | ✓ | [google-ads.md](integrations/google-ads.md) | +| meta-ads | Ads | ✓ | - | [✓](clis/meta-ads.js) | ✓ | [meta-ads.md](integrations/meta-ads.md) | +| linkedin-ads | Ads | ✓ | - | [✓](clis/linkedin-ads.js) | - | [linkedin-ads.md](integrations/linkedin-ads.md) | +| tiktok-ads | Ads | ✓ | - | [✓](clis/tiktok-ads.js) | ✓ | [tiktok-ads.md](integrations/tiktok-ads.md) | +| zapier | Automation | ✓ | ✓ | [✓](clis/zapier.js) | - | [zapier.md](integrations/zapier.md) | | shopify | Commerce | ✓ | - | ✓ | ✓ | [shopify.md](integrations/shopify.md) | | wordpress | CMS | ✓ | - | ✓ | ✓ | [wordpress.md](integrations/wordpress.md) | | webflow | CMS | ✓ | - | ✓ | ✓ | [webflow.md](integrations/webflow.md) | @@ -160,6 +160,18 @@ E-commerce platforms and content management systems. --- +## CLI Tools + +Zero-dependency, single-file Node.js CLIs for tools that don't ship their own. See [`clis/README.md`](clis/README.md) for install instructions and usage. + +All CLIs follow a consistent pattern: +- **No dependencies** — Node 18+ only, uses native `fetch` +- **JSON output** — pipe to `jq`, save to file, or use in scripts +- **Env var auth** — set `{TOOL}_API_KEY` and go +- **Consistent commands** — `{tool} [options]` + +--- + ## MCP-Enabled Tools These tools have Model Context Protocol servers available, enabling direct agent interaction: diff --git a/tools/clis/README.md b/tools/clis/README.md new file mode 100644 index 0000000..0d14dce --- /dev/null +++ b/tools/clis/README.md @@ -0,0 +1,120 @@ +# Marketing CLIs + +Zero-dependency, single-file CLI tools for marketing platforms that don't ship their own. + +Every CLI is a standalone Node.js script (Node 18+) with no `npm install` required — just `chmod +x` and go. + +## Install + +### Option 1: Run directly + +```bash +node tools/clis/ahrefs.js backlinks list --target example.com +``` + +### Option 2: Symlink for global access + +```bash +# Symlink any CLI you want available globally +ln -sf "$(pwd)/tools/clis/ahrefs.js" ~/.local/bin/ahrefs +ln -sf "$(pwd)/tools/clis/resend.js" ~/.local/bin/resend + +# Then use directly +ahrefs backlinks list --target example.com +resend send --from you@example.com --to them@example.com --subject "Hello" --html "

Hi

" +``` + +### Option 3: Add the whole directory to PATH + +```bash +export PATH="$PATH:/path/to/marketingskills/tools/clis" +``` + +## Authentication + +Every CLI reads credentials from environment variables: + +| CLI | Environment Variable | +|-----|---------------------| +| `ahrefs` | `AHREFS_API_KEY` | +| `adobe-analytics` | `ADOBE_CLIENT_ID`, `ADOBE_ACCESS_TOKEN` | +| `amplitude` | `AMPLITUDE_API_KEY`, `AMPLITUDE_SECRET_KEY` | +| `customer-io` | `CUSTOMERIO_APP_KEY` (App API), `CUSTOMERIO_SITE_ID` + `CUSTOMERIO_API_KEY` (Track API) | +| `dub` | `DUB_API_KEY` | +| `ga4` | `GA4_ACCESS_TOKEN` | +| `google-ads` | `GOOGLE_ADS_TOKEN`, `GOOGLE_ADS_DEVELOPER_TOKEN` | +| `google-search-console` | `GSC_ACCESS_TOKEN` | +| `kit` | `KIT_API_KEY`, `KIT_API_SECRET` | +| `linkedin-ads` | `LINKEDIN_ACCESS_TOKEN` | +| `mailchimp` | `MAILCHIMP_API_KEY` | +| `mention-me` | `MENTIONME_API_KEY` | +| `meta-ads` | `META_ACCESS_TOKEN` | +| `mixpanel` | `MIXPANEL_TOKEN` (ingestion), `MIXPANEL_API_KEY` + `MIXPANEL_SECRET` (query) | +| `resend` | `RESEND_API_KEY` | +| `rewardful` | `REWARDFUL_API_KEY` | +| `segment` | `SEGMENT_WRITE_KEY` (tracking), `SEGMENT_ACCESS_TOKEN` (profile) | +| `semrush` | `SEMRUSH_API_KEY` | +| `sendgrid` | `SENDGRID_API_KEY` | +| `tiktok-ads` | `TIKTOK_ACCESS_TOKEN` | +| `tolt` | `TOLT_API_KEY` | +| `zapier` | `ZAPIER_API_KEY` | + +## Command Pattern + +All CLIs follow the same structure: + +``` +{tool} [options] +``` + +Examples: + +```bash +ahrefs backlinks list --target example.com --limit 50 +semrush keywords overview --phrase "marketing automation" --database us +mailchimp campaigns list --limit 20 +resend send --from you@example.com --to them@example.com --subject "Hello" --html "

Hi

" +dub links create --url https://example.com/landing --key summer-sale +``` + +## Output + +All CLIs output JSON to stdout for easy piping: + +```bash +# Pipe to jq +ahrefs backlinks list --target example.com | jq '.backlinks[].url_from' + +# Save to file +semrush keywords overview --phrase "saas marketing" --database us > keywords.json + +# Use in scripts +DOMAINS=$(rewardful affiliates list | jq -r '.data[].email') +``` + +## Available CLIs + +| CLI | Category | Tool | +|-----|----------|------| +| `resend.js` | Email | [Resend](https://resend.com) | +| `sendgrid.js` | Email | [SendGrid](https://sendgrid.com) | +| `mailchimp.js` | Email | [Mailchimp](https://mailchimp.com) | +| `kit.js` | Email | [Kit](https://kit.com) | +| `customer-io.js` | Email | [Customer.io](https://customer.io) | +| `ahrefs.js` | SEO | [Ahrefs](https://ahrefs.com) | +| `semrush.js` | SEO | [SEMrush](https://semrush.com) | +| `google-search-console.js` | SEO | [Google Search Console](https://search.google.com/search-console) | +| `ga4.js` | Analytics | [Google Analytics 4](https://analytics.google.com) | +| `mixpanel.js` | Analytics | [Mixpanel](https://mixpanel.com) | +| `amplitude.js` | Analytics | [Amplitude](https://amplitude.com) | +| `segment.js` | Analytics | [Segment](https://segment.com) | +| `adobe-analytics.js` | Analytics | [Adobe Analytics](https://business.adobe.com/products/analytics) | +| `rewardful.js` | Referral | [Rewardful](https://www.getrewardful.com) | +| `tolt.js` | Referral | [Tolt](https://tolt.io) | +| `mention-me.js` | Referral | [Mention Me](https://www.mention-me.com) | +| `dub.js` | Links | [Dub.co](https://dub.co) | +| `google-ads.js` | Ads | [Google Ads](https://ads.google.com) | +| `meta-ads.js` | Ads | [Meta Ads](https://www.facebook.com/business/ads) | +| `linkedin-ads.js` | Ads | [LinkedIn Ads](https://business.linkedin.com/marketing-solutions/ads) | +| `tiktok-ads.js` | Ads | [TikTok Ads](https://ads.tiktok.com) | +| `zapier.js` | Automation | [Zapier](https://zapier.com) | diff --git a/tools/clis/adobe-analytics.js b/tools/clis/adobe-analytics.js new file mode 100755 index 0000000..bd3d376 --- /dev/null +++ b/tools/clis/adobe-analytics.js @@ -0,0 +1,157 @@ +#!/usr/bin/env node + +const ACCESS_TOKEN = process.env.ADOBE_ACCESS_TOKEN +const CLIENT_ID = process.env.ADOBE_CLIENT_ID +const COMPANY_ID = process.env.ADOBE_COMPANY_ID + +if (!ACCESS_TOKEN || !CLIENT_ID || !COMPANY_ID) { + console.error(JSON.stringify({ error: 'ADOBE_ACCESS_TOKEN, ADOBE_CLIENT_ID, and ADOBE_COMPANY_ID environment variables required' })) + process.exit(1) +} + +const BASE_URL = `https://analytics.adobe.io/api/${COMPANY_ID}` + +async function api(method, path, body) { + const res = await fetch(`${BASE_URL}${path}`, { + method, + headers: { + 'Authorization': `Bearer ${ACCESS_TOKEN}`, + 'x-api-key': CLIENT_ID, + 'Content-Type': 'application/json', + }, + body: body ? JSON.stringify(body) : undefined, + }) + const text = await res.text() + try { + return JSON.parse(text) + } catch { + return { status: res.status, body: text } + } +} + +function parseArgs(args) { + const result = { _: [] } + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (arg.startsWith('--')) { + const key = arg.slice(2) + const next = args[i + 1] + if (next && !next.startsWith('--')) { + result[key] = next + i++ + } else { + result[key] = true + } + } else { + result._.push(arg) + } + } + return result +} + +const args = parseArgs(process.argv.slice(2)) +const [cmd, sub, ...rest] = args._ + +async function main() { + let result + + switch (cmd) { + case 'reportsuites': + switch (sub) { + case 'list': + result = await api('GET', '/reportsuites') + break + default: + result = { error: 'Unknown reportsuites subcommand. Use: list' } + } + break + + case 'dimensions': + switch (sub) { + case 'list': { + if (!args.rsid) { result = { error: '--rsid required' }; break } + const params = new URLSearchParams() + params.set('rsid', args.rsid) + result = await api('GET', `/dimensions?${params}`) + break + } + default: + result = { error: 'Unknown dimensions subcommand. Use: list' } + } + break + + case 'metrics': + switch (sub) { + case 'list': { + if (!args.rsid) { result = { error: '--rsid required' }; break } + const params = new URLSearchParams() + params.set('rsid', args.rsid) + result = await api('GET', `/metrics?${params}`) + break + } + default: + result = { error: 'Unknown metrics subcommand. Use: list' } + } + break + + case 'reports': + switch (sub) { + case 'run': { + if (!args.rsid) { result = { error: '--rsid required' }; break } + if (!args['start-date']) { result = { error: '--start-date required' }; break } + if (!args['end-date']) { result = { error: '--end-date required' }; break } + if (!args.metrics) { result = { error: '--metrics required (comma-separated)' }; break } + const body = { + rsid: args.rsid, + globalFilters: [{ + type: 'dateRange', + dateRange: `${args['start-date']}T00:00:00/${args['end-date']}T23:59:59`, + }], + metricContainer: { + metrics: args.metrics.split(',').map(m => ({ id: m.trim() })), + }, + } + if (args.dimension) { + body.dimension = args.dimension + } + result = await api('POST', '/reports', body) + break + } + default: + result = { error: 'Unknown reports subcommand. Use: run' } + } + break + + case 'segments': + switch (sub) { + case 'list': { + const params = new URLSearchParams() + if (args.rsid) params.set('rsid', args.rsid) + result = await api('GET', `/segments?${params}`) + break + } + default: + result = { error: 'Unknown segments subcommand. Use: list' } + } + break + + default: + result = { + error: 'Unknown command', + usage: { + reportsuites: 'reportsuites list', + dimensions: 'dimensions list --rsid ', + metrics: 'metrics list --rsid ', + reports: 'reports run --rsid --start-date --end-date --metrics [--dimension ]', + segments: 'segments list [--rsid ]', + } + } + } + + console.log(JSON.stringify(result, null, 2)) +} + +main().catch(err => { + console.error(JSON.stringify({ error: err.message })) + process.exit(1) +}) diff --git a/tools/clis/ahrefs.js b/tools/clis/ahrefs.js new file mode 100755 index 0000000..c3b5aa4 --- /dev/null +++ b/tools/clis/ahrefs.js @@ -0,0 +1,184 @@ +#!/usr/bin/env node + +const API_KEY = process.env.AHREFS_API_KEY +const BASE_URL = 'https://api.ahrefs.com/v3' + +if (!API_KEY) { + console.error(JSON.stringify({ error: 'AHREFS_API_KEY environment variable required' })) + process.exit(1) +} + +async function api(method, path) { + const res = await fetch(`${BASE_URL}${path}`, { + method, + headers: { + 'Authorization': `Bearer ${API_KEY}`, + 'Content-Type': 'application/json', + }, + }) + const text = await res.text() + try { + return JSON.parse(text) + } catch { + return { status: res.status, body: text } + } +} + +function parseArgs(args) { + const result = { _: [] } + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (arg.startsWith('--')) { + const key = arg.slice(2) + const next = args[i + 1] + if (next && !next.startsWith('--')) { + result[key] = next + i++ + } else { + result[key] = true + } + } else { + result._.push(arg) + } + } + return result +} + +const args = parseArgs(process.argv.slice(2)) +const [cmd, sub, ...rest] = args._ + +async function main() { + let result + const mode = args.mode || 'domain' + + switch (cmd) { + case 'domain-rating': + switch (sub) { + case 'get': { + const params = new URLSearchParams({ target: args.target }) + result = await api('GET', `/site-explorer/domain-rating?${params}`) + break + } + default: + result = { error: 'Unknown domain-rating subcommand. Use: get' } + } + break + + case 'backlinks': + switch (sub) { + case 'list': { + const params = new URLSearchParams({ target: args.target, mode }) + if (args.limit) params.set('limit', args.limit) + result = await api('GET', `/site-explorer/backlinks?${params}`) + break + } + default: + result = { error: 'Unknown backlinks subcommand. Use: list' } + } + break + + case 'refdomains': + switch (sub) { + case 'list': { + const params = new URLSearchParams({ target: args.target, mode }) + if (args.limit) params.set('limit', args.limit) + result = await api('GET', `/site-explorer/refdomains?${params}`) + break + } + default: + result = { error: 'Unknown refdomains subcommand. Use: list' } + } + break + + case 'keywords': + switch (sub) { + case 'organic': { + const params = new URLSearchParams({ target: args.target, mode }) + if (args.country) params.set('country', args.country) + if (args.limit) params.set('limit', args.limit) + result = await api('GET', `/site-explorer/organic-keywords?${params}`) + break + } + default: + result = { error: 'Unknown keywords subcommand. Use: organic' } + } + break + + case 'top-pages': + switch (sub) { + case 'list': { + const params = new URLSearchParams({ target: args.target, mode }) + if (args.country) params.set('country', args.country) + if (args.limit) params.set('limit', args.limit) + result = await api('GET', `/site-explorer/top-pages?${params}`) + break + } + default: + result = { error: 'Unknown top-pages subcommand. Use: list' } + } + break + + case 'keyword-overview': + switch (sub) { + case 'get': { + const params = new URLSearchParams({ keywords: args.keywords }) + if (args.country) params.set('country', args.country) + result = await api('GET', `/keywords-explorer/overview?${params}`) + break + } + default: + result = { error: 'Unknown keyword-overview subcommand. Use: get' } + } + break + + case 'keyword-suggestions': + switch (sub) { + case 'get': { + const params = new URLSearchParams({ keyword: args.keyword }) + if (args.country) params.set('country', args.country) + if (args.limit) params.set('limit', args.limit) + result = await api('GET', `/keywords-explorer/matching-terms?${params}`) + break + } + default: + result = { error: 'Unknown keyword-suggestions subcommand. Use: get' } + } + break + + case 'serp': + switch (sub) { + case 'get': { + const params = new URLSearchParams({ keyword: args.keyword }) + if (args.country) params.set('country', args.country) + result = await api('GET', `/keywords-explorer/serp-overview?${params}`) + break + } + default: + result = { error: 'Unknown serp subcommand. Use: get' } + } + break + + default: + result = { + error: 'Unknown command', + usage: { + 'domain-rating': 'domain-rating get --target ', + 'backlinks': 'backlinks list --target [--mode ] [--limit ]', + 'refdomains': 'refdomains list --target [--mode ] [--limit ]', + 'keywords': 'keywords organic --target [--country ] [--limit ]', + 'top-pages': 'top-pages list --target [--country ] [--limit ]', + 'keyword-overview': 'keyword-overview get --keywords [--country ]', + 'keyword-suggestions': 'keyword-suggestions get --keyword [--country ] [--limit ]', + 'serp': 'serp get --keyword [--country ]', + 'modes': 'domain (default), subdomains, prefix, exact', + } + } + } + + console.log(JSON.stringify(result, null, 2)) +} + +main().catch(err => { + console.error(JSON.stringify({ error: err.message })) + process.exit(1) +}) diff --git a/tools/clis/amplitude.js b/tools/clis/amplitude.js new file mode 100755 index 0000000..777e11b --- /dev/null +++ b/tools/clis/amplitude.js @@ -0,0 +1,174 @@ +#!/usr/bin/env node + +const API_KEY = process.env.AMPLITUDE_API_KEY +const SECRET_KEY = process.env.AMPLITUDE_SECRET_KEY +const INGESTION_URL = 'https://api2.amplitude.com' +const QUERY_URL = 'https://amplitude.com/api/2' + +if (!API_KEY) { + console.error(JSON.stringify({ error: 'AMPLITUDE_API_KEY environment variable required' })) + process.exit(1) +} + +async function ingestApi(method, path, body) { + const res = await fetch(`${INGESTION_URL}${path}`, { + method, + headers: { 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined, + }) + const text = await res.text() + try { + return JSON.parse(text) + } catch { + return { status: res.status, body: text } + } +} + +async function queryApi(method, path, params) { + if (!SECRET_KEY) { + return { error: 'AMPLITUDE_SECRET_KEY required for query/export operations' } + } + const auth = Buffer.from(`${API_KEY}:${SECRET_KEY}`).toString('base64') + const url = params ? `${QUERY_URL}${path}?${params}` : `${QUERY_URL}${path}` + const res = await fetch(url, { + method, + headers: { + 'Authorization': `Basic ${auth}`, + 'Content-Type': 'application/json', + }, + }) + const text = await res.text() + try { + return JSON.parse(text) + } catch { + return { status: res.status, body: text } + } +} + +function parseArgs(args) { + const result = { _: [] } + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (arg.startsWith('--')) { + const key = arg.slice(2) + const next = args[i + 1] + if (next && !next.startsWith('--')) { + result[key] = next + i++ + } else { + result[key] = true + } + } else { + result._.push(arg) + } + } + return result +} + +const args = parseArgs(process.argv.slice(2)) +const [cmd, sub, ...rest] = args._ + +async function main() { + let result + + switch (cmd) { + case 'track': + switch (sub) { + case 'event': { + if (!args['user-id']) { result = { error: '--user-id required' }; break } + if (!args['event-type']) { result = { error: '--event-type required' }; break } + const event = { + user_id: args['user-id'], + event_type: args['event-type'], + } + if (args.properties) { + event.event_properties = JSON.parse(args.properties) + } + result = await ingestApi('POST', '/2/httpapi', { + api_key: API_KEY, + events: [event], + }) + break + } + case 'batch': { + if (!args.events) { result = { error: '--events required (JSON array)' }; break } + const events = JSON.parse(args.events) + result = await ingestApi('POST', '/batch', { + api_key: API_KEY, + events, + }) + break + } + default: + result = { error: 'Unknown track subcommand. Use: event, batch' } + } + break + + case 'users': + switch (sub) { + case 'activity': { + if (!args['user-id']) { result = { error: '--user-id required' }; break } + const params = new URLSearchParams() + params.set('user', args['user-id']) + result = await queryApi('GET', '/useractivity', params) + break + } + default: + result = { error: 'Unknown users subcommand. Use: activity' } + } + break + + case 'export': + switch (sub) { + case 'events': { + if (!args.start) { result = { error: '--start required (e.g. 20240101T00)' }; break } + if (!args.end) { result = { error: '--end required (e.g. 20240131T23)' }; break } + const params = new URLSearchParams() + params.set('start', args.start) + params.set('end', args.end) + result = await queryApi('GET', '/export', params) + break + } + default: + result = { error: 'Unknown export subcommand. Use: events' } + } + break + + case 'retention': + switch (sub) { + case 'get': { + if (!args.start) { result = { error: '--start required (e.g. 20240101)' }; break } + if (!args.end) { result = { error: '--end required (e.g. 20240131)' }; break } + const params = new URLSearchParams() + params.set('start', args.start) + params.set('end', args.end) + if (args.event) { + params.set('e', JSON.stringify({ event_type: args.event })) + } + result = await queryApi('GET', '/retention', params) + break + } + default: + result = { error: 'Unknown retention subcommand. Use: get' } + } + break + + default: + result = { + error: 'Unknown command', + usage: { + track: 'track [event --user-id --event-type [--properties ] | batch --events ]', + users: 'users activity --user-id ', + export: 'export events --start --end ', + retention: 'retention get --start --end [--event ]', + } + } + } + + console.log(JSON.stringify(result, null, 2)) +} + +main().catch(err => { + console.error(JSON.stringify({ error: err.message })) + process.exit(1) +}) diff --git a/tools/clis/customer-io.js b/tools/clis/customer-io.js new file mode 100755 index 0000000..1981e30 --- /dev/null +++ b/tools/clis/customer-io.js @@ -0,0 +1,186 @@ +#!/usr/bin/env node + +const APP_KEY = process.env.CUSTOMERIO_APP_KEY +const SITE_ID = process.env.CUSTOMERIO_SITE_ID +const API_KEY = process.env.CUSTOMERIO_API_KEY + +const TRACK_URL = 'https://track.customer.io/api/v1' +const APP_URL = 'https://api.customer.io/v1' + +const hasTrackAuth = SITE_ID && API_KEY +const hasAppAuth = APP_KEY + +if (!hasTrackAuth && !hasAppAuth) { + console.error(JSON.stringify({ error: 'CUSTOMERIO_APP_KEY (for App API) or CUSTOMERIO_SITE_ID + CUSTOMERIO_API_KEY (for Track API) environment variables required' })) + process.exit(1) +} + +const basicAuth = hasTrackAuth ? Buffer.from(`${SITE_ID}:${API_KEY}`).toString('base64') : null + +async function trackApi(method, path, body) { + if (!hasTrackAuth) { + return { error: 'Track API requires CUSTOMERIO_SITE_ID and CUSTOMERIO_API_KEY environment variables' } + } + const res = await fetch(`${TRACK_URL}${path}`, { + method, + headers: { + 'Authorization': `Basic ${basicAuth}`, + 'Content-Type': 'application/json', + }, + body: body ? JSON.stringify(body) : undefined, + }) + const text = await res.text() + try { + return JSON.parse(text) + } catch { + return { status: res.status, body: text } + } +} + +async function appApi(method, path, body) { + if (!hasAppAuth) { + return { error: 'App API requires CUSTOMERIO_APP_KEY environment variable' } + } + const res = await fetch(`${APP_URL}${path}`, { + method, + headers: { + 'Authorization': `Bearer ${APP_KEY}`, + 'Content-Type': 'application/json', + }, + body: body ? JSON.stringify(body) : undefined, + }) + const text = await res.text() + try { + return JSON.parse(text) + } catch { + return { status: res.status, body: text } + } +} + +function parseArgs(args) { + const result = { _: [] } + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (arg.startsWith('--')) { + const key = arg.slice(2) + const next = args[i + 1] + if (next && !next.startsWith('--')) { + result[key] = next + i++ + } else { + result[key] = true + } + } else { + result._.push(arg) + } + } + return result +} + +const args = parseArgs(process.argv.slice(2)) +const [cmd, sub, ...rest] = args._ + +async function main() { + let result + + switch (cmd) { + case 'customers': + switch (sub) { + case 'identify': { + const customerId = rest[0] || args.id + const body = {} + if (args.email) body.email = args.email + if (args['first-name']) body.first_name = args['first-name'] + if (args['last-name']) body.last_name = args['last-name'] + if (args['created-at']) body.created_at = parseInt(args['created-at']) + if (args.plan) body.plan = args.plan + if (args.data) Object.assign(body, JSON.parse(args.data)) + result = await trackApi('PUT', `/customers/${customerId}`, body) + break + } + case 'get': { + const customerId = rest[0] || args.id + result = await appApi('GET', `/customers/${customerId}/attributes`) + break + } + case 'delete': { + const customerId = rest[0] || args.id + result = await trackApi('DELETE', `/customers/${customerId}`) + break + } + case 'track-event': { + const customerId = rest[0] || args.id + const body = { name: args.name } + if (args.data) body.data = JSON.parse(args.data) + result = await trackApi('POST', `/customers/${customerId}/events`, body) + break + } + default: + result = { error: 'Unknown customers subcommand. Use: identify, get, delete, track-event' } + } + break + + case 'campaigns': + switch (sub) { + case 'list': + result = await appApi('GET', '/campaigns') + break + case 'get': + result = await appApi('GET', `/campaigns/${rest[0]}`) + break + case 'metrics': + result = await appApi('GET', `/campaigns/${rest[0]}/metrics`) + break + case 'trigger': { + const body = {} + if (args.emails) body.emails = args.emails.split(',') + if (args.ids) body.ids = args.ids.split(',') + if (args.data) body.data = JSON.parse(args.data) + result = await appApi('POST', `/campaigns/${rest[0]}/triggers`, body) + break + } + default: + result = { error: 'Unknown campaigns subcommand. Use: list, get, metrics, trigger' } + } + break + + case 'send': + switch (sub) { + case 'email': { + const body = { + transactional_message_id: args['message-id'], + to: args.to, + identifiers: {}, + } + if (args['identifier-id']) body.identifiers.id = args['identifier-id'] + if (args['identifier-email']) body.identifiers.email = args['identifier-email'] + if (args.data) body.message_data = JSON.parse(args.data) + if (args.from) body.from = args.from + if (args.subject) body.subject = args.subject + if (args.body) body.body = args.body + result = await appApi('POST', '/send/email', body) + break + } + default: + result = { error: 'Unknown send subcommand. Use: email' } + } + break + + default: + result = { + error: 'Unknown command', + usage: { + customers: 'customers [identify|get|delete|track-event] [--email ] [--first-name ] [--plan ] [--data ] [--name ]', + campaigns: 'campaigns [list|get|metrics|trigger] [campaign_id] [--emails ] [--ids ] [--data ]', + send: 'send email --message-id --to [--identifier-id ] [--identifier-email ] [--data ]', + } + } + } + + console.log(JSON.stringify(result, null, 2)) +} + +main().catch(err => { + console.error(JSON.stringify({ error: err.message })) + process.exit(1) +}) diff --git a/tools/clis/dub.js b/tools/clis/dub.js new file mode 100755 index 0000000..8a861a0 --- /dev/null +++ b/tools/clis/dub.js @@ -0,0 +1,145 @@ +#!/usr/bin/env node + +const API_KEY = process.env.DUB_API_KEY +const BASE_URL = 'https://api.dub.co' + +if (!API_KEY) { + console.error(JSON.stringify({ error: 'DUB_API_KEY environment variable required' })) + process.exit(1) +} + +async function api(method, path, body) { + const res = await fetch(`${BASE_URL}${path}`, { + method, + headers: { + 'Authorization': `Bearer ${API_KEY}`, + 'Content-Type': 'application/json', + }, + body: body ? JSON.stringify(body) : undefined, + }) + const text = await res.text() + try { + return JSON.parse(text) + } catch { + return { status: res.status, body: text } + } +} + +function parseArgs(args) { + const result = { _: [] } + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (arg.startsWith('--')) { + const key = arg.slice(2) + const next = args[i + 1] + if (next && !next.startsWith('--')) { + result[key] = next + i++ + } else { + result[key] = true + } + } else { + result._.push(arg) + } + } + return result +} + +const args = parseArgs(process.argv.slice(2)) +const [cmd, sub, ...rest] = args._ + +async function main() { + let result + + switch (cmd) { + case 'links': + switch (sub) { + case 'create': { + const body = {} + if (args.url) body.url = args.url + if (args.domain) body.domain = args.domain + if (args.key) body.key = args.key + if (args.tags) body.tags = args.tags.split(',') + result = await api('POST', '/links', body) + break + } + case 'list': { + const params = new URLSearchParams() + if (args.domain) params.set('domain', args.domain) + if (args.page) params.set('page', args.page) + result = await api('GET', `/links?${params}`) + break + } + case 'get': { + const params = new URLSearchParams() + if (args.domain) params.set('domain', args.domain) + if (args.key) params.set('key', args.key) + result = await api('GET', `/links?${params}`) + break + } + case 'update': { + const body = {} + if (args.url) body.url = args.url + if (args.tags) body.tags = args.tags.split(',') + result = await api('PATCH', `/links/${args.id}`, body) + break + } + case 'delete': + result = await api('DELETE', `/links/${args.id}`) + break + case 'bulk-create': { + const links = JSON.parse(args.links || '[]') + result = await api('POST', '/links/bulk', links) + break + } + default: + result = { error: 'Unknown links subcommand. Use: create, list, get, update, delete, bulk-create' } + } + break + + case 'analytics': + switch (sub) { + case 'get': { + const params = new URLSearchParams() + if (args.domain) params.set('domain', args.domain) + if (args.key) params.set('key', args.key) + if (args.interval) params.set('interval', args.interval) + result = await api('GET', `/analytics?${params}`) + break + } + case 'country': { + const params = new URLSearchParams() + if (args.domain) params.set('domain', args.domain) + if (args.key) params.set('key', args.key) + result = await api('GET', `/analytics/country?${params}`) + break + } + case 'device': { + const params = new URLSearchParams() + if (args.domain) params.set('domain', args.domain) + if (args.key) params.set('key', args.key) + result = await api('GET', `/analytics/device?${params}`) + break + } + default: + result = { error: 'Unknown analytics subcommand. Use: get, country, device' } + } + break + + default: + result = { + error: 'Unknown command', + usage: { + links: 'links [create|list|get|update|delete|bulk-create] [--url ] [--domain ] [--key ] [--tags ] [--id ] [--page ] [--links ]', + analytics: 'analytics [get|country|device] [--domain ] [--key ] [--interval ]', + } + } + } + + console.log(JSON.stringify(result, null, 2)) +} + +main().catch(err => { + console.error(JSON.stringify({ error: err.message })) + process.exit(1) +}) diff --git a/tools/clis/ga4.js b/tools/clis/ga4.js new file mode 100755 index 0000000..d38315f --- /dev/null +++ b/tools/clis/ga4.js @@ -0,0 +1,181 @@ +#!/usr/bin/env node + +const ACCESS_TOKEN = process.env.GA4_ACCESS_TOKEN +const DATA_API = 'https://analyticsdata.googleapis.com/v1beta' +const ADMIN_API = 'https://analyticsadmin.googleapis.com/v1beta' +const MP_URL = 'https://www.google-analytics.com/mp/collect' + +if (!ACCESS_TOKEN) { + console.error(JSON.stringify({ error: 'GA4_ACCESS_TOKEN environment variable required' })) + process.exit(1) +} + +async function api(method, baseUrl, path, body) { + const res = await fetch(`${baseUrl}${path}`, { + method, + headers: { + 'Authorization': `Bearer ${ACCESS_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: body ? JSON.stringify(body) : undefined, + }) + const text = await res.text() + try { + return JSON.parse(text) + } catch { + return { status: res.status, body: text } + } +} + +async function mpApi(measurementId, apiSecret, body) { + const params = new URLSearchParams({ measurement_id: measurementId, api_secret: apiSecret }) + const res = await fetch(`${MP_URL}?${params}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + const text = await res.text() + if (!text) return { status: res.status, success: res.ok } + try { + return JSON.parse(text) + } catch { + return { status: res.status, body: text } + } +} + +function parseArgs(args) { + const result = { _: [] } + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (arg.startsWith('--')) { + const key = arg.slice(2) + const next = args[i + 1] + if (next && !next.startsWith('--')) { + result[key] = next + i++ + } else { + result[key] = true + } + } else { + result._.push(arg) + } + } + return result +} + +const args = parseArgs(process.argv.slice(2)) +const [cmd, sub, ...rest] = args._ + +async function main() { + let result + + switch (cmd) { + case 'reports': + switch (sub) { + case 'run': { + const property = args.property + if (!property) { result = { error: '--property required' }; break } + const body = { + dateRanges: [{ + startDate: args['start-date'] || '30daysAgo', + endDate: args['end-date'] || 'today', + }], + } + if (args.dimensions) { + body.dimensions = args.dimensions.split(',').map(d => ({ name: d.trim() })) + } + if (args.metrics) { + body.metrics = args.metrics.split(',').map(m => ({ name: m.trim() })) + } + result = await api('POST', DATA_API, `/properties/${property}:runReport`, body) + break + } + default: + result = { error: 'Unknown reports subcommand. Use: run' } + } + break + + case 'realtime': + switch (sub) { + case 'run': { + const property = args.property + if (!property) { result = { error: '--property required' }; break } + const body = {} + if (args.dimensions) { + body.dimensions = args.dimensions.split(',').map(d => ({ name: d.trim() })) + } + if (args.metrics) { + body.metrics = args.metrics.split(',').map(m => ({ name: m.trim() })) + } + result = await api('POST', DATA_API, `/properties/${property}:runRealtimeReport`, body) + break + } + default: + result = { error: 'Unknown realtime subcommand. Use: run' } + } + break + + case 'conversions': + switch (sub) { + case 'list': { + const property = args.property + if (!property) { result = { error: '--property required' }; break } + result = await api('GET', ADMIN_API, `/properties/${property}/conversionEvents`) + break + } + case 'create': { + const property = args.property + if (!property) { result = { error: '--property required' }; break } + if (!args['event-name']) { result = { error: '--event-name required' }; break } + result = await api('POST', ADMIN_API, `/properties/${property}/conversionEvents`, { + eventName: args['event-name'], + }) + break + } + default: + result = { error: 'Unknown conversions subcommand. Use: list, create' } + } + break + + case 'events': + switch (sub) { + case 'send': { + if (!args['measurement-id']) { result = { error: '--measurement-id required' }; break } + if (!args['api-secret']) { result = { error: '--api-secret required' }; break } + if (!args['client-id']) { result = { error: '--client-id required' }; break } + if (!args['event-name']) { result = { error: '--event-name required' }; break } + const eventParams = args.params ? JSON.parse(args.params) : {} + const body = { + client_id: args['client-id'], + events: [{ + name: args['event-name'], + params: eventParams, + }], + } + result = await mpApi(args['measurement-id'], args['api-secret'], body) + break + } + default: + result = { error: 'Unknown events subcommand. Use: send' } + } + break + + default: + result = { + error: 'Unknown command', + usage: { + reports: 'reports run --property [--start-date ] [--end-date ] [--dimensions ] [--metrics ]', + realtime: 'realtime run --property [--dimensions ] [--metrics ]', + conversions: 'conversions [list|create] --property [--event-name ]', + events: 'events send --measurement-id --api-secret --client-id --event-name [--params ]', + } + } + } + + console.log(JSON.stringify(result, null, 2)) +} + +main().catch(err => { + console.error(JSON.stringify({ error: err.message })) + process.exit(1) +}) diff --git a/tools/clis/google-ads.js b/tools/clis/google-ads.js new file mode 100755 index 0000000..c18a9bf --- /dev/null +++ b/tools/clis/google-ads.js @@ -0,0 +1,186 @@ +#!/usr/bin/env node + +const TOKEN = process.env.GOOGLE_ADS_TOKEN +const DEV_TOKEN = process.env.GOOGLE_ADS_DEVELOPER_TOKEN +const CUSTOMER_ID = process.env.GOOGLE_ADS_CUSTOMER_ID +const BASE_URL = 'https://googleads.googleapis.com/v14' + +if (!TOKEN || !DEV_TOKEN || !CUSTOMER_ID) { + console.error(JSON.stringify({ error: 'GOOGLE_ADS_TOKEN, GOOGLE_ADS_DEVELOPER_TOKEN, and GOOGLE_ADS_CUSTOMER_ID environment variables required' })) + process.exit(1) +} + +async function api(method, path, body) { + const res = await fetch(`${BASE_URL}${path}`, { + method, + headers: { + 'Authorization': `Bearer ${TOKEN}`, + 'developer-token': DEV_TOKEN, + 'Content-Type': 'application/json', + }, + body: body ? JSON.stringify(body) : undefined, + }) + const text = await res.text() + try { + return JSON.parse(text) + } catch { + return { status: res.status, body: text } + } +} + +async function gaql(query) { + return api('POST', `/customers/${CUSTOMER_ID}/googleAds:searchStream`, { query }) +} + +function parseArgs(args) { + const result = { _: [] } + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (arg.startsWith('--')) { + const key = arg.slice(2) + const next = args[i + 1] + if (next && !next.startsWith('--')) { + result[key] = next + i++ + } else { + result[key] = true + } + } else { + result._.push(arg) + } + } + return result +} + +const args = parseArgs(process.argv.slice(2)) +const [cmd, sub, ...rest] = args._ + +function daysToDateRange(days) { + const d = parseInt(days) || 30 + if (d === 7) return 'LAST_7_DAYS' + if (d === 14) return 'LAST_14_DAYS' + if (d === 30) return 'LAST_30_DAYS' + if (d === 90) return 'LAST_90_DAYS' + return `LAST_${d}_DAYS` +} + +async function main() { + let result + + switch (cmd) { + case 'account': + switch (sub) { + case 'info': + default: + result = await gaql('SELECT customer.id, customer.descriptive_name FROM customer') + } + break + + case 'campaigns': + switch (sub) { + case 'list': + result = await gaql('SELECT campaign.id, campaign.name, campaign.status, campaign_budget.amount_micros FROM campaign ORDER BY campaign.id') + break + case 'performance': { + const dateRange = daysToDateRange(args.days) + result = await gaql(`SELECT campaign.name, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions FROM campaign WHERE segments.date DURING ${dateRange}`) + break + } + case 'pause': { + if (!args.id) { result = { error: '--id required' }; break } + result = await api('POST', `/customers/${CUSTOMER_ID}/campaigns:mutate`, { + operations: [{ + update: { + resourceName: `customers/${CUSTOMER_ID}/campaigns/${args.id}`, + status: 'PAUSED', + }, + updateMask: 'status', + }], + }) + break + } + case 'enable': { + if (!args.id) { result = { error: '--id required' }; break } + result = await api('POST', `/customers/${CUSTOMER_ID}/campaigns:mutate`, { + operations: [{ + update: { + resourceName: `customers/${CUSTOMER_ID}/campaigns/${args.id}`, + status: 'ENABLED', + }, + updateMask: 'status', + }], + }) + break + } + default: + result = { error: 'Unknown campaigns subcommand. Use: list, performance, pause, enable' } + } + break + + case 'adgroups': + switch (sub) { + case 'performance': { + const dateRange = daysToDateRange(args.days) + const limit = args.limit ? ` LIMIT ${args.limit}` : '' + result = await gaql(`SELECT ad_group.name, metrics.impressions, metrics.clicks, metrics.conversions FROM ad_group WHERE segments.date DURING ${dateRange}${limit}`) + break + } + default: + result = { error: 'Unknown adgroups subcommand. Use: performance' } + } + break + + case 'keywords': + switch (sub) { + case 'performance': { + const dateRange = daysToDateRange(args.days) + const limit = args.limit || '50' + result = await gaql(`SELECT ad_group_criterion.keyword.text, metrics.impressions, metrics.clicks, metrics.average_cpc FROM keyword_view WHERE segments.date DURING ${dateRange} ORDER BY metrics.clicks DESC LIMIT ${limit}`) + break + } + default: + result = { error: 'Unknown keywords subcommand. Use: performance' } + } + break + + case 'budgets': + switch (sub) { + case 'update': { + if (!args.id || !args.amount) { result = { error: '--id and --amount required' }; break } + const amountMicros = String(Math.round(parseFloat(args.amount) * 1000000)) + result = await api('POST', `/customers/${CUSTOMER_ID}/campaignBudgets:mutate`, { + operations: [{ + update: { + resourceName: `customers/${CUSTOMER_ID}/campaignBudgets/${args.id}`, + amountMicros, + }, + updateMask: 'amountMicros', + }], + }) + break + } + default: + result = { error: 'Unknown budgets subcommand. Use: update' } + } + break + + default: + result = { + error: 'Unknown command', + usage: { + account: 'account [info]', + campaigns: 'campaigns [list|performance|pause|enable] [--days 30] [--id ]', + adgroups: 'adgroups [performance] [--days 30] [--limit ]', + keywords: 'keywords [performance] [--days 30] [--limit 50]', + budgets: 'budgets [update] --id --amount ', + }, + } + } + + console.log(JSON.stringify(result, null, 2)) +} + +main().catch(err => { + console.error(JSON.stringify({ error: err.message })) + process.exit(1) +}) diff --git a/tools/clis/google-search-console.js b/tools/clis/google-search-console.js new file mode 100755 index 0000000..f94a29b --- /dev/null +++ b/tools/clis/google-search-console.js @@ -0,0 +1,161 @@ +#!/usr/bin/env node + +const ACCESS_TOKEN = process.env.GSC_ACCESS_TOKEN +const BASE_URL = 'https://searchconsole.googleapis.com' + +if (!ACCESS_TOKEN) { + console.error(JSON.stringify({ error: 'GSC_ACCESS_TOKEN environment variable required' })) + process.exit(1) +} + +async function api(method, path, body) { + const res = await fetch(`${BASE_URL}${path}`, { + method, + headers: { + 'Authorization': `Bearer ${ACCESS_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: body ? JSON.stringify(body) : undefined, + }) + const text = await res.text() + try { + return JSON.parse(text) + } catch { + return { status: res.status, body: text } + } +} + +function parseArgs(args) { + const result = { _: [] } + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (arg.startsWith('--')) { + const key = arg.slice(2) + const next = args[i + 1] + if (next && !next.startsWith('--')) { + result[key] = next + i++ + } else { + result[key] = true + } + } else { + result._.push(arg) + } + } + return result +} + +const args = parseArgs(process.argv.slice(2)) +const [cmd, sub, ...rest] = args._ + +function getDefaultDates() { + const end = new Date() + end.setDate(end.getDate() - 3) + const start = new Date(end) + start.setDate(start.getDate() - 28) + return { + startDate: start.toISOString().split('T')[0], + endDate: end.toISOString().split('T')[0], + } +} + +async function main() { + let result + const siteUrl = args['site-url'] + + switch (cmd) { + case 'search': { + if (!siteUrl) { + result = { error: '--site-url is required for search commands' } + break + } + const encodedSiteUrl = encodeURIComponent(siteUrl) + const defaults = getDefaultDates() + const body = { + startDate: args['start-date'] || defaults.startDate, + endDate: args['end-date'] || defaults.endDate, + rowLimit: parseInt(args.limit || '100', 10), + } + + switch (sub) { + case 'query': + body.dimensions = ['query'] + result = await api('POST', `/webmasters/v3/sites/${encodedSiteUrl}/searchAnalytics/query`, body) + break + case 'pages': + body.dimensions = ['page'] + result = await api('POST', `/webmasters/v3/sites/${encodedSiteUrl}/searchAnalytics/query`, body) + break + case 'countries': + body.dimensions = ['country', 'query'] + result = await api('POST', `/webmasters/v3/sites/${encodedSiteUrl}/searchAnalytics/query`, body) + break + default: + result = { error: 'Unknown search subcommand. Use: query, pages, countries' } + } + break + } + + case 'inspect': { + if (!siteUrl) { + result = { error: '--site-url is required for inspect commands' } + break + } + switch (sub) { + case 'url': + result = await api('POST', '/v1/urlInspection/index:inspect', { + inspectionUrl: args.url, + siteUrl: siteUrl, + }) + break + default: + result = { error: 'Unknown inspect subcommand. Use: url' } + } + break + } + + case 'sitemaps': { + if (!siteUrl) { + result = { error: '--site-url is required for sitemaps commands' } + break + } + const encodedSiteUrl = encodeURIComponent(siteUrl) + switch (sub) { + case 'list': + result = await api('GET', `/webmasters/v3/sites/${encodedSiteUrl}/sitemaps`) + break + case 'submit': { + const sitemapUrl = encodeURIComponent(args['sitemap-url']) + result = await api('PUT', `/webmasters/v3/sites/${encodedSiteUrl}/sitemaps/${sitemapUrl}`) + if (!result.body && !result.error) { + result = { success: true, message: 'Sitemap submitted successfully' } + } + break + } + default: + result = { error: 'Unknown sitemaps subcommand. Use: list, submit' } + } + break + } + + default: + result = { + error: 'Unknown command', + usage: { + 'search query': 'search query --site-url [--start-date ] [--end-date ] [--limit ]', + 'search pages': 'search pages --site-url [--start-date ] [--end-date ] [--limit ]', + 'search countries': 'search countries --site-url [--start-date ] [--end-date ] [--limit ]', + 'inspect url': 'inspect url --site-url --url ', + 'sitemaps list': 'sitemaps list --site-url ', + 'sitemaps submit': 'sitemaps submit --site-url --sitemap-url ', + } + } + } + + console.log(JSON.stringify(result, null, 2)) +} + +main().catch(err => { + console.error(JSON.stringify({ error: err.message })) + process.exit(1) +}) diff --git a/tools/clis/kit.js b/tools/clis/kit.js new file mode 100755 index 0000000..41a8973 --- /dev/null +++ b/tools/clis/kit.js @@ -0,0 +1,197 @@ +#!/usr/bin/env node + +const API_SECRET = process.env.KIT_API_SECRET +const API_KEY = process.env.KIT_API_KEY +const BASE_URL = 'https://api.convertkit.com/v3' + +if (!API_SECRET && !API_KEY) { + console.error(JSON.stringify({ error: 'KIT_API_SECRET or KIT_API_KEY environment variable required' })) + process.exit(1) +} + +async function api(method, path, body, useSecret = true) { + const url = new URL(`${BASE_URL}${path}`) + if (method === 'GET' || method === 'DELETE') { + if (useSecret && API_SECRET) { + url.searchParams.set('api_secret', API_SECRET) + } else if (API_KEY) { + url.searchParams.set('api_key', API_KEY) + } + } + const opts = { + method, + headers: { 'Content-Type': 'application/json' }, + } + if (body && (method === 'POST' || method === 'PUT')) { + const authBody = { ...body } + if (useSecret && API_SECRET) { + authBody.api_secret = API_SECRET + } else if (API_KEY) { + authBody.api_key = API_KEY + } + opts.body = JSON.stringify(authBody) + } + const res = await fetch(url.toString(), opts) + const text = await res.text() + try { + return JSON.parse(text) + } catch { + return { status: res.status, body: text } + } +} + +function parseArgs(args) { + const result = { _: [] } + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (arg.startsWith('--')) { + const key = arg.slice(2) + const next = args[i + 1] + if (next && !next.startsWith('--')) { + result[key] = next + i++ + } else { + result[key] = true + } + } else { + result._.push(arg) + } + } + return result +} + +const args = parseArgs(process.argv.slice(2)) +const [cmd, sub, ...rest] = args._ + +async function main() { + let result + + switch (cmd) { + case 'subscribers': + switch (sub) { + case 'list': { + const params = args.page ? `&page=${args.page}` : '' + result = await api('GET', `/subscribers?${params}`) + break + } + case 'get': + result = await api('GET', `/subscribers/${rest[0]}`) + break + case 'update': { + const body = {} + if (args['first-name']) body.first_name = args['first-name'] + if (args.fields) body.fields = JSON.parse(args.fields) + result = await api('PUT', `/subscribers/${rest[0]}`, body) + break + } + case 'unsubscribe': { + const body = { email: args.email } + result = await api('PUT', '/unsubscribe', body) + break + } + default: + result = { error: 'Unknown subscribers subcommand. Use: list, get, update, unsubscribe' } + } + break + + case 'forms': + switch (sub) { + case 'list': + result = await api('GET', '/forms', null, false) + break + case 'subscribe': { + const formId = rest[0] + const body = { email: args.email } + if (args['first-name']) body.first_name = args['first-name'] + if (args.fields) body.fields = JSON.parse(args.fields) + result = await api('POST', `/forms/${formId}/subscribe`, body, false) + break + } + default: + result = { error: 'Unknown forms subcommand. Use: list, subscribe' } + } + break + + case 'sequences': + switch (sub) { + case 'list': + result = await api('GET', '/sequences', null, false) + break + case 'subscribe': { + const sequenceId = rest[0] + const body = { email: args.email } + if (args['first-name']) body.first_name = args['first-name'] + if (args.fields) body.fields = JSON.parse(args.fields) + result = await api('POST', `/sequences/${sequenceId}/subscribe`, body, false) + break + } + default: + result = { error: 'Unknown sequences subcommand. Use: list, subscribe' } + } + break + + case 'tags': + switch (sub) { + case 'list': + result = await api('GET', '/tags', null, false) + break + case 'subscribe': { + const tagId = rest[0] + const body = { email: args.email } + if (args['first-name']) body.first_name = args['first-name'] + if (args.fields) body.fields = JSON.parse(args.fields) + result = await api('POST', `/tags/${tagId}/subscribe`, body, false) + break + } + case 'remove': { + const tagId = rest[0] + const subscriberId = rest[1] || args['subscriber-id'] + result = await api('DELETE', `/subscribers/${subscriberId}/tags/${tagId}`) + break + } + default: + result = { error: 'Unknown tags subcommand. Use: list, subscribe, remove' } + } + break + + case 'broadcasts': + switch (sub) { + case 'list': { + const params = args.page ? `&page=${args.page}` : '' + result = await api('GET', `/broadcasts?${params}`) + break + } + case 'create': { + const body = { + subject: args.subject, + content: args.content, + } + if (args['email-layout-template']) body.email_layout_template = args['email-layout-template'] + result = await api('POST', '/broadcasts', body) + break + } + default: + result = { error: 'Unknown broadcasts subcommand. Use: list, create' } + } + break + + default: + result = { + error: 'Unknown command', + usage: { + subscribers: 'subscribers [list|get|update|unsubscribe] [id] [--email ] [--first-name ] [--fields ] [--page ]', + forms: 'forms [list|subscribe] [form_id] [--email ] [--first-name ] [--fields ]', + sequences: 'sequences [list|subscribe] [sequence_id] [--email ] [--first-name ] [--fields ]', + tags: 'tags [list|subscribe|remove] [tag_id] [subscriber_id] [--email ] [--subscriber-id ]', + broadcasts: 'broadcasts [list|create] [--subject ] [--content ] [--email-layout-template