gsc-cli 2.0.2 → 2.2.0
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.
- checksums.yaml +4 -4
- data/AUTH.md +205 -0
- data/FUNDING.md +120 -0
- data/README.md +463 -299
- data/bin/gsc +29158 -4921
- data/dist/gsc +29158 -4921
- data/lib/gsc/aio_hunter.rb +343 -0
- data/lib/gsc/answer_synthesizer.rb +157 -0
- data/lib/gsc/api.rb +53 -1
- data/lib/gsc/auth.rb +26 -0
- data/lib/gsc/backlinks_manager.rb +96 -0
- data/lib/gsc/brand_segmenter.rb +140 -0
- data/lib/gsc/cache_manager.rb +806 -0
- data/lib/gsc/cannibalization_analyzer.rb +141 -0
- data/lib/gsc/canonical_chains.rb +367 -0
- data/lib/gsc/citation_simulator.rb +339 -0
- data/lib/gsc/cli/aio_hunter.rb +154 -0
- data/lib/gsc/cli/analytics.rb +788 -0
- data/lib/gsc/cli/audit.rb +1976 -0
- data/lib/gsc/cli/base.rb +384 -0
- data/lib/gsc/cli/cache.rb +266 -0
- data/lib/gsc/cli/canonical.rb +223 -0
- data/lib/gsc/cli/citation_simulator.rb +152 -0
- data/lib/gsc/cli/dashboard.rb +354 -0
- data/lib/gsc/cli/doctor.rb +129 -0
- data/lib/gsc/cli/eeat.rb +125 -0
- data/lib/gsc/cli/ga4.rb +852 -0
- data/lib/gsc/cli/growth.rb +650 -0
- data/lib/gsc/cli/hreflang.rb +164 -0
- data/lib/gsc/cli/image_seo.rb +162 -0
- data/lib/gsc/cli/indexing.rb +458 -0
- data/lib/gsc/cli/intent_shift.rb +125 -0
- data/lib/gsc/cli/keyword_value.rb +134 -0
- data/lib/gsc/cli/keywords.rb +795 -0
- data/lib/gsc/cli/landing_roi.rb +308 -0
- data/lib/gsc/cli/low_ctr.rb +213 -0
- data/lib/gsc/cli/mobile_parity.rb +150 -0
- data/lib/gsc/cli/report.rb +100 -0
- data/lib/gsc/cli/rich_results.rb +172 -0
- data/lib/gsc/cli/schema_generate.rb +149 -0
- data/lib/gsc/cli/seasonal.rb +232 -0
- data/lib/gsc/cli/security.rb +153 -0
- data/lib/gsc/cli/setup.rb +1291 -0
- data/lib/gsc/cli/sitemap_tree.rb +143 -0
- data/lib/gsc/cli/skill_pack.rb +62 -0
- data/lib/gsc/cli/soft_404.rb +199 -0
- data/lib/gsc/cli/sparkline.rb +227 -0
- data/lib/gsc/cli/watchdog.rb +150 -0
- data/lib/gsc/cli/zombie_purger.rb +208 -0
- data/lib/gsc/cli.rb +706 -5111
- data/lib/gsc/cli_advanced.rb +1513 -0
- data/lib/gsc/client.rb +17 -2
- data/lib/gsc/color.rb +16 -1
- data/lib/gsc/command_registry.rb +47 -9
- data/lib/gsc/config.rb +11 -2
- data/lib/gsc/content_gap.rb +112 -0
- data/lib/gsc/ctr_curve.rb +115 -0
- data/lib/gsc/decay_predictor.rb +322 -0
- data/lib/gsc/doctor.rb +434 -0
- data/lib/gsc/eeat_auditor.rb +428 -0
- data/lib/gsc/entity_auditor.rb +229 -0
- data/lib/gsc/firewall_scanner.rb +733 -0
- data/lib/gsc/geo_auditor.rb +368 -0
- data/lib/gsc/google_suggest.rb +109 -0
- data/lib/gsc/google_trends.rb +8 -1
- data/lib/gsc/heading_validator.rb +283 -0
- data/lib/gsc/hreflang_validator.rb +412 -0
- data/lib/gsc/image_seo.rb +286 -0
- data/lib/gsc/indexing_queue.rb +179 -0
- data/lib/gsc/indexnow.rb +93 -0
- data/lib/gsc/intent_shift.rb +188 -0
- data/lib/gsc/internal_links.rb +249 -0
- data/lib/gsc/keyword_value.rb +191 -0
- data/lib/gsc/landing_roi.rb +195 -0
- data/lib/gsc/llms_generator.rb +425 -0
- data/lib/gsc/low_ctr_rewriter.rb +408 -0
- data/lib/gsc/mobile_parity.rb +222 -0
- data/lib/gsc/network_tracer.rb +93 -0
- data/lib/gsc/open_page_rank.rb +72 -0
- data/lib/gsc/page_analyzer.rb +47 -7
- data/lib/gsc/page_comparator.rb +108 -0
- data/lib/gsc/page_speed.rb +110 -0
- data/lib/gsc/prompts.rb +38 -29
- data/lib/gsc/questions_harvester.rb +178 -0
- data/lib/gsc/report_generator.rb +461 -0
- data/lib/gsc/rich_results.rb +388 -0
- data/lib/gsc/robots_checker.rb +114 -0
- data/lib/gsc/schema_generator.rb +788 -0
- data/lib/gsc/schema_validator.rb +120 -0
- data/lib/gsc/seasonal_predictor.rb +381 -0
- data/lib/gsc/security_scanner.rb +496 -0
- data/lib/gsc/serp_feature_detector.rb +359 -0
- data/lib/gsc/serp_preview.rb +152 -0
- data/lib/gsc/site_crawler.rb +113 -21
- data/lib/gsc/sitemap_loader.rb +15 -4
- data/lib/gsc/sitemap_tree.rb +301 -0
- data/lib/gsc/skill_pack.rb +195 -0
- data/lib/gsc/soft_404_analyzer.rb +385 -0
- data/lib/gsc/sparkline.rb +171 -0
- data/lib/gsc/speed_correlator.rb +416 -0
- data/lib/gsc/striking_playbook.rb +190 -0
- data/lib/gsc/title_optimizer.rb +420 -0
- data/lib/gsc/vault.rb +260 -0
- data/lib/gsc/version.rb +1 -1
- data/lib/gsc/watchdog.rb +235 -0
- data/lib/gsc/zombie_purger.rb +366 -0
- data/lib/gsc.rb +144 -0
- metadata +91 -2
|
@@ -0,0 +1,1291 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'net/http'
|
|
6
|
+
require 'uri'
|
|
7
|
+
require_relative 'base'
|
|
8
|
+
require_relative '../vault' if File.exist?(File.expand_path('../vault.rb', __dir__))
|
|
9
|
+
require_relative '../prompts' if File.exist?(File.expand_path('../prompts.rb', __dir__))
|
|
10
|
+
require_relative '../command_registry' if File.exist?(File.expand_path('../command_registry.rb', __dir__))
|
|
11
|
+
|
|
12
|
+
module GSC
|
|
13
|
+
class CLI
|
|
14
|
+
module Setup
|
|
15
|
+
DEFAULT_UPDATE_URL = ENV['GSC_UPDATE_URL'] || 'https://raw.githubusercontent.com/ApollosWave/gsc-cli/main/dist/gsc'
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def run(command, target, extra, args, options, parser = nil)
|
|
20
|
+
case command
|
|
21
|
+
when 'vault'
|
|
22
|
+
handle_vault(target, extra, args[3], options)
|
|
23
|
+
when 'use', 'switch', 'sw'
|
|
24
|
+
handle_use(target, options)
|
|
25
|
+
when /^\d+$/
|
|
26
|
+
handle_use(command, options)
|
|
27
|
+
when 'domains', 'list'
|
|
28
|
+
handle_use(nil, options)
|
|
29
|
+
when 'connect', 'setup', 'init', 'install'
|
|
30
|
+
if target.to_s.downcase == 'ke' || target.to_s.downcase == 'keywordseverywhere'
|
|
31
|
+
Keywords.handle_connect_ke(extra)
|
|
32
|
+
elsif target.to_s.downcase == 'indexnow' || target.to_s.downcase == 'in'
|
|
33
|
+
Indexing.handle_indexnow('connect', extra, options)
|
|
34
|
+
else
|
|
35
|
+
handle_connect_wizard
|
|
36
|
+
end
|
|
37
|
+
when 'connect-ga4', 'setup-ga4', 'link-ga4'
|
|
38
|
+
handle_connect_ga4_wizard(options)
|
|
39
|
+
when 'connect-ke', 'setup-ke', 'link-ke', 'connect-keywordseverywhere'
|
|
40
|
+
Keywords.handle_connect_ke(target)
|
|
41
|
+
when 'open'
|
|
42
|
+
handle_open_command
|
|
43
|
+
when 'where', 'which'
|
|
44
|
+
print_where_info(options)
|
|
45
|
+
when 'version', '-v', '--version'
|
|
46
|
+
print_version_info(options)
|
|
47
|
+
when 'update', 'upgrade'
|
|
48
|
+
handle_update_command(options)
|
|
49
|
+
when 'prompts', 'prompt', 'playbooks', 'playbook'
|
|
50
|
+
handle_prompts_command(target, extra, options)
|
|
51
|
+
when 'skills', 'skill', 'init-skill'
|
|
52
|
+
subcmd = (command == 'init-skill') ? 'install' : target
|
|
53
|
+
handle_skills_command(subcmd, options)
|
|
54
|
+
when 'config'
|
|
55
|
+
handle_config_command(target, extra, args[3], options)
|
|
56
|
+
when 'commands', 'palette', 'schema'
|
|
57
|
+
handle_commands(options)
|
|
58
|
+
when 'help'
|
|
59
|
+
handle_help(parser, options)
|
|
60
|
+
else
|
|
61
|
+
raise "Unknown setup command: #{command}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def handle_vault(subcmd, target, extra, options)
|
|
66
|
+
action = subcmd.to_s.downcase
|
|
67
|
+
action = 'list' if action.empty? || action == 'vault'
|
|
68
|
+
|
|
69
|
+
case action
|
|
70
|
+
when 'status'
|
|
71
|
+
stat = GSC::Vault.status
|
|
72
|
+
if options[:json]
|
|
73
|
+
puts JSON.pretty_generate(stat)
|
|
74
|
+
else
|
|
75
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
76
|
+
puts "🔐 #{Color::BOLD}AGENCY CREDENTIAL VAULT SECURITY STATUS#{Color::RESET}"
|
|
77
|
+
puts "─" * 70
|
|
78
|
+
puts " • Vault Directory: #{stat[:vault_dir]}"
|
|
79
|
+
puts " • Encryption Algorithm: #{Color.c(stat[:encryption_algorithm], Color::GREEN, Color::BOLD)}"
|
|
80
|
+
puts " • Master Key Present: #{stat[:master_key_present] ? Color.c('Yes (Stored locally)', Color::GREEN) : Color.c('Generated on demand', Color::YELLOW)}"
|
|
81
|
+
puts " • Stored Client Keys: #{Color.c(stat[:keys_stored].to_s, Color::CYAN, Color::BOLD)}"
|
|
82
|
+
puts " • Domains Mapped: #{Color.c(stat[:domains_mapped].to_s, Color::CYAN)}"
|
|
83
|
+
puts " • POSIX 0700 Security: #{stat[:permissions_secure] ? Color.c('ENFORCED', Color::GREEN) : Color.c('PERMISSIVE', Color::YELLOW)}"
|
|
84
|
+
puts " • Active Target Domain: #{Color.c(stat[:active_domain] || 'None', Color::GREEN, Color::BOLD)}"
|
|
85
|
+
puts "─" * 70
|
|
86
|
+
puts "💡 Switch active client anytime: #{Color.c('gsc switch <domain|alias|#>', Color::CYAN)}"
|
|
87
|
+
puts ""
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
when 'add', 'import'
|
|
91
|
+
key_path = target
|
|
92
|
+
unless key_path && File.file?(File.expand_path(key_path))
|
|
93
|
+
puts Color.c("❌ Error: Valid service account JSON path required. Example: gsc vault add ./client-key.json --domain client.com", Color::RED)
|
|
94
|
+
return
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
domain = options[:domain] || extra
|
|
98
|
+
alias_name = options[:alias]
|
|
99
|
+
ga4_id = options[:property]
|
|
100
|
+
|
|
101
|
+
begin
|
|
102
|
+
entry = GSC::Vault.add_key(key_path, domain: domain, alias_name: alias_name, ga4_id: ga4_id)
|
|
103
|
+
if options[:json]
|
|
104
|
+
puts JSON.pretty_generate({ status: 'ok', entry: entry })
|
|
105
|
+
else
|
|
106
|
+
puts Color.c("✅ Successfully encrypted and added key to Agency Vault!", Color::GREEN, Color::BOLD)
|
|
107
|
+
puts " • Client Email: #{Color.c(entry['client_email'], Color::CYAN)}"
|
|
108
|
+
puts " • Domain: #{Color.c(entry['domain'] || 'Not set', Color::GREEN)}"
|
|
109
|
+
puts " • Alias: #{Color.c(entry['alias'] || 'Not set', Color::YELLOW)}"
|
|
110
|
+
puts " • Key Vault: #{entry['key_file']} (AES-256-GCM Encrypted)"
|
|
111
|
+
end
|
|
112
|
+
rescue StandardError => e
|
|
113
|
+
puts Color.c("❌ Vault Error: #{e.message}", Color::RED)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
when 'remove', 'rm', 'delete'
|
|
117
|
+
target_query = target
|
|
118
|
+
unless target_query
|
|
119
|
+
puts Color.c("❌ Error: Specify domain or alias to remove. Example: gsc vault remove client.com", Color::RED)
|
|
120
|
+
return
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
ok = GSC::Vault.remove_entry(target_query)
|
|
124
|
+
if ok
|
|
125
|
+
puts Color.c("✅ Removed #{target_query} from Agency Vault.", Color::GREEN)
|
|
126
|
+
else
|
|
127
|
+
puts Color.c("⚠️ Entry not found in Agency Vault: #{target_query}", Color::YELLOW)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
when 'list', 'ls'
|
|
131
|
+
entries = GSC::Vault.list_entries
|
|
132
|
+
if options[:json]
|
|
133
|
+
puts JSON.pretty_generate({ vault: GSC::Vault.status, entries: entries })
|
|
134
|
+
return
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
138
|
+
puts "🔐 #{Color::BOLD}AGENCY CREDENTIAL VAULT (AES-256-GCM Secure Store)#{Color::RESET}"
|
|
139
|
+
puts "─" * 75
|
|
140
|
+
|
|
141
|
+
if entries.empty?
|
|
142
|
+
puts Color.c("No client keys registered in Agency Vault yet.", Color::YELLOW)
|
|
143
|
+
puts "To add a key: #{Color.c('gsc vault add /path/to/key.json --domain client.com --alias client', Color::CYAN)}"
|
|
144
|
+
puts "─" * 75
|
|
145
|
+
return
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
puts "#{Color::BOLD} # | Domain | Alias | Client Account / Email#{Color::RESET}"
|
|
149
|
+
puts "─" * 75
|
|
150
|
+
entries.each_with_index do |e, i|
|
|
151
|
+
num_str = "[#{i + 1}]".ljust(4)
|
|
152
|
+
dom_str = (e['domain'] || 'unassigned').ljust(28)
|
|
153
|
+
alias_str = (e['alias'] || '-').ljust(10)
|
|
154
|
+
email_str = e['client_email'].to_s
|
|
155
|
+
marker = e['active'] ? " #{Color.c('👈 [ACTIVE]', Color::GREEN, Color::BOLD)}" : ""
|
|
156
|
+
|
|
157
|
+
puts "#{Color.c(num_str, Color::CYAN)}| #{dom_str} | #{Color.c(alias_str, Color::YELLOW)} | #{email_str}#{marker}"
|
|
158
|
+
end
|
|
159
|
+
puts "─" * 75
|
|
160
|
+
puts "💡 Switch active client anytime: #{Color.c('gsc switch <domain|alias|#>', Color::CYAN)}"
|
|
161
|
+
puts ""
|
|
162
|
+
else
|
|
163
|
+
puts Color.c("Unknown vault action: #{action}. Valid actions: list, add, remove, status", Color::YELLOW)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def handle_use(domain_arg, options)
|
|
168
|
+
domains = Base.fetch_available_domains
|
|
169
|
+
active = Config.default_domain
|
|
170
|
+
|
|
171
|
+
# Check Agency Vault alias or domain match
|
|
172
|
+
if domain_arg && !domain_arg.empty?
|
|
173
|
+
vault_entry = GSC::Vault.find_entry(domain_arg)
|
|
174
|
+
if vault_entry
|
|
175
|
+
switch_res = GSC::Vault.switch_to(domain_arg)
|
|
176
|
+
clean = switch_res[:domain]
|
|
177
|
+
if options[:json]
|
|
178
|
+
puts JSON.pretty_generate({ status: 'ok', activeDomain: clean, vaultEntry: vault_entry })
|
|
179
|
+
return
|
|
180
|
+
end
|
|
181
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
182
|
+
puts Color.c("✅ Switched active property to: #{clean}", Color::GREEN, Color::BOLD)
|
|
183
|
+
puts Color.c(" 🔑 Authenticated via Agency Vault [AES-256-GCM] (#{vault_entry['client_email']})", Color::CYAN)
|
|
184
|
+
puts Color.c(" Saved to #{Config::CONFIG_FILE}", Color::GRAY)
|
|
185
|
+
return
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
if domain_arg.nil? || domain_arg.empty?
|
|
190
|
+
if options[:json]
|
|
191
|
+
puts JSON.pretty_generate({ activeDomain: active, availableDomains: domains })
|
|
192
|
+
return
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
puts Base::BANNER
|
|
196
|
+
if active
|
|
197
|
+
puts "Current active domain is: #{Color.c(active, Color::GREEN, Color::BOLD)}\n\n"
|
|
198
|
+
else
|
|
199
|
+
puts Color.c("No active domain is currently set.\n\n", Color::YELLOW)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
if domains.empty?
|
|
203
|
+
puts "No domains configured or accessible yet."
|
|
204
|
+
puts "Usage: gsc use <domain>"
|
|
205
|
+
return
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
puts "#{Color::BOLD} # | Domain | GA4 Link Status#{Color::RESET}"
|
|
209
|
+
puts "------------------------------------------------------------------"
|
|
210
|
+
domains.each_with_index do |dom, idx|
|
|
211
|
+
num_str = Color.c("[#{idx + 1}]".ljust(4), Color::CYAN, Color::BOLD)
|
|
212
|
+
ga4_id = Config.ga4_property_id(dom)
|
|
213
|
+
ga4_str = ga4_id ? Color.c("[GA4: #{ga4_id}]", Color::MAGENTA) : Color.c("[GA4: Not linked]", Color::GRAY)
|
|
214
|
+
suffix = (dom == active) ? " #{Color.c('👈 [ACTIVE]', Color::GREEN, Color::BOLD)}" : ""
|
|
215
|
+
puts "#{num_str}| #{dom.ljust(28)} | #{ga4_str}#{suffix}"
|
|
216
|
+
end
|
|
217
|
+
puts "------------------------------------------------------------------"
|
|
218
|
+
|
|
219
|
+
if $stdin.tty?
|
|
220
|
+
print "\nSelect domain number (1-#{domains.size}) to switch active, or press Enter to keep current: "
|
|
221
|
+
choice = $stdin.gets&.strip
|
|
222
|
+
if choice && choice =~ /^\d+$/
|
|
223
|
+
num = choice.to_i
|
|
224
|
+
if num >= 1 && num <= domains.size
|
|
225
|
+
domain_arg = domains[num - 1]
|
|
226
|
+
else
|
|
227
|
+
puts Color.c("Invalid selection.", Color::YELLOW)
|
|
228
|
+
return
|
|
229
|
+
end
|
|
230
|
+
elsif choice && !choice.empty? && choice != 'q'
|
|
231
|
+
domain_arg = choice
|
|
232
|
+
else
|
|
233
|
+
return
|
|
234
|
+
end
|
|
235
|
+
else
|
|
236
|
+
puts "\nTo switch domains, run: #{Color.c('gsc use <number|domain>', Color::CYAN)}"
|
|
237
|
+
return
|
|
238
|
+
end
|
|
239
|
+
elsif domain_arg =~ /^\d+$/
|
|
240
|
+
num = domain_arg.to_i
|
|
241
|
+
if num >= 1 && num <= domains.size
|
|
242
|
+
domain_arg = domains[num - 1]
|
|
243
|
+
else
|
|
244
|
+
puts Color.c("❌ Error: Invalid domain number #{num}. Must be between 1 and #{domains.size}.", Color::RED)
|
|
245
|
+
exit 1
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
clean = Config.set_default_domain(domain_arg)
|
|
250
|
+
if options[:json]
|
|
251
|
+
puts JSON.pretty_generate({ status: 'ok', activeDomain: clean })
|
|
252
|
+
return
|
|
253
|
+
end
|
|
254
|
+
if options[:in_dashboard]
|
|
255
|
+
puts Color.c("✅ Switched active domain to: #{clean}", Color::GREEN, Color::BOLD)
|
|
256
|
+
return
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
puts Base::BANNER
|
|
260
|
+
puts Color.c("✅ Active default domain set to: #{clean}", Color::GREEN, Color::BOLD)
|
|
261
|
+
puts Color.c(" Saved to #{Config::CONFIG_FILE}", Color::GRAY)
|
|
262
|
+
puts "\nNow all commands (#{Color.c('gsc top-queries')}, #{Color.c('gsc audit')}, #{Color.c('gsc performance')}) will automatically target #{Color.c(clean, Color::CYAN)} without needing -d!"
|
|
263
|
+
puts
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def handle_connect_wizard
|
|
267
|
+
puts Base::BANNER
|
|
268
|
+
puts "#{Color::BOLD}🪄 GSC INTERACTIVE SETUP WIZARD#{Color::RESET}\n"
|
|
269
|
+
|
|
270
|
+
key_file = nil
|
|
271
|
+
|
|
272
|
+
existing_key = Auth.find_key(nil)
|
|
273
|
+
if existing_key && File.exist?(existing_key)
|
|
274
|
+
sa = JSON.parse(File.read(existing_key)) rescue {}
|
|
275
|
+
if sa['client_email']
|
|
276
|
+
puts "🔑 Found existing key already configured:"
|
|
277
|
+
puts " #{Color.c(existing_key, Color::CYAN)} (#{sa['client_email']})"
|
|
278
|
+
print "Keep and test this key? [Y/n]: "
|
|
279
|
+
choice = $stdin.gets&.strip
|
|
280
|
+
if choice.nil? || choice.empty? || choice.downcase == 'y'
|
|
281
|
+
key_file = existing_key
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
unless key_file
|
|
287
|
+
begin
|
|
288
|
+
downloads_dir = File.expand_path('~/Downloads')
|
|
289
|
+
if Dir.exist?(downloads_dir)
|
|
290
|
+
recent_keys = Dir.glob(File.join(downloads_dir, '*.json')).select do |f|
|
|
291
|
+
next false if (File.size(f) rescue 999_999) > 50_000
|
|
292
|
+
content = File.read(f) rescue ""
|
|
293
|
+
content.include?('"client_email"') && content.include?('"private_key"')
|
|
294
|
+
end.sort_by { |f| File.mtime(f) rescue Time.at(0) }.reverse
|
|
295
|
+
|
|
296
|
+
if recent_keys.any?
|
|
297
|
+
latest = recent_keys.first
|
|
298
|
+
sa = JSON.parse(File.read(latest)) rescue {}
|
|
299
|
+
if sa['client_email']
|
|
300
|
+
puts "🔍 Found recently downloaded service account key in Downloads:"
|
|
301
|
+
puts " #{Color.c(File.basename(latest), Color::CYAN)} (#{sa['client_email']})"
|
|
302
|
+
print "Use this key? [Y/n]: "
|
|
303
|
+
choice = $stdin.gets&.strip
|
|
304
|
+
if choice.nil? || choice.empty? || choice.downcase == 'y'
|
|
305
|
+
key_file = latest
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
rescue Errno::EPERM, StandardError
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
unless key_file
|
|
315
|
+
puts "\n👉 #{Color::BOLD}Drag and drop your downloaded Google Cloud JSON key into this terminal#{Color::RESET}"
|
|
316
|
+
print "(or paste the file path): "
|
|
317
|
+
input = $stdin.gets&.strip
|
|
318
|
+
if input.nil? || input.empty?
|
|
319
|
+
puts Color.c("Setup cancelled.", Color::YELLOW)
|
|
320
|
+
exit 0
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
cleaned_path = input.gsub(/^['"]|['"]$/, '').gsub('\\ ', ' ')
|
|
324
|
+
cleaned_path = File.expand_path(cleaned_path)
|
|
325
|
+
unless File.exist?(cleaned_path)
|
|
326
|
+
puts Color.c("❌ Error: File not found at: #{cleaned_path}", Color::RED)
|
|
327
|
+
exit 1
|
|
328
|
+
end
|
|
329
|
+
key_file = cleaned_path
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
begin
|
|
333
|
+
sa = JSON.parse(File.read(key_file))
|
|
334
|
+
unless sa['client_email'] && sa['private_key']
|
|
335
|
+
puts Color.c("❌ Error: Selected JSON file is missing client_email or private_key.", Color::RED)
|
|
336
|
+
exit 1
|
|
337
|
+
end
|
|
338
|
+
rescue StandardError => e
|
|
339
|
+
puts Color.c("❌ Error parsing JSON: #{e.message}", Color::RED)
|
|
340
|
+
exit 1
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
dest = File.join(Config::CONFIG_DIR, 'service-account.json')
|
|
344
|
+
FileUtils.mkdir_p(Config::CONFIG_DIR)
|
|
345
|
+
File.chmod(0700, Config::CONFIG_DIR) rescue nil
|
|
346
|
+
FileUtils.cp(key_file, dest) unless File.expand_path(key_file) == File.expand_path(dest)
|
|
347
|
+
File.chmod(0600, dest) rescue nil
|
|
348
|
+
Config.set_key_path(dest)
|
|
349
|
+
puts Color.c("\n✅ Key configured at: #{dest}", Color::GREEN, Color::BOLD)
|
|
350
|
+
puts " Service Account: #{Color.c(sa['client_email'], Color::CYAN)}"
|
|
351
|
+
|
|
352
|
+
print "\n🔄 Testing connection to Google OAuth2 API... "
|
|
353
|
+
token = nil
|
|
354
|
+
begin
|
|
355
|
+
token = Auth.fetch_access_token(sa)
|
|
356
|
+
puts Color.c("Connected! (Token acquired)", Color::GREEN)
|
|
357
|
+
rescue StandardError => e
|
|
358
|
+
puts Color.c("Failed! #{e.message}", Color::RED)
|
|
359
|
+
exit 1
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
print "🌐 Querying verified Search Console domain properties... "
|
|
363
|
+
client = Client.new(token: token)
|
|
364
|
+
api = API.new(client)
|
|
365
|
+
sites_res = api.list_sites
|
|
366
|
+
|
|
367
|
+
if sites_res[:ok]
|
|
368
|
+
entries = sites_res.dig(:data, 'siteEntry') || []
|
|
369
|
+
if entries.empty?
|
|
370
|
+
puts Color.c("\n⚠️ No domain properties found in Search Console yet.", Color::YELLOW)
|
|
371
|
+
puts "\n#{Color::BOLD}Next Step:#{Color::RESET}"
|
|
372
|
+
puts "1. Go to: https://search.google.com/search-console/"
|
|
373
|
+
puts "2. In Settings > Users and permissions, click 'Add user'"
|
|
374
|
+
puts "3. Paste: #{Color.c(sa['client_email'], Color::CYAN)}"
|
|
375
|
+
puts "4. Set permission to #{Color::BOLD}Owner#{Color::RESET} and click Add."
|
|
376
|
+
puts "\nThen run #{Color.c('gsc domains', Color::CYAN)} anytime to verify!"
|
|
377
|
+
else
|
|
378
|
+
puts Color.c("Found #{entries.size} properties!\n", Color::GREEN)
|
|
379
|
+
puts "#{Color::BOLD}Available Search Console Domains:#{Color::RESET}"
|
|
380
|
+
entries.each_with_index do |s, idx|
|
|
381
|
+
puts " #{idx + 1}) #{Color.c(s['siteUrl'], Color::CYAN)}"
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
print "\nChoose a default domain (1-#{entries.size}) [1]: "
|
|
385
|
+
choice = $stdin.gets&.strip
|
|
386
|
+
idx = (choice && !choice.empty?) ? (choice.to_i - 1) : 0
|
|
387
|
+
idx = 0 if idx < 0 || idx >= entries.size
|
|
388
|
+
|
|
389
|
+
selected_url = entries[idx]['siteUrl']
|
|
390
|
+
clean_dom = Config.set_default_domain(selected_url)
|
|
391
|
+
puts Color.c("✅ Default domain set to: #{clean_dom}", Color::GREEN, Color::BOLD)
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
puts "\n#{Color::BOLD}══════════════════════════════════════════════════════════════#{Color::RESET}"
|
|
396
|
+
puts "#{Color::BOLD}🎉 SETUP COMPLETE!#{Color::RESET}"
|
|
397
|
+
puts "You can now run:"
|
|
398
|
+
puts " #{Color.c('gsc top-queries', Color::CYAN)} - See your Google search rankings"
|
|
399
|
+
puts " #{Color.c('gsc performance', Color::CYAN)} - 30-day search performance summary"
|
|
400
|
+
puts " #{Color.c('gsc audit', Color::CYAN)} - Full automated SEO audit"
|
|
401
|
+
puts " #{Color.c('gsc open', Color::CYAN)} - Reveal config folder in Finder"
|
|
402
|
+
puts "#{Color::BOLD}══════════════════════════════════════════════════════════════#{Color::RESET}\n"
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def handle_connect_ga4_wizard(options = {})
|
|
406
|
+
puts Base::BANNER
|
|
407
|
+
puts "#{Color::BOLD}📊 GA4 INTERACTIVE LINKING WIZARD#{Color::RESET}\n"
|
|
408
|
+
|
|
409
|
+
key_path = Auth.find_key(options[:key])
|
|
410
|
+
unless key_path && File.exist?(key_path)
|
|
411
|
+
puts Color.c("❌ Error: No Google Service Account key found.", Color::RED)
|
|
412
|
+
puts "Please run #{Color.c('gsc connect', Color::GREEN, Color::BOLD)} first to set up your Google credentials."
|
|
413
|
+
exit 1
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
sa = JSON.parse(File.read(key_path)) rescue {}
|
|
417
|
+
sa_email = sa['client_email']
|
|
418
|
+
unless sa_email
|
|
419
|
+
puts Color.c("❌ Error: Could not read client_email from #{key_path}", Color::RED)
|
|
420
|
+
exit 1
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
puts "🔑 Using Service Account: #{Color.c(sa_email, Color::CYAN, Color::BOLD)}\n\n"
|
|
424
|
+
|
|
425
|
+
puts "#{Color::BOLD}STEP 1: Enable Google Analytics APIs#{Color::RESET}"
|
|
426
|
+
puts " Open Google Cloud Console (in your browser):"
|
|
427
|
+
puts " 👉 #{Color.c('https://console.cloud.google.com/apis/library/analyticsdata.googleapis.com', Color::CYAN)} (Data API - for metrics)"
|
|
428
|
+
puts " 👉 #{Color.c('https://console.cloud.google.com/apis/library/analyticsadmin.googleapis.com', Color::CYAN)} (Admin API - for auto-discovery)"
|
|
429
|
+
puts " Click the #{Color::BOLD}'ENABLE'#{Color::RESET} button on each page.\n\n"
|
|
430
|
+
|
|
431
|
+
puts "#{Color::BOLD}STEP 2: Grant Viewer Access in Google Analytics 4#{Color::RESET}"
|
|
432
|
+
puts " 1. Open Google Analytics: #{Color.c('https://analytics.google.com/', Color::CYAN)}"
|
|
433
|
+
puts " 2. Click #{Color::BOLD}Admin#{Color::RESET} (gear icon at bottom-left of your sidebar)"
|
|
434
|
+
puts " 3. In Property settings, click #{Color::BOLD}Property access management#{Color::RESET}"
|
|
435
|
+
puts " 4. Click the blue #{Color::BOLD}'+'#{Color::RESET} button at top-right ➔ #{Color::BOLD}Add users#{Color::RESET}"
|
|
436
|
+
puts " 5. Paste your service account email:"
|
|
437
|
+
puts " 👉 #{Color.c(sa_email, Color::CYAN, Color::BOLD)}"
|
|
438
|
+
puts " 6. Assign the #{Color::BOLD}Viewer#{Color::RESET} role, uncheck 'Notify new users', and click #{Color::BOLD}Add#{Color::RESET}.\n\n"
|
|
439
|
+
|
|
440
|
+
puts "#{Color::BOLD}STEP 3: Find Your Numeric GA4 Property ID#{Color::RESET}"
|
|
441
|
+
puts " • Option A: Look at the top-left property switcher (under your property name)"
|
|
442
|
+
puts " • Option B: In #{Color::BOLD}Admin ➔ Property details#{Color::RESET}, copy the 9-digit #{Color::BOLD}Property ID#{Color::RESET} (e.g. 123456789)\n\n"
|
|
443
|
+
|
|
444
|
+
default_dom = options[:domain] || Config.default_domain
|
|
445
|
+
prompt_text = default_dom ? "Which domain should this GA4 property link to? [#{default_dom}]: " : "Which domain should this GA4 property link to?: "
|
|
446
|
+
print prompt_text
|
|
447
|
+
input_dom = $stdin.gets&.strip
|
|
448
|
+
target_dom = (input_dom && !input_dom.empty?) ? input_dom : default_dom
|
|
449
|
+
if target_dom.nil? || target_dom.empty?
|
|
450
|
+
puts Color.c("\nDomain required for GA4 link.", Color::RED)
|
|
451
|
+
exit 1
|
|
452
|
+
end
|
|
453
|
+
target_dom = target_dom.sub(%r{^https?://}, '').sub(/^sc-domain:/, '').chomp('/')
|
|
454
|
+
|
|
455
|
+
current_id = Config.ga4_property_id(target_dom)
|
|
456
|
+
id_prompt = current_id ? " [#{current_id}]" : ""
|
|
457
|
+
print "Enter your 9-digit GA4 Property ID#{id_prompt}: "
|
|
458
|
+
input_id = $stdin.gets&.strip
|
|
459
|
+
property_id = (input_id && !input_id.empty?) ? input_id : current_id
|
|
460
|
+
|
|
461
|
+
if property_id.nil? || property_id.empty?
|
|
462
|
+
puts Color.c("\nLinking cancelled.", Color::YELLOW)
|
|
463
|
+
exit 0
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
clean_id = property_id.to_s.strip.sub(%r{^properties/}, '')
|
|
467
|
+
puts "\n🔄 Testing connection to GA4 Property #{clean_id}..."
|
|
468
|
+
|
|
469
|
+
begin
|
|
470
|
+
token = Auth.fetch_access_token(sa)
|
|
471
|
+
client = Client.new(token: token)
|
|
472
|
+
api = API.new(client)
|
|
473
|
+
|
|
474
|
+
test_res = api.query_ga4_report(clean_id, days: 7, limit: 1)
|
|
475
|
+
if test_res[:ok]
|
|
476
|
+
Config.set_ga4_property_id(clean_id, target_dom)
|
|
477
|
+
puts Color.c("✅ Connection verified! Successfully queried Google Analytics Data API.", Color::GREEN, Color::BOLD)
|
|
478
|
+
puts Color.c("🎉 Linked GA4 Property #{clean_id} to #{target_dom}!", Color::GREEN, Color::BOLD)
|
|
479
|
+
puts "\nYou can now run:"
|
|
480
|
+
puts " #{Color.c('gsc ga4', Color::CYAN)} - Landing page bounce rates, engagement rates & duration"
|
|
481
|
+
puts " #{Color.c('gsc correlation', Color::CYAN)} - Merged GSC keyword rankings + GA4 on-site retention"
|
|
482
|
+
puts " #{Color.c('gsc audit', Color::CYAN)} - Comprehensive 5-step SEO & GA4 health audit"
|
|
483
|
+
else
|
|
484
|
+
puts Color.c("\n⚠️ Live test returned error (#{test_res[:status]}): #{test_res.dig(:data, 'error', 'message')}", Color::YELLOW)
|
|
485
|
+
print "Save Property ID anyway? [y/N]: "
|
|
486
|
+
choice = $stdin.gets&.strip
|
|
487
|
+
if choice =~ /^y/i
|
|
488
|
+
Config.set_ga4_property_id(clean_id, target_dom)
|
|
489
|
+
puts Color.c("💾 Saved Property ID #{clean_id} for #{target_dom}.", Color::GREEN)
|
|
490
|
+
puts "Make sure to enable the API in Google Cloud and grant Viewer role in GA4 Admin."
|
|
491
|
+
else
|
|
492
|
+
puts Color.c("Setup aborted.", Color::YELLOW)
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
rescue StandardError => e
|
|
496
|
+
puts Color.c("\n❌ OAuth / Network Error: #{e.message}", Color::RED)
|
|
497
|
+
end
|
|
498
|
+
puts
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def handle_open_command
|
|
502
|
+
dir = Config::CONFIG_DIR
|
|
503
|
+
FileUtils.mkdir_p(dir)
|
|
504
|
+
puts "📂 Opening #{Color.c(dir, Color::CYAN)} in Finder..."
|
|
505
|
+
if RUBY_PLATFORM =~ /darwin/
|
|
506
|
+
system('open', dir)
|
|
507
|
+
elsif RUBY_PLATFORM =~ /linux/
|
|
508
|
+
system('xdg-open', dir)
|
|
509
|
+
elsif RUBY_PLATFORM =~ /mswin|mingw|cygwin/
|
|
510
|
+
system('explorer', dir)
|
|
511
|
+
else
|
|
512
|
+
puts "Directory: #{dir}"
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def print_where_info(options)
|
|
517
|
+
key_path = Auth.find_key(options[:key])
|
|
518
|
+
sa = key_path ? (JSON.parse(File.read(key_path)) rescue {}) : {}
|
|
519
|
+
|
|
520
|
+
if options[:json]
|
|
521
|
+
puts JSON.pretty_generate({
|
|
522
|
+
executable: File.expand_path($PROGRAM_NAME),
|
|
523
|
+
configDirectory: Config::CONFIG_DIR,
|
|
524
|
+
configFile: Config::CONFIG_FILE,
|
|
525
|
+
activeKey: key_path,
|
|
526
|
+
serviceAccount: sa['client_email'],
|
|
527
|
+
activeDomain: Config.default_domain
|
|
528
|
+
})
|
|
529
|
+
return
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
puts Base::BANNER
|
|
533
|
+
puts "#{Color::BOLD}📍 GSC CLI INSTALLATION & ENVIRONMENT:#{Color::RESET}"
|
|
534
|
+
puts " Executable: #{Color.c(File.expand_path($PROGRAM_NAME), Color::CYAN)}"
|
|
535
|
+
puts " Config Directory: #{Color.c(Config::CONFIG_DIR, Color::CYAN)}"
|
|
536
|
+
puts " Config File: #{Color.c(Config::CONFIG_FILE, Color::CYAN)}"
|
|
537
|
+
if key_path
|
|
538
|
+
puts " Active Key: #{Color.c(key_path, Color::GREEN)} (#{sa['client_email']})"
|
|
539
|
+
else
|
|
540
|
+
puts " Active Key: #{Color.c('None found (run: gsc connect)', Color::YELLOW)}"
|
|
541
|
+
end
|
|
542
|
+
active_dom = Config.default_domain
|
|
543
|
+
puts " Active Domain: #{active_dom ? Color.c(active_dom, Color::GREEN, Color::BOLD) : Color.c('(none - run: gsc use <domain>)', Color::GRAY)}"
|
|
544
|
+
puts
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def print_version_info(options)
|
|
548
|
+
if options[:json]
|
|
549
|
+
puts JSON.pretty_generate({
|
|
550
|
+
name: 'gsc',
|
|
551
|
+
version: VERSION,
|
|
552
|
+
ruby: RUBY_VERSION,
|
|
553
|
+
platform: RUBY_PLATFORM,
|
|
554
|
+
executable: File.expand_path($PROGRAM_NAME),
|
|
555
|
+
configFile: Config::CONFIG_FILE
|
|
556
|
+
})
|
|
557
|
+
else
|
|
558
|
+
puts "#{Color::BOLD}gsc#{Color::RESET} version #{Color.c(VERSION, Color::GREEN, Color::BOLD)} (Ruby #{RUBY_VERSION} #{RUBY_PLATFORM})"
|
|
559
|
+
puts " Executable: #{File.expand_path($PROGRAM_NAME)}"
|
|
560
|
+
puts " Config: #{Config::CONFIG_FILE}"
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def handle_update_command(options)
|
|
565
|
+
repo_url = Config.load['update_url'] || ENV['GSC_UPDATE_URL'] || DEFAULT_UPDATE_URL
|
|
566
|
+
|
|
567
|
+
uri = URI(repo_url)
|
|
568
|
+
raise "Security Error: Update URL must use HTTPS: #{repo_url}" unless uri.scheme == 'https'
|
|
569
|
+
|
|
570
|
+
puts "🔄 Checking for updates from: #{Color.c(repo_url, Color::CYAN)}..." unless options[:json]
|
|
571
|
+
|
|
572
|
+
begin
|
|
573
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
574
|
+
http.use_ssl = true
|
|
575
|
+
http.open_timeout = 6
|
|
576
|
+
http.read_timeout = 10
|
|
577
|
+
|
|
578
|
+
req = Net::HTTP::Get.new(uri.request_uri)
|
|
579
|
+
req['User-Agent'] = "gsc-cli/#{VERSION}"
|
|
580
|
+
res = http.request(req)
|
|
581
|
+
|
|
582
|
+
if res.code != '200'
|
|
583
|
+
raise "Could not fetch update from #{repo_url} (HTTP #{res.code})"
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
remote_script = res.body
|
|
587
|
+
remote_version = remote_script[/VERSION\s*=\s*['"]([^'"]+)['"]/, 1] || 'unknown'
|
|
588
|
+
|
|
589
|
+
if remote_version == VERSION
|
|
590
|
+
if options[:json]
|
|
591
|
+
puts JSON.pretty_generate({ status: 'up-to-date', currentVersion: VERSION })
|
|
592
|
+
else
|
|
593
|
+
puts Color.c("✨ You are already running the latest version of gsc (#{VERSION})!", Color::GREEN, Color::BOLD)
|
|
594
|
+
end
|
|
595
|
+
return
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
require 'tempfile'
|
|
599
|
+
temp = Tempfile.new(['gsc-update', '.rb'])
|
|
600
|
+
temp.write(remote_script)
|
|
601
|
+
temp.close
|
|
602
|
+
|
|
603
|
+
syntax_ok = system('ruby', '-c', temp.path, out: File::NULL, err: File::NULL)
|
|
604
|
+
unless syntax_ok
|
|
605
|
+
temp.unlink
|
|
606
|
+
raise "Downloaded script failed Ruby syntax verification."
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
target_bin = File.expand_path($PROGRAM_NAME)
|
|
610
|
+
dest_paths = [target_bin]
|
|
611
|
+
default_local = File.expand_path('~/.local/bin/gsc')
|
|
612
|
+
dest_paths << default_local if File.exist?(default_local) && !dest_paths.include?(default_local)
|
|
613
|
+
|
|
614
|
+
dest_paths.each do |path|
|
|
615
|
+
File.write(path, remote_script)
|
|
616
|
+
File.chmod(0755, path)
|
|
617
|
+
end
|
|
618
|
+
temp.unlink
|
|
619
|
+
|
|
620
|
+
if options[:json]
|
|
621
|
+
puts JSON.pretty_generate({ status: 'updated', from: VERSION, to: remote_version, paths: dest_paths })
|
|
622
|
+
else
|
|
623
|
+
puts Color.c("🎉 Successfully updated gsc from v#{VERSION} to v#{remote_version}!", Color::GREEN, Color::BOLD)
|
|
624
|
+
puts " Updated binary: #{dest_paths.join(', ')}"
|
|
625
|
+
end
|
|
626
|
+
rescue StandardError => e
|
|
627
|
+
if options[:json]
|
|
628
|
+
puts JSON.pretty_generate({ error: e.message })
|
|
629
|
+
else
|
|
630
|
+
puts Color.c("❌ Update failed: #{e.message}", Color::RED)
|
|
631
|
+
puts "💡 To change update URL, run: #{Color.c('gsc config set-update-url <url>', Color::CYAN)}"
|
|
632
|
+
end
|
|
633
|
+
exit 1
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
def copy_to_clipboard(text)
|
|
638
|
+
if RUBY_PLATFORM =~ /darwin/i
|
|
639
|
+
IO.popen("pbcopy", "w") { |io| io.write(text) }
|
|
640
|
+
true
|
|
641
|
+
elsif system("which xclip > /dev/null 2>&1")
|
|
642
|
+
IO.popen("xclip -selection clipboard", "w") { |io| io.write(text) }
|
|
643
|
+
true
|
|
644
|
+
elsif system("which wl-copy > /dev/null 2>&1")
|
|
645
|
+
IO.popen("wl-copy", "w") { |io| io.write(text) }
|
|
646
|
+
true
|
|
647
|
+
else
|
|
648
|
+
false
|
|
649
|
+
end
|
|
650
|
+
rescue StandardError
|
|
651
|
+
false
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
def handle_prompts_command(target, extra, options = {})
|
|
655
|
+
domain = options[:domain] || Config.default_domain || '<domain>'
|
|
656
|
+
|
|
657
|
+
if options[:json]
|
|
658
|
+
if target && (target =~ /^\d+$/ || ['master', 'mega', 'all'].include?(target.downcase))
|
|
659
|
+
item = Prompts.find(target.to_i)
|
|
660
|
+
if item
|
|
661
|
+
rendered = Prompts.render_prompt(item[:id], domain: domain, seed: options[:seed], url: options[:url])
|
|
662
|
+
puts JSON.pretty_generate(item.merge(rendered_prompt: rendered, activeDomain: domain))
|
|
663
|
+
else
|
|
664
|
+
puts JSON.pretty_generate({ error: "Playbook ##{target} not found" })
|
|
665
|
+
end
|
|
666
|
+
else
|
|
667
|
+
list = Prompts.all.map do |p|
|
|
668
|
+
p.merge(rendered_prompt: Prompts.render_prompt(p[:id], domain: domain, seed: options[:seed], url: options[:url]))
|
|
669
|
+
end
|
|
670
|
+
puts JSON.pretty_generate({
|
|
671
|
+
playbookCount: list.size,
|
|
672
|
+
activeDomain: domain,
|
|
673
|
+
playbooks: list
|
|
674
|
+
})
|
|
675
|
+
end
|
|
676
|
+
return
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
if target
|
|
680
|
+
item = if target =~ /^\d+$/
|
|
681
|
+
Prompts.find(target.to_i)
|
|
682
|
+
elsif ['master', 'mega', 'all'].include?(target.downcase)
|
|
683
|
+
Prompts.find(0)
|
|
684
|
+
else
|
|
685
|
+
Prompts.all.find do |p|
|
|
686
|
+
p[:title].downcase.include?(target.downcase) ||
|
|
687
|
+
p[:category].downcase.include?(target.downcase)
|
|
688
|
+
end
|
|
689
|
+
end
|
|
690
|
+
if item
|
|
691
|
+
show_playbook_detail(item[:id], domain, options)
|
|
692
|
+
return
|
|
693
|
+
end
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
loop do
|
|
697
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
698
|
+
puts "#{Color::BOLD}🤖 #{Prompts.all.size} AUTONOMOUS AI SEO PLAYBOOKS & PROMPTS#{Color::RESET} (Domain: #{Color.c(domain, Color::CYAN)})"
|
|
699
|
+
puts " Select any playbook to inspect, copy to clipboard, or run directly.\n"
|
|
700
|
+
|
|
701
|
+
current_cat = nil
|
|
702
|
+
Prompts.all.each do |p|
|
|
703
|
+
if p[:category] != current_cat
|
|
704
|
+
current_cat = p[:category]
|
|
705
|
+
puts "\n" + Color.c(p[:category_name], Color::BOLD, Color::MAGENTA)
|
|
706
|
+
end
|
|
707
|
+
id_str = Color.c("[#{p[:id].to_s.rjust(2)}]", Color::CYAN, Color::BOLD)
|
|
708
|
+
title_str = p[:title].ljust(44)
|
|
709
|
+
impact_str = Color.c(p[:impact], Color::GRAY)
|
|
710
|
+
puts " #{id_str} #{title_str} #{impact_str}"
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
puts "\n" + ("─" * 70)
|
|
714
|
+
print "\nEnter Playbook # (0-#{Prompts.all.size - 1}), search term, or 'q' to exit: "
|
|
715
|
+
input = $stdin.gets&.strip
|
|
716
|
+
break if input.nil? || input.empty? || ['q', 'exit', 'quit'].include?(input.downcase)
|
|
717
|
+
|
|
718
|
+
if input =~ /^\d+$/
|
|
719
|
+
choice = input.to_i
|
|
720
|
+
item = Prompts.find(choice)
|
|
721
|
+
if item
|
|
722
|
+
action = show_playbook_detail(choice, domain, options)
|
|
723
|
+
break if action == :exit
|
|
724
|
+
else
|
|
725
|
+
puts Color.c("❌ Invalid choice. Please choose 0-#{Prompts.all.size - 1}", Color::RED)
|
|
726
|
+
end
|
|
727
|
+
else
|
|
728
|
+
matches = Prompts.all.select do |p|
|
|
729
|
+
p[:title].downcase.include?(input.downcase) ||
|
|
730
|
+
p[:category].downcase.include?(input.downcase) ||
|
|
731
|
+
p[:template].downcase.include?(input.downcase)
|
|
732
|
+
end
|
|
733
|
+
if matches.empty?
|
|
734
|
+
puts Color.c("\n❌ No playbooks matched '#{input}'.", Color::YELLOW)
|
|
735
|
+
sleep 1.2
|
|
736
|
+
else
|
|
737
|
+
puts "\n" + Color.c("🔍 Search Results for '#{input}':", Color::GREEN, Color::BOLD)
|
|
738
|
+
matches.each do |p|
|
|
739
|
+
puts " #{Color.c("[#{p[:id].to_s.rjust(2)}]", Color::CYAN, Color::BOLD)} #{p[:title].ljust(44)} #{Color.c(p[:impact], Color::GRAY)}"
|
|
740
|
+
end
|
|
741
|
+
print "\nEnter Playbook # to view (or Enter to go back): "
|
|
742
|
+
sub_choice = $stdin.gets&.strip
|
|
743
|
+
if sub_choice =~ /^\d+$/
|
|
744
|
+
show_playbook_detail(sub_choice.to_i, domain, options)
|
|
745
|
+
end
|
|
746
|
+
end
|
|
747
|
+
end
|
|
748
|
+
end
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
def show_playbook_detail(id, domain, options = {})
|
|
752
|
+
item = Prompts.find(id)
|
|
753
|
+
unless item
|
|
754
|
+
puts Color.c("❌ Playbook ##{id} not found.", Color::RED)
|
|
755
|
+
return
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
rendered = Prompts.render_prompt(id, domain: domain, seed: options[:seed], url: options[:url])
|
|
759
|
+
|
|
760
|
+
puts "\n" + ("═" * 70)
|
|
761
|
+
puts "#{Color::BOLD}📋 PLAYBOOK ##{item[:id]}: #{item[:title].upcase}#{Color::RESET}"
|
|
762
|
+
puts " Category: #{item[:category_name]} · #{Color.c(item[:impact], Color::GREEN)}"
|
|
763
|
+
puts " Underlying CLI Command: #{Color.c(item[:cli_command], Color::CYAN)}"
|
|
764
|
+
puts ("─" * 70)
|
|
765
|
+
puts "\n#{Color::BOLD}🤖 READY-TO-PASTE PROMPT FOR YOUR AI AGENT / ASSISTANT:#{Color::RESET}\n\n"
|
|
766
|
+
puts Color.c(rendered, Color::YELLOW)
|
|
767
|
+
puts "\n" + ("─" * 70)
|
|
768
|
+
|
|
769
|
+
if options[:copy]
|
|
770
|
+
copied = copy_to_clipboard(rendered)
|
|
771
|
+
if copied
|
|
772
|
+
puts Color.c("📋 Copied to clipboard automatically!", Color::GREEN, Color::BOLD)
|
|
773
|
+
return
|
|
774
|
+
end
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
print "\n[c] Copy to Clipboard | [r] Run CLI Command Now | [Enter] Back: "
|
|
778
|
+
sub = $stdin.gets&.strip&.downcase
|
|
779
|
+
case sub
|
|
780
|
+
when 'c', 'copy'
|
|
781
|
+
copied = copy_to_clipboard(rendered)
|
|
782
|
+
if copied
|
|
783
|
+
puts Color.c("\n✅ Copied prompt to clipboard! Paste it into your AI agent.\n", Color::GREEN, Color::BOLD)
|
|
784
|
+
else
|
|
785
|
+
puts Color.c("\n❌ Clipboard tool not available.\n", Color::RED)
|
|
786
|
+
end
|
|
787
|
+
print "Press Enter to continue..."
|
|
788
|
+
$stdin.gets
|
|
789
|
+
when 'r', 'run'
|
|
790
|
+
cmd_to_run = item[:cli_command].sub(/ --json$/, '')
|
|
791
|
+
puts "\n🚀 Running: #{Color.c(cmd_to_run, Color::CYAN, Color::BOLD)}...\n\n"
|
|
792
|
+
system("gsc #{cmd_to_run.sub(/^gsc /, '')}")
|
|
793
|
+
print "\nPress Enter to continue..."
|
|
794
|
+
$stdin.gets
|
|
795
|
+
when 'q', 'quit', 'exit'
|
|
796
|
+
return :exit
|
|
797
|
+
end
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
def handle_skills_command(subcommand, options)
|
|
801
|
+
gemini_dir = File.expand_path('~/.gemini/config/skills/gsc')
|
|
802
|
+
gemini_path = File.join(gemini_dir, 'SKILL.md')
|
|
803
|
+
|
|
804
|
+
claude_dir = File.expand_path('~/.claude/skills/gsc')
|
|
805
|
+
claude_path = File.join(claude_dir, 'SKILL.md')
|
|
806
|
+
|
|
807
|
+
local_dir = File.expand_path('.agents/skills/gsc')
|
|
808
|
+
local_path = File.join(local_dir, 'SKILL.md')
|
|
809
|
+
|
|
810
|
+
case subcommand
|
|
811
|
+
when 'install', 'setup', 'update'
|
|
812
|
+
installed = []
|
|
813
|
+
|
|
814
|
+
if Dir.exist?(File.expand_path('~/.gemini')) || (!Dir.exist?(File.expand_path('~/.claude')) && !options[:local])
|
|
815
|
+
FileUtils.mkdir_p(gemini_dir)
|
|
816
|
+
File.write(gemini_path, CommandRegistry::SKILL_MD_CONTENT)
|
|
817
|
+
installed << { platform: 'Antigravity / Gemini', path: gemini_path }
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
if Dir.exist?(File.expand_path('~/.claude'))
|
|
821
|
+
FileUtils.mkdir_p(claude_dir)
|
|
822
|
+
File.write(claude_path, CommandRegistry::SKILL_MD_CONTENT)
|
|
823
|
+
installed << { platform: 'Claude Code', path: claude_path }
|
|
824
|
+
end
|
|
825
|
+
|
|
826
|
+
if options[:local] || Dir.exist?(File.expand_path('.agents'))
|
|
827
|
+
FileUtils.mkdir_p(local_dir)
|
|
828
|
+
File.write(local_path, CommandRegistry::SKILL_MD_CONTENT)
|
|
829
|
+
installed << { platform: 'Universal Workspace (.agents)', path: local_path }
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
if options[:json]
|
|
833
|
+
puts JSON.pretty_generate({ status: 'ok', installed: installed })
|
|
834
|
+
else
|
|
835
|
+
puts Base::BANNER
|
|
836
|
+
puts Color.c("✅ Agent Skill installed successfully across detected platforms:", Color::GREEN, Color::BOLD)
|
|
837
|
+
installed.each do |inst|
|
|
838
|
+
puts " • #{Color.c(inst[:platform], Color::BOLD)}: #{Color.c(inst[:path], Color::CYAN)}"
|
|
839
|
+
end
|
|
840
|
+
puts "\nAI agents will now automatically detect and trigger GSC commands for SEO and indexing tasks."
|
|
841
|
+
puts
|
|
842
|
+
end
|
|
843
|
+
when 'show', 'cat', 'raw'
|
|
844
|
+
puts CommandRegistry::SKILL_MD_CONTENT
|
|
845
|
+
else
|
|
846
|
+
installed = []
|
|
847
|
+
installed << { platform: 'Antigravity / Gemini', path: gemini_path } if File.exist?(gemini_path)
|
|
848
|
+
installed << { platform: 'Claude Code', path: claude_path } if File.exist?(claude_path)
|
|
849
|
+
installed << { platform: 'Universal Workspace (.agents)', path: local_path } if File.exist?(local_path)
|
|
850
|
+
|
|
851
|
+
if options[:json]
|
|
852
|
+
puts JSON.pretty_generate({
|
|
853
|
+
skill: 'gsc',
|
|
854
|
+
installed: !installed.empty?,
|
|
855
|
+
locations: installed,
|
|
856
|
+
description: 'Automates Google Search Console, Google Indexing API, live URL index inspection, sitemap batch submission, and search ranking analytics.'
|
|
857
|
+
})
|
|
858
|
+
return
|
|
859
|
+
end
|
|
860
|
+
|
|
861
|
+
puts Base::BANNER
|
|
862
|
+
puts "#{Color::BOLD}🤖 GSC AI AGENT SKILL (Platform-Agnostic):#{Color::RESET}"
|
|
863
|
+
if installed.empty?
|
|
864
|
+
puts " Status: #{Color.c('⚠️ Not currently installed', Color::YELLOW, Color::BOLD)}"
|
|
865
|
+
puts " Install: Run #{Color.c('gsc skills install', Color::CYAN)} to auto-detect and install for your agent."
|
|
866
|
+
else
|
|
867
|
+
puts " Status: #{Color.c('✅ Installed and active', Color::GREEN, Color::BOLD)}"
|
|
868
|
+
installed.each do |inst|
|
|
869
|
+
puts " • #{inst[:platform]}: #{Color.c(inst[:path], Color::CYAN)}"
|
|
870
|
+
end
|
|
871
|
+
end
|
|
872
|
+
puts
|
|
873
|
+
puts "#{Color::BOLD}Agent Features & Capabilities:#{Color::RESET}"
|
|
874
|
+
puts " • #{Color.c('gsc top-queries --json', Color::CYAN)} - Query real SERP rankings, impressions, and CTR"
|
|
875
|
+
puts " • #{Color.c('gsc inspect <url> --json', Color::CYAN)} - Live index status & Googlebot crawl verdict"
|
|
876
|
+
puts " • #{Color.c('gsc index <url> --json', Color::CYAN)} - Instant Googlebot re-crawl notification"
|
|
877
|
+
puts " • #{Color.c('gsc audit --json', Color::CYAN)} - Automated 4-step SEO & indexing health audit"
|
|
878
|
+
puts " • #{Color.c('gsc domains --json', Color::CYAN)} - List verified properties & active domain"
|
|
879
|
+
puts
|
|
880
|
+
puts "#{Color::BOLD}Available subcommands:#{Color::RESET}"
|
|
881
|
+
puts " #{Color.c('gsc skills', Color::CYAN)} Display skill status across agent platforms"
|
|
882
|
+
puts " #{Color.c('gsc skills install', Color::CYAN)} Auto-install to ~/.gemini, ~/.claude, and .agents"
|
|
883
|
+
puts " #{Color.c('gsc skills show', Color::CYAN)} Print raw SKILL.md markdown for any agent"
|
|
884
|
+
puts
|
|
885
|
+
end
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
def handle_config_command(subcommand, subvalue, subextra_or_options = nil, maybe_options = {})
|
|
889
|
+
if subextra_or_options.is_a?(Hash)
|
|
890
|
+
options = subextra_or_options
|
|
891
|
+
subextra = nil
|
|
892
|
+
else
|
|
893
|
+
subextra = subextra_or_options
|
|
894
|
+
options = maybe_options || {}
|
|
895
|
+
end
|
|
896
|
+
|
|
897
|
+
case subcommand
|
|
898
|
+
when 'set'
|
|
899
|
+
key = subvalue.to_s.strip
|
|
900
|
+
val = subextra.to_s.strip
|
|
901
|
+
if key.empty? || val.empty?
|
|
902
|
+
if options[:json]
|
|
903
|
+
puts JSON.pretty_generate({ error: 'Usage: gsc config set <key> <value>' })
|
|
904
|
+
else
|
|
905
|
+
puts Color.c("❌ Error: Please specify key and value.", Color::RED)
|
|
906
|
+
puts "Example: gsc config set opr_api_key opr_live_xxxx"
|
|
907
|
+
puts "Example: gsc config set pagespeed_api_key AIzaSyxxxx"
|
|
908
|
+
end
|
|
909
|
+
exit 1
|
|
910
|
+
end
|
|
911
|
+
|
|
912
|
+
Config.set(key, val)
|
|
913
|
+
if key == 'opr_api_key'
|
|
914
|
+
Config.set('openpagerank_api_key', val)
|
|
915
|
+
elsif key == 'openpagerank_api_key'
|
|
916
|
+
Config.set('opr_api_key', val)
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
masked = val.length > 8 ? "#{val[0..7]}...#{val[-4..-1]}" : "***"
|
|
920
|
+
if options[:json]
|
|
921
|
+
puts JSON.pretty_generate({ status: 'ok', key: key, value: masked, configFile: Config::CONFIG_FILE })
|
|
922
|
+
else
|
|
923
|
+
puts Base::BANNER
|
|
924
|
+
puts Color.c("✅ Saved configuration: #{Color.c(key, Color::CYAN)} = #{Color.c(masked, Color::GREEN)}", Color::GREEN, Color::BOLD)
|
|
925
|
+
puts Color.c(" 📁 Stored in #{Config::CONFIG_FILE}", Color::GRAY)
|
|
926
|
+
end
|
|
927
|
+
return
|
|
928
|
+
|
|
929
|
+
when 'get'
|
|
930
|
+
key = subvalue.to_s.strip
|
|
931
|
+
val = Config.get(key)
|
|
932
|
+
if options[:json]
|
|
933
|
+
puts JSON.pretty_generate({ key: key, value: val })
|
|
934
|
+
else
|
|
935
|
+
puts val || "(not set)"
|
|
936
|
+
end
|
|
937
|
+
return
|
|
938
|
+
|
|
939
|
+
when 'set-domain', 'domain'
|
|
940
|
+
if subvalue.nil? || subvalue.empty?
|
|
941
|
+
if options[:json]
|
|
942
|
+
puts JSON.pretty_generate({ error: 'Please specify a domain' })
|
|
943
|
+
else
|
|
944
|
+
puts Color.c("❌ Error: Please specify a domain.", Color::RED)
|
|
945
|
+
end
|
|
946
|
+
exit 1
|
|
947
|
+
end
|
|
948
|
+
clean = Config.set_default_domain(subvalue)
|
|
949
|
+
if options[:json]
|
|
950
|
+
puts JSON.pretty_generate({ status: 'ok', defaultDomain: clean })
|
|
951
|
+
else
|
|
952
|
+
puts Base::BANNER
|
|
953
|
+
puts Color.c("✅ Default domain updated to: #{clean}", Color::GREEN, Color::BOLD)
|
|
954
|
+
end
|
|
955
|
+
when 'set-key', 'key'
|
|
956
|
+
if subvalue.nil? || !File.exist?(subvalue)
|
|
957
|
+
if options[:json]
|
|
958
|
+
puts JSON.pretty_generate({ error: "Key file does not exist: #{subvalue}" })
|
|
959
|
+
else
|
|
960
|
+
puts Color.c("❌ Error: Key file does not exist: #{subvalue}", Color::RED)
|
|
961
|
+
end
|
|
962
|
+
exit 1
|
|
963
|
+
end
|
|
964
|
+
dest = File.join(Config::CONFIG_DIR, 'service-account.json')
|
|
965
|
+
FileUtils.mkdir_p(Config::CONFIG_DIR)
|
|
966
|
+
File.chmod(0700, Config::CONFIG_DIR) rescue nil
|
|
967
|
+
FileUtils.cp(subvalue, dest)
|
|
968
|
+
File.chmod(0600, dest) rescue nil
|
|
969
|
+
Config.set_key_path(dest)
|
|
970
|
+
if options[:json]
|
|
971
|
+
puts JSON.pretty_generate({ status: 'ok', keyPath: dest })
|
|
972
|
+
else
|
|
973
|
+
puts Base::BANNER
|
|
974
|
+
puts Color.c("✅ Service account key copied to: #{dest}", Color::GREEN, Color::BOLD)
|
|
975
|
+
end
|
|
976
|
+
when 'set-update-url', 'update-url'
|
|
977
|
+
if subvalue.nil? || subvalue.empty?
|
|
978
|
+
if options[:json]
|
|
979
|
+
puts JSON.pretty_generate({ error: 'Please specify an update URL' })
|
|
980
|
+
else
|
|
981
|
+
puts Color.c("❌ Error: Please specify an update URL.", Color::RED)
|
|
982
|
+
end
|
|
983
|
+
exit 1
|
|
984
|
+
end
|
|
985
|
+
Config.save('update_url' => subvalue)
|
|
986
|
+
if options[:json]
|
|
987
|
+
puts JSON.pretty_generate({ status: 'ok', updateUrl: subvalue })
|
|
988
|
+
else
|
|
989
|
+
puts Base::BANNER
|
|
990
|
+
puts Color.c("✅ Update source URL saved to: #{subvalue}", Color::GREEN, Color::BOLD)
|
|
991
|
+
end
|
|
992
|
+
when 'set-ga4', 'ga4'
|
|
993
|
+
if subvalue.nil? || subvalue.empty?
|
|
994
|
+
if options[:json]
|
|
995
|
+
puts JSON.pretty_generate({ error: 'Please specify a numeric GA4 Property ID' })
|
|
996
|
+
else
|
|
997
|
+
puts Color.c("❌ Error: Please specify a numeric GA4 Property ID.", Color::RED)
|
|
998
|
+
puts "Example: gsc config set-ga4 123456789 [-d example.com]"
|
|
999
|
+
end
|
|
1000
|
+
exit 1
|
|
1001
|
+
end
|
|
1002
|
+
target_dom = options[:domain] || Config.default_domain
|
|
1003
|
+
saved_id = Config.set_ga4_property_id(subvalue, target_dom)
|
|
1004
|
+
if options[:json]
|
|
1005
|
+
puts JSON.pretty_generate({ status: 'ok', domain: target_dom, ga4PropertyId: saved_id })
|
|
1006
|
+
else
|
|
1007
|
+
puts Base::BANNER
|
|
1008
|
+
dom_str = target_dom ? " for domain #{Color.c(target_dom, Color::CYAN)}" : ""
|
|
1009
|
+
puts Color.c("✅ GA4 Property #{Color.c(saved_id, Color::MAGENTA, Color::BOLD)} linked#{dom_str}!", Color::GREEN, Color::BOLD)
|
|
1010
|
+
end
|
|
1011
|
+
when 'unlink-ga4'
|
|
1012
|
+
target_dom = options[:domain] || Config.default_domain
|
|
1013
|
+
Config.unlink_ga4_property(target_dom)
|
|
1014
|
+
if options[:json]
|
|
1015
|
+
puts JSON.pretty_generate({ status: 'ok', unlinkedDomain: target_dom })
|
|
1016
|
+
else
|
|
1017
|
+
puts Base::BANNER
|
|
1018
|
+
puts Color.c("✅ Unlinked GA4 property for domain #{Color.c(target_dom, Color::CYAN)}.", Color::GREEN)
|
|
1019
|
+
end
|
|
1020
|
+
else
|
|
1021
|
+
cfg = Config.load
|
|
1022
|
+
key_path = Auth.find_key(options[:key])
|
|
1023
|
+
sa = key_path ? (JSON.parse(File.read(key_path)) rescue {}) : {}
|
|
1024
|
+
|
|
1025
|
+
if options[:json]
|
|
1026
|
+
puts JSON.pretty_generate(cfg.merge({
|
|
1027
|
+
configFile: Config::CONFIG_FILE,
|
|
1028
|
+
keyLocation: key_path,
|
|
1029
|
+
serviceAccount: sa['client_email']
|
|
1030
|
+
}))
|
|
1031
|
+
return
|
|
1032
|
+
end
|
|
1033
|
+
|
|
1034
|
+
puts Base::BANNER
|
|
1035
|
+
puts "#{Color::BOLD}⚙️ GSC CLI CONFIGURATION:#{Color::RESET}"
|
|
1036
|
+
puts " Config File: #{Color.c(Config::CONFIG_FILE, Color::CYAN)}"
|
|
1037
|
+
puts " Default Domain: #{cfg['default_domain'] ? Color.c(cfg['default_domain'], Color::GREEN, Color::BOLD) : Color.c('(not set - run: gsc use <domain>)', Color::GRAY)}"
|
|
1038
|
+
if key_path
|
|
1039
|
+
puts " Key Location: #{Color.c(key_path, Color::CYAN)}"
|
|
1040
|
+
puts " Service Account: #{Color.c(sa['client_email'], Color::GREEN)}"
|
|
1041
|
+
else
|
|
1042
|
+
puts " Key Location: #{Color.c('Not found', Color::RED)}"
|
|
1043
|
+
end
|
|
1044
|
+
opr = cfg['opr_api_key'] || cfg['openpagerank_api_key']
|
|
1045
|
+
if opr
|
|
1046
|
+
m_opr = opr.length > 8 ? "#{opr[0..7]}...#{opr[-4..-1]}" : "***"
|
|
1047
|
+
puts " OpenPageRank: #{Color.c(m_opr, Color::GREEN)} (Active)"
|
|
1048
|
+
else
|
|
1049
|
+
puts " OpenPageRank: #{Color.c('(not set - run: gsc config set opr_api_key <key>)', Color::GRAY)}"
|
|
1050
|
+
end
|
|
1051
|
+
|
|
1052
|
+
ps = cfg['pagespeed_api_key']
|
|
1053
|
+
if ps
|
|
1054
|
+
m_ps = ps.length > 8 ? "#{ps[0..7]}...#{ps[-4..-1]}" : "***"
|
|
1055
|
+
puts " PageSpeed API: #{Color.c(m_ps, Color::GREEN)} (Active)"
|
|
1056
|
+
end
|
|
1057
|
+
|
|
1058
|
+
ke = cfg['keywords_everywhere_api_key']
|
|
1059
|
+
if ke
|
|
1060
|
+
m_ke = ke.length > 8 ? "#{ke[0..7]}...#{ke[-4..-1]}" : "***"
|
|
1061
|
+
puts " Keywords Evr: #{Color.c(m_ke, Color::GREEN)} (Active)"
|
|
1062
|
+
end
|
|
1063
|
+
|
|
1064
|
+
puts "\n #{Color::BOLD}Linked GA4 Properties per Domain:#{Color::RESET}"
|
|
1065
|
+
ga4_props = cfg['ga4_properties'] || {}
|
|
1066
|
+
if ga4_props.empty?
|
|
1067
|
+
puts " #{Color.c('(None linked yet. Use: gsc config set-ga4 <id> -d <domain>)', Color::GRAY)}"
|
|
1068
|
+
else
|
|
1069
|
+
ga4_props.each do |dom, pid|
|
|
1070
|
+
active_star = (dom == cfg['default_domain']) ? " #{Color.c('👈 [ACTIVE]', Color::GREEN)}" : ""
|
|
1071
|
+
puts " • #{Color.c(dom, Color::CYAN)} ➔ #{Color.c(pid, Color::MAGENTA, Color::BOLD)}#{active_star}"
|
|
1072
|
+
end
|
|
1073
|
+
end
|
|
1074
|
+
puts
|
|
1075
|
+
puts "#{Color::BOLD}Configuration shortcuts:#{Color::RESET}"
|
|
1076
|
+
puts " gsc connect Interactive setup wizard (drag & drop key)"
|
|
1077
|
+
puts " gsc use <domain> Set active default domain"
|
|
1078
|
+
puts " gsc config set <key> <val> Set API key (e.g. opr_api_key, pagespeed_api_key)"
|
|
1079
|
+
puts " gsc config get <key> Read a stored configuration value"
|
|
1080
|
+
end
|
|
1081
|
+
end
|
|
1082
|
+
|
|
1083
|
+
def handle_commands(options)
|
|
1084
|
+
if options[:json]
|
|
1085
|
+
puts JSON.pretty_generate({
|
|
1086
|
+
cli: 'gsc',
|
|
1087
|
+
activeDomain: Config.default_domain,
|
|
1088
|
+
commandCount: CommandRegistry::COMMAND_REGISTRY.sum { |c| c[:commands].size },
|
|
1089
|
+
categories: CommandRegistry::COMMAND_REGISTRY
|
|
1090
|
+
})
|
|
1091
|
+
else
|
|
1092
|
+
print_commands_help
|
|
1093
|
+
end
|
|
1094
|
+
end
|
|
1095
|
+
|
|
1096
|
+
def handle_help(parser, options = {})
|
|
1097
|
+
puts Base::BANNER
|
|
1098
|
+
puts parser if parser
|
|
1099
|
+
print_commands_help
|
|
1100
|
+
end
|
|
1101
|
+
|
|
1102
|
+
def print_setup_instructions
|
|
1103
|
+
puts Color.c("\n❌ Error: Google Service Account Key JSON not found!\n", Color::RED, Color::BOLD)
|
|
1104
|
+
puts <<~SETUP
|
|
1105
|
+
#{Color::BOLD}QUICK SETUP GUIDE (2 Minutes):#{Color::RESET}
|
|
1106
|
+
|
|
1107
|
+
#{Color::CYAN}1. Google Cloud Console:#{Color::RESET}
|
|
1108
|
+
• Go to: https://console.cloud.google.com/
|
|
1109
|
+
• In 'APIs & Services' > 'Library', enable:
|
|
1110
|
+
- #{Color::BOLD}Web Search Indexing API#{Color::RESET}
|
|
1111
|
+
- #{Color::BOLD}Google Search Console API#{Color::RESET}
|
|
1112
|
+
- #{Color::BOLD}Google Analytics Data API#{Color::RESET} (for on-site metrics & retention)
|
|
1113
|
+
- #{Color::BOLD}Google Analytics Admin API#{Color::RESET} (for auto-discovering GA4 properties)
|
|
1114
|
+
• In 'IAM & Admin' > 'Service Accounts', create a service account.
|
|
1115
|
+
• Click the service account > 'Keys' > 'Add Key' > 'Create new key' > 'JSON'.
|
|
1116
|
+
• Download the key.
|
|
1117
|
+
|
|
1118
|
+
#{Color::CYAN}2. Run 1-Click Connect:#{Color::RESET}
|
|
1119
|
+
• Run: #{Color.c('gsc connect', Color::GREEN, Color::BOLD)}
|
|
1120
|
+
• It will automatically find the key in Downloads or let you drag & drop it!
|
|
1121
|
+
|
|
1122
|
+
#{Color::CYAN}3. Google Search Console Permissions:#{Color::RESET}
|
|
1123
|
+
• Copy your Service Account email (shown during gsc connect).
|
|
1124
|
+
• Go to: https://search.google.com/search-console/
|
|
1125
|
+
• Select your property > 'Settings' > 'Users and permissions'.
|
|
1126
|
+
• Click #{Color::BOLD}'Add User'#{Color::RESET} with permission #{Color::BOLD}Owner#{Color::RESET}.
|
|
1127
|
+
|
|
1128
|
+
Then re-run: #{Color::BOLD}gsc domains#{Color::RESET} to verify!
|
|
1129
|
+
SETUP
|
|
1130
|
+
end
|
|
1131
|
+
|
|
1132
|
+
def print_commands_help
|
|
1133
|
+
puts <<~COMMANDS
|
|
1134
|
+
|
|
1135
|
+
#{Color::BOLD}SETUP, CONFIGURATION & DOMAIN SWITCHING:#{Color::RESET}
|
|
1136
|
+
#{Color.c('connect', Color::GREEN)} 1-Click Setup Wizard: auto-detects key or drag & drop
|
|
1137
|
+
#{Color.c('open', Color::GREEN)} Reveal config folder (~/.config/gsc) in Finder
|
|
1138
|
+
#{Color.c('use <domain>', Color::GREEN)} Set default active domain (saved in ~/.config/gsc/config.json)
|
|
1139
|
+
#{Color.c('where', Color::GREEN)} Show CLI location, active credentials, and config path
|
|
1140
|
+
#{Color.c('config', Color::GREEN)} View or update current CLI configuration
|
|
1141
|
+
#{Color.c('domains', Color::GREEN)} List all verified Search Console properties (highlights active)
|
|
1142
|
+
#{Color.c('version', Color::GREEN)} Show CLI version and runtime environment
|
|
1143
|
+
#{Color.c('update', Color::GREEN)} Fetch latest version from GitHub and self-update
|
|
1144
|
+
|
|
1145
|
+
#{Color::BOLD}GOOGLE TRENDS & KEYWORD INTELLIGENCE:#{Color::RESET}
|
|
1146
|
+
#{Color.c('trends <keyword>', Color::CYAN).ljust(38)} Real-time Google Trends search demand, velocity & breakouts
|
|
1147
|
+
#{Color.c('planner <seed>', Color::CYAN).ljust(38)} Autocomplete expansion, search intent & opportunity scoring
|
|
1148
|
+
#{Color.c('planner-import <file>', Color::CYAN).ljust(38)} Import Google Ads Keyword Planner CSV/TSV export & score
|
|
1149
|
+
#{Color.c('ke <keyword|file>', Color::YELLOW).ljust(38)} Keywords Everywhere exact monthly volume, CPC & competition
|
|
1150
|
+
#{Color.c('ke-credits', Color::YELLOW).ljust(38)} Check remaining Keywords Everywhere account balance
|
|
1151
|
+
#{Color.c('connect ke [key]', Color::GREEN).ljust(38)} Connect Keywords Everywhere API key (~/.config/gsc/config.json)
|
|
1152
|
+
|
|
1153
|
+
#{Color::BOLD}SEARCH ANALYTICS & MONITORING:#{Color::RESET}
|
|
1154
|
+
#{Color.c('performance', Color::YELLOW)} Executive dashboard: Totals, Devices, Countries, Snippets, Queries, Pages, Cities
|
|
1155
|
+
#{Color.c('top-queries', Color::YELLOW)} Top search queries, impressions, CTR, and average rankings
|
|
1156
|
+
#{Color.c('top-pages', Color::YELLOW)} Top indexed pages driving clicks & impressions
|
|
1157
|
+
#{Color.c('devices', Color::YELLOW)} Device breakdown (Desktop, Mobile, Tablet) with clicks share
|
|
1158
|
+
#{Color.c('countries', Color::YELLOW)} Geographic search demand by country (with flags & CTR)
|
|
1159
|
+
#{Color.c('cities', Color::YELLOW)} Top visitor cities, volume, bounce rates & retention (via GA4)
|
|
1160
|
+
#{Color.c('snippets', Color::YELLOW)} Search appearance & rich snippets (Reviews, Products, FAQs)
|
|
1161
|
+
#{Color.c('audit', Color::CYAN)} Automated 4-step SEO & Indexing Health Check
|
|
1162
|
+
|
|
1163
|
+
#{Color::BOLD}ENTERPRISE GROWTH & AUDIT INTELLIGENCE:#{Color::RESET}
|
|
1164
|
+
#{Color.c('opportunities', Color::YELLOW)} Striking-distance queries (Pos 7–20) to push to Page 1 / Top 3
|
|
1165
|
+
#{Color.c('underperformers', Color::YELLOW)} Top 10 queries with low CTR (easy title & meta hook wins)
|
|
1166
|
+
#{Color.c('cannibalization', Color::RED)} Detect multiple URLs competing for the same query
|
|
1167
|
+
#{Color.c('decay [--compare 28]', Color::RED)} Period-over-period trend analysis (Decaying vs Surging)
|
|
1168
|
+
#{Color.c('zombies [sitemap]', Color::RED)} Detect zero-impression pages in sitemap wasting crawl budget
|
|
1169
|
+
|
|
1170
|
+
#{Color::BOLD}GOOGLE ANALYTICS 4 (GA4) & POST-CLICK BEHAVIOR:#{Color::RESET}
|
|
1171
|
+
#{Color.c('connect-ga4', Color::MAGENTA)} Interactive GA4 linking wizard (walks through setup & tests live)
|
|
1172
|
+
#{Color.c('config set-ga4 <id>', Color::MAGENTA)} Link a GA4 Property ID to active domain (stored in config)
|
|
1173
|
+
#{Color.c('config unlink-ga4', Color::MAGENTA)} Unlink GA4 Property ID from active domain
|
|
1174
|
+
#{Color.c('realtime [--watch]', Color::MAGENTA)} Live active visitors, pages, and countries right now
|
|
1175
|
+
#{Color.c('ga4 [--organic]', Color::MAGENTA)} Landing page bounce rates, engagement rates, sessions, duration
|
|
1176
|
+
#{Color.c('correlation [--organic]', Color::MAGENTA)} Merge GSC keyword rankings with GA4 bounce rates per landing page
|
|
1177
|
+
#{Color.c('ads [--days 30]', Color::MAGENTA)} Google Ads campaign performance (Clicks, Cost, CPC, CPA, Conv)
|
|
1178
|
+
#{Color.c('channels [--days 30]', Color::MAGENTA)} Omnichannel acquisition breakdown (Organic, Paid, Direct, Referral)
|
|
1179
|
+
#{Color.c('ga4-properties', Color::MAGENTA)} Discover all GA4 Property IDs accessible by your service account
|
|
1180
|
+
|
|
1181
|
+
#{Color::BOLD}INDEXING & SITEMAP COMMANDS:#{Color::RESET}
|
|
1182
|
+
#{Color.c('index <url>', Color::GREEN)} Notify Googlebot to crawl/index a URL (URL_UPDATED)
|
|
1183
|
+
#{Color.c('remove <url>', Color::GREEN)} Notify Googlebot a URL has been deleted (URL_DELETED)
|
|
1184
|
+
#{Color.c('status <url>', Color::GREEN)} Check Google Indexing API metadata for a URL
|
|
1185
|
+
#{Color.c('inspect <url>', Color::CYAN)} Live Search Console URL inspection (coverage, canonical, crawl date)
|
|
1186
|
+
#{Color.c('inspect-sitemap [path|url]', Color::CYAN)} Bulk inspect all sitemap URLs & generate health report
|
|
1187
|
+
#{Color.c('index-sitemap [path|url]', Color::GREEN)} Submit all URLs in an XML sitemap for batch indexing
|
|
1188
|
+
#{Color.c('sitemaps-list', Color::MAGENTA)} List submitted sitemaps in Search Console & status
|
|
1189
|
+
#{Color.c('sitemaps-submit <url>', Color::MAGENTA)} Submit / re-submit an XML sitemap to Search Console
|
|
1190
|
+
|
|
1191
|
+
#{Color::BOLD}OFFLINE CACHE (TURN 20):#{Color::RESET}
|
|
1192
|
+
#{Color.c('cache status', Color::CYAN)} Inspect offline cache database, engine & snapshot size
|
|
1193
|
+
#{Color.c('cache warm [domain]', Color::CYAN)} Fetch live GSC search data and persist locally
|
|
1194
|
+
#{Color.c('cache query [filter]', Color::CYAN)} Instant (<10ms) offline query search and analysis
|
|
1195
|
+
#{Color.c('cache pages [filter]', Color::CYAN)} Instant (<10ms) offline page performance review
|
|
1196
|
+
#{Color.c('cache diff <snap1> <snap2>', Color::CYAN)} Offline delta analysis between historical snapshots
|
|
1197
|
+
#{Color.c('cache clear [domain]', Color::CYAN)} Purge offline snapshots for domain or entirely
|
|
1198
|
+
|
|
1199
|
+
#{Color::BOLD}AUTONOMOUS AI, GEO & AUDITING SUITE (v2.2):#{Color::RESET}
|
|
1200
|
+
#{Color.c('doctor', Color::GREEN)} Zero-dependency architecture certification (<100ms cold start)
|
|
1201
|
+
#{Color.c('vault [status|list|add]', Color::GREEN)} Agency credential vault with AES-256-GCM encryption
|
|
1202
|
+
#{Color.c('geo [url]', Color::CYAN)} Generative Engine Optimization (GEO) & citability audit
|
|
1203
|
+
#{Color.c('cite-sim <url|file> [q]', Color::CYAN)} AI Search Citation Simulator (ChatGPT, Perplexity, Claude)
|
|
1204
|
+
#{Color.c('answer <query>', Color::CYAN)} Direct answer & information gain snippet synthesizer
|
|
1205
|
+
#{Color.c('aio-hunter [query|domain]', Color::CYAN)} Google AI Overview (AIO) radar & citation gap hunter
|
|
1206
|
+
#{Color.c('soft-404 <url|file>', Color::RED)} 404 & Soft-404 crawl waste diagnostic & server rules
|
|
1207
|
+
#{Color.c('sparklines [query|page]', Color::YELLOW)} Terminal ASCII sparklines & visual trajectory graphs
|
|
1208
|
+
#{Color.c('strike [domain]', Color::YELLOW)} Striking-distance playbook: rewrites & link anchors
|
|
1209
|
+
#{Color.c('skill-pack [dir]', Color::CYAN)} Export autonomous AI Agent Skill package
|
|
1210
|
+
|
|
1211
|
+
#{Color::BOLD}AGENT & PROGRAMMATIC INTEGRATION:#{Color::RESET}
|
|
1212
|
+
#{Color.c('skills [install|show]', Color::CYAN)} Inspect, install, or export the AI Agent Skill
|
|
1213
|
+
#{Color.c('-j, --json', Color::CYAN)} Machine-readable JSON output for all commands
|
|
1214
|
+
|
|
1215
|
+
#{Color::BOLD}EXAMPLES BY WORKFLOW:#{Color::RESET}
|
|
1216
|
+
|
|
1217
|
+
#{Color.c('1. Domain Setup & Switching:', Color::CYAN, Color::BOLD)}
|
|
1218
|
+
gsc connect # Interactive 1-click credential setup
|
|
1219
|
+
gsc domains # List all verified properties (choose by number)
|
|
1220
|
+
gsc use # Interactive numbered domain switcher
|
|
1221
|
+
gsc use 2 # Switch active domain by number
|
|
1222
|
+
gsc use example.com # Switch active domain by name
|
|
1223
|
+
gsc where # Inspect CLI path, active key, and config file
|
|
1224
|
+
|
|
1225
|
+
#{Color.c('2. GA4 Linking & Analytics:', Color::CYAN, Color::BOLD)}
|
|
1226
|
+
gsc connect-ga4 # Interactive GA4 linking wizard
|
|
1227
|
+
gsc config set-ga4 123456789 # Link GA4 property ID to active domain
|
|
1228
|
+
gsc ga4-properties # Discover accessible GA4 properties
|
|
1229
|
+
gsc ga4 --organic # View landing page bounce rates & engagement
|
|
1230
|
+
gsc correlation # Correlate SERP rankings with on-site bounce rates
|
|
1231
|
+
gsc ads # Google Ads campaign performance, cost & CPC
|
|
1232
|
+
gsc channels # Omnichannel traffic sources (Organic, Paid, Direct)
|
|
1233
|
+
|
|
1234
|
+
#{Color.c('3. Live Realtime Visitor Monitoring:', Color::CYAN, Color::BOLD)}
|
|
1235
|
+
gsc realtime # Snapshot of active visitors, URLs & countries
|
|
1236
|
+
gsc realtime --watch # Stream live visitors in terminal (refreshes every 5s)
|
|
1237
|
+
gsc realtime -d example.com --watch 3 # Stream specific domain every 3s
|
|
1238
|
+
gsc realtime --json # Machine-readable JSON output for live dashboards
|
|
1239
|
+
|
|
1240
|
+
#{Color.c('4. Search Performance & Multi-Dimensional Analytics:', Color::CYAN, Color::BOLD)}
|
|
1241
|
+
gsc performance # Executive 360° dashboard (Devices, Countries, Snippets, Cities)
|
|
1242
|
+
gsc devices # Desktop vs Mobile vs Tablet search traffic breakdown
|
|
1243
|
+
gsc countries --limit 20 # Top countries driving organic search impressions & clicks
|
|
1244
|
+
gsc cities --limit 20 # Top visitor cities and behavioral retention (via GA4)
|
|
1245
|
+
gsc snippets # Rich snippet appearances (Review stars, Product listings, FAQs)
|
|
1246
|
+
gsc top-queries # Top search keywords, impressions, CTR, position
|
|
1247
|
+
gsc top-queries -s imp --days 7 # Sort queries by highest impression volume
|
|
1248
|
+
gsc top-pages -s clicks # Top traffic-driving landing pages
|
|
1249
|
+
gsc opportunities --min-imp 10 # Striking-distance queries (Pos 7–20) to push to Top 3
|
|
1250
|
+
gsc underperformers # Fix low CTR titles on existing Top 10 rankings
|
|
1251
|
+
gsc cannibalization # Detect internal URL ranking conflicts
|
|
1252
|
+
gsc decay --compare 28 # Compare last 28 days vs prior 28 days
|
|
1253
|
+
gsc zombies public/sitemap.xml # Find zero-impression crawl waste pages
|
|
1254
|
+
gsc audit # Comprehensive 4-step SEO & GA4 health audit
|
|
1255
|
+
|
|
1256
|
+
#{Color.c('5. Google Indexing & Sitemaps:', Color::CYAN, Color::BOLD)}
|
|
1257
|
+
gsc inspect https://example.com/pricing # Live Google index check (coverage, canonical, date)
|
|
1258
|
+
gsc index https://example.com/new-page # Notify Googlebot to crawl/index URL immediately
|
|
1259
|
+
gsc remove https://example.com/deleted-page # Notify Googlebot a page has been removed
|
|
1260
|
+
gsc status https://example.com/page # Check Google Indexing API submission status
|
|
1261
|
+
gsc sitemaps-list # List submitted sitemaps in Search Console
|
|
1262
|
+
gsc sitemaps-submit https://example.com/sitemap.xml # Submit new XML sitemap to Google
|
|
1263
|
+
gsc index-sitemap public/sitemap.xml # Batch submit all sitemap URLs to Google Indexing API
|
|
1264
|
+
gsc inspect-sitemap public/sitemap.xml # Bulk inspect indexation status for all sitemap URLs
|
|
1265
|
+
|
|
1266
|
+
#{Color.c('6. Agent & JSON Export:', Color::CYAN, Color::BOLD)}
|
|
1267
|
+
gsc top-queries --json # Clean JSON output for AI agents & pipelines
|
|
1268
|
+
gsc top-pages --csv pages.csv # Export top pages to CSV
|
|
1269
|
+
gsc skills show # View the AI Agent Skill prompt
|
|
1270
|
+
gsc skills install # Install agent skill to ~/.gemini/config
|
|
1271
|
+
|
|
1272
|
+
#{Color.c('7. Trends, Autocomplete & Keyword Intelligence:', Color::CYAN, Color::BOLD)}
|
|
1273
|
+
gsc trends "seo audit" # Google Trends search interest & regional demand
|
|
1274
|
+
gsc trends "technical seo" --geo US # Filter trends to United States
|
|
1275
|
+
gsc trends "core web vitals" --time 12m # 12-month interest timeline & momentum
|
|
1276
|
+
gsc planner "search console" # Free autocomplete expansion & opportunity scoring
|
|
1277
|
+
gsc planner-import keywords.csv # Import Google Ads Keyword Planner export & rank
|
|
1278
|
+
gsc ke "technical seo" # Keywords Everywhere search volume, CPC & competition
|
|
1279
|
+
gsc ke-credits # Check remaining Keywords Everywhere credit balance
|
|
1280
|
+
gsc connect ke # Link Keywords Everywhere API key
|
|
1281
|
+
|
|
1282
|
+
#{Color.c('8. Offline Cache & Speed (Turn 20):', Color::CYAN, Color::BOLD)}
|
|
1283
|
+
gsc cache warm # Cache search performance data locally for instant querying
|
|
1284
|
+
gsc cache query "search console" # Query cached data in <10ms without hitting Google API
|
|
1285
|
+
gsc cache diff snap_1 snap_2 # Compare rankings between two offline snapshots
|
|
1286
|
+
|
|
1287
|
+
COMMANDS
|
|
1288
|
+
end
|
|
1289
|
+
end
|
|
1290
|
+
end
|
|
1291
|
+
end
|