gsc-cli 2.1.0 → 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.
Files changed (100) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +410 -414
  3. data/bin/gsc +28067 -5661
  4. data/dist/gsc +29121 -5046
  5. data/lib/gsc/aio_hunter.rb +343 -0
  6. data/lib/gsc/answer_synthesizer.rb +157 -0
  7. data/lib/gsc/api.rb +53 -1
  8. data/lib/gsc/auth.rb +26 -0
  9. data/lib/gsc/brand_segmenter.rb +140 -0
  10. data/lib/gsc/cache_manager.rb +806 -0
  11. data/lib/gsc/cannibalization_analyzer.rb +141 -0
  12. data/lib/gsc/canonical_chains.rb +367 -0
  13. data/lib/gsc/citation_simulator.rb +339 -0
  14. data/lib/gsc/cli/aio_hunter.rb +154 -0
  15. data/lib/gsc/cli/analytics.rb +788 -0
  16. data/lib/gsc/cli/audit.rb +1976 -0
  17. data/lib/gsc/cli/base.rb +384 -0
  18. data/lib/gsc/cli/cache.rb +266 -0
  19. data/lib/gsc/cli/canonical.rb +223 -0
  20. data/lib/gsc/cli/citation_simulator.rb +152 -0
  21. data/lib/gsc/cli/dashboard.rb +354 -0
  22. data/lib/gsc/cli/doctor.rb +129 -0
  23. data/lib/gsc/cli/eeat.rb +125 -0
  24. data/lib/gsc/cli/ga4.rb +852 -0
  25. data/lib/gsc/cli/growth.rb +650 -0
  26. data/lib/gsc/cli/hreflang.rb +164 -0
  27. data/lib/gsc/cli/image_seo.rb +162 -0
  28. data/lib/gsc/cli/indexing.rb +458 -0
  29. data/lib/gsc/cli/intent_shift.rb +125 -0
  30. data/lib/gsc/cli/keyword_value.rb +134 -0
  31. data/lib/gsc/cli/keywords.rb +795 -0
  32. data/lib/gsc/cli/landing_roi.rb +308 -0
  33. data/lib/gsc/cli/low_ctr.rb +213 -0
  34. data/lib/gsc/cli/mobile_parity.rb +150 -0
  35. data/lib/gsc/cli/report.rb +100 -0
  36. data/lib/gsc/cli/rich_results.rb +172 -0
  37. data/lib/gsc/cli/schema_generate.rb +149 -0
  38. data/lib/gsc/cli/seasonal.rb +232 -0
  39. data/lib/gsc/cli/security.rb +153 -0
  40. data/lib/gsc/cli/setup.rb +1291 -0
  41. data/lib/gsc/cli/sitemap_tree.rb +143 -0
  42. data/lib/gsc/cli/skill_pack.rb +62 -0
  43. data/lib/gsc/cli/soft_404.rb +199 -0
  44. data/lib/gsc/cli/sparkline.rb +227 -0
  45. data/lib/gsc/cli/watchdog.rb +150 -0
  46. data/lib/gsc/cli/zombie_purger.rb +208 -0
  47. data/lib/gsc/cli.rb +707 -5265
  48. data/lib/gsc/cli_advanced.rb +987 -44
  49. data/lib/gsc/client.rb +17 -2
  50. data/lib/gsc/color.rb +16 -1
  51. data/lib/gsc/command_registry.rb +47 -9
  52. data/lib/gsc/config.rb +2 -2
  53. data/lib/gsc/ctr_curve.rb +115 -0
  54. data/lib/gsc/decay_predictor.rb +322 -0
  55. data/lib/gsc/doctor.rb +434 -0
  56. data/lib/gsc/eeat_auditor.rb +428 -0
  57. data/lib/gsc/entity_auditor.rb +229 -0
  58. data/lib/gsc/firewall_scanner.rb +733 -0
  59. data/lib/gsc/geo_auditor.rb +368 -0
  60. data/lib/gsc/google_trends.rb +8 -1
  61. data/lib/gsc/heading_validator.rb +283 -0
  62. data/lib/gsc/hreflang_validator.rb +412 -0
  63. data/lib/gsc/image_seo.rb +286 -0
  64. data/lib/gsc/indexing_queue.rb +179 -0
  65. data/lib/gsc/indexnow.rb +93 -0
  66. data/lib/gsc/intent_shift.rb +188 -0
  67. data/lib/gsc/internal_links.rb +153 -36
  68. data/lib/gsc/keyword_value.rb +191 -0
  69. data/lib/gsc/landing_roi.rb +195 -0
  70. data/lib/gsc/llms_generator.rb +343 -22
  71. data/lib/gsc/low_ctr_rewriter.rb +408 -0
  72. data/lib/gsc/mobile_parity.rb +222 -0
  73. data/lib/gsc/network_tracer.rb +8 -1
  74. data/lib/gsc/page_analyzer.rb +47 -7
  75. data/lib/gsc/prompts.rb +38 -29
  76. data/lib/gsc/questions_harvester.rb +178 -0
  77. data/lib/gsc/report_generator.rb +461 -0
  78. data/lib/gsc/rich_results.rb +388 -0
  79. data/lib/gsc/robots_checker.rb +46 -15
  80. data/lib/gsc/schema_generator.rb +788 -0
  81. data/lib/gsc/schema_validator.rb +36 -38
  82. data/lib/gsc/seasonal_predictor.rb +381 -0
  83. data/lib/gsc/security_scanner.rb +496 -0
  84. data/lib/gsc/serp_feature_detector.rb +359 -0
  85. data/lib/gsc/serp_preview.rb +108 -22
  86. data/lib/gsc/site_crawler.rb +113 -21
  87. data/lib/gsc/sitemap_loader.rb +15 -4
  88. data/lib/gsc/sitemap_tree.rb +301 -0
  89. data/lib/gsc/skill_pack.rb +195 -0
  90. data/lib/gsc/soft_404_analyzer.rb +385 -0
  91. data/lib/gsc/sparkline.rb +171 -0
  92. data/lib/gsc/speed_correlator.rb +416 -0
  93. data/lib/gsc/striking_playbook.rb +190 -0
  94. data/lib/gsc/title_optimizer.rb +420 -0
  95. data/lib/gsc/vault.rb +260 -0
  96. data/lib/gsc/version.rb +1 -1
  97. data/lib/gsc/watchdog.rb +235 -0
  98. data/lib/gsc/zombie_purger.rb +366 -0
  99. data/lib/gsc.rb +118 -0
  100. metadata +75 -1
@@ -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