gsc-cli 2.0.1 → 2.1.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.
data/dist/gsc CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  # ==============================================================================
5
5
  # Google Search Console & Indexing API CLI Tool (Pure Ruby - Zero Gem Dependencies)
6
- # Standalone Single-File Distribution (Built from lib/gsc v2.0.1)
6
+ # Standalone Single-File Distribution (Built from lib/gsc v2.1.0)
7
7
  # ==============================================================================
8
8
 
9
9
  require 'net/http'
@@ -20,7 +20,7 @@ require 'stringio'
20
20
 
21
21
  # --- version.rb ---
22
22
  module GSC
23
- VERSION = '2.0.1'
23
+ VERSION = '2.1.0'
24
24
  end
25
25
 
26
26
  # --- color.rb ---
@@ -55,6 +55,15 @@ module GSC
55
55
  CONFIG_DIR = File.expand_path('~/.config/gsc')
56
56
  CONFIG_FILE = File.join(CONFIG_DIR, 'config.json')
57
57
 
58
+ def self.get(key)
59
+ load[key.to_s]
60
+ end
61
+
62
+ def self.set(key, value)
63
+ save(key.to_s => value)
64
+ value
65
+ end
66
+
58
67
  def self.load
59
68
  return {} unless File.exist?(CONFIG_FILE)
60
69
  JSON.parse(File.read(CONFIG_FILE))
@@ -2844,6 +2853,22 @@ opts.on('--save', 'Save keyword research snapshot to ~/.config/gsc/domains/<doma
2844
2853
  options[:save] = true
2845
2854
  end
2846
2855
 
2856
+ opts.on('--alphabet', 'Run alphabet soup harvest (a-z) for search suggestions') do
2857
+ options[:alphabet] = true
2858
+ end
2859
+
2860
+ opts.on('--numbers', 'Include numbers (0-9) in alphabet soup search suggestions') do
2861
+ options[:numbers] = true
2862
+ end
2863
+
2864
+ opts.on('--strategy STRAT', 'PageSpeed device strategy: mobile or desktop (default: mobile)') do |s|
2865
+ options[:strategy] = s
2866
+ end
2867
+
2868
+ opts.on('--bot NAME', 'User agent bot name for robots.txt testing (default: googlebot)') do |b|
2869
+ options[:bot] = b
2870
+ end
2871
+
2847
2872
 
2848
2873
  opts.on('--delay MS', Integer, 'Delay between sequential requests in ms (default: 120)') do |delay|
2849
2874
  options[:delay] = delay
@@ -2913,6 +2938,72 @@ opts.on('--dry-run', 'Simulate API calls without mutating data') do
2913
2938
  exit 0 unless options[:in_dashboard]
2914
2939
  return
2915
2940
 
2941
+ when 'suggest', 'autocomplete', 'sug'
2942
+ handle_suggest_command(target, options)
2943
+ exit 0 unless options[:in_dashboard]
2944
+ return if options[:in_dashboard]
2945
+
2946
+ when 'questions', 'paa', 'faqs'
2947
+ handle_questions_command(target, options)
2948
+ exit 0 unless options[:in_dashboard]
2949
+ return if options[:in_dashboard]
2950
+
2951
+ when 'authority', 'opr', 'da', 'domain-authority'
2952
+ handle_authority_command(target, extra, options)
2953
+ exit 0 unless options[:in_dashboard]
2954
+ return if options[:in_dashboard]
2955
+
2956
+ when 'speed', 'vitals', 'pagespeed', 'psi'
2957
+ handle_speed_command(target, options)
2958
+ exit 0 unless options[:in_dashboard]
2959
+ return if options[:in_dashboard]
2960
+
2961
+ when 'compare', 'diff-seo', 'vs'
2962
+ handle_compare_command(target, extra, options)
2963
+ exit 0 unless options[:in_dashboard]
2964
+ return if options[:in_dashboard]
2965
+
2966
+ when 'content-gap', 'gap'
2967
+ handle_content_gap_command(target, extra, options)
2968
+ exit 0 unless options[:in_dashboard]
2969
+ return if options[:in_dashboard]
2970
+
2971
+ when 'internal-links', 'orphans', 'links-audit'
2972
+ handle_internal_links_command(target, options)
2973
+ exit 0 unless options[:in_dashboard]
2974
+ return if options[:in_dashboard]
2975
+
2976
+ when 'schema', 'rich-snippets', 'ld-json'
2977
+ handle_schema_command(target, extra, options)
2978
+ exit 0 unless options[:in_dashboard]
2979
+ return if options[:in_dashboard]
2980
+
2981
+ when 'llms', 'ai-ready'
2982
+ handle_llms_command(target, extra, options)
2983
+ exit 0 unless options[:in_dashboard]
2984
+ return if options[:in_dashboard]
2985
+
2986
+ when 'preview', 'serp-preview', 'social-preview'
2987
+ handle_preview_command(target, options)
2988
+ exit 0 unless options[:in_dashboard]
2989
+ return if options[:in_dashboard]
2990
+
2991
+ when 'trace', 'redirects', 'hops'
2992
+ handle_trace_command(target, options)
2993
+ exit 0 unless options[:in_dashboard]
2994
+ return if options[:in_dashboard]
2995
+
2996
+ when 'robots', 'robots-txt'
2997
+ handle_robots_command(target, extra, options)
2998
+ exit 0 unless options[:in_dashboard]
2999
+ return if options[:in_dashboard]
3000
+
3001
+ when 'backlinks', 'links'
3002
+ handle_backlinks_command(target, extra, options)
3003
+ exit 0 unless options[:in_dashboard]
3004
+ return if options[:in_dashboard]
3005
+
3006
+
2916
3007
  when 'trends', 'tr', 'google-trends', 'gtrends', 't'
2917
3008
  if target.nil? || target.strip.empty?
2918
3009
  # No target query given: pass through to GSC period decay/trends below
@@ -2983,7 +3074,7 @@ when 'version', '-v', '--version'
2983
3074
  exit 0 unless options[:in_dashboard]
2984
3075
  return if options[:in_dashboard]
2985
3076
  when 'config'
2986
- handle_config_command(target, extra, options)
3077
+ handle_config_command(target, extra, args[3], options)
2987
3078
  exit 0 unless options[:in_dashboard]
2988
3079
  return if options[:in_dashboard]
2989
3080
  when 'connect', 'setup', 'init', 'install'
@@ -6668,8 +6759,56 @@ def self.handle_skills_command(subcommand, options)
6668
6759
  puts
6669
6760
  end
6670
6761
 
6671
- def self.handle_config_command(subcommand, subvalue, options)
6762
+ def self.handle_config_command(subcommand, subvalue, subextra_or_options = nil, maybe_options = {})
6763
+ if subextra_or_options.is_a?(Hash)
6764
+ options = subextra_or_options
6765
+ subextra = nil
6766
+ else
6767
+ subextra = subextra_or_options
6768
+ options = maybe_options || {}
6769
+ end
6672
6770
  case subcommand
6771
+ when 'set'
6772
+ key = subvalue.to_s.strip
6773
+ val = subextra.to_s.strip
6774
+ if key.empty? || val.empty?
6775
+ if options[:json]
6776
+ puts JSON.pretty_generate({ error: 'Usage: gsc config set <key> <value>' })
6777
+ else
6778
+ puts Color.c("❌ Error: Please specify key and value.", Color::RED)
6779
+ puts "Example: gsc config set opr_api_key opr_live_xxxx"
6780
+ puts "Example: gsc config set pagespeed_api_key AIzaSyxxxx"
6781
+ end
6782
+ exit 1
6783
+ end
6784
+
6785
+ Config.set(key, val)
6786
+ if key == 'opr_api_key'
6787
+ Config.set('openpagerank_api_key', val)
6788
+ elsif key == 'openpagerank_api_key'
6789
+ Config.set('opr_api_key', val)
6790
+ end
6791
+
6792
+ masked = val.length > 8 ? "#{val[0..7]}...#{val[-4..-1]}" : "***"
6793
+ if options[:json]
6794
+ puts JSON.pretty_generate({ status: 'ok', key: key, value: masked, configFile: Config::CONFIG_FILE })
6795
+ else
6796
+ puts BANNER
6797
+ puts Color.c("✅ Saved configuration: #{Color.c(key, Color::CYAN)} = #{Color.c(masked, Color::GREEN)}", Color::GREEN, Color::BOLD)
6798
+ puts Color.c(" 📁 Stored in #{Config::CONFIG_FILE}", Color::GRAY)
6799
+ end
6800
+ return
6801
+
6802
+ when 'get'
6803
+ key = subvalue.to_s.strip
6804
+ val = Config.get(key)
6805
+ if options[:json]
6806
+ puts JSON.pretty_generate({ key: key, value: val })
6807
+ else
6808
+ puts val || "(not set)"
6809
+ end
6810
+ return
6811
+
6673
6812
  when 'set-domain', 'domain'
6674
6813
  if subvalue.nil? || subvalue.empty?
6675
6814
  if options[:json]
@@ -6775,7 +6914,28 @@ def self.handle_skills_command(subcommand, options)
6775
6914
  else
6776
6915
  puts " Key Location: #{Color.c('Not found', Color::RED)}"
6777
6916
  end
6778
- puts "\n #{Color::BOLD}Linked GA4 Properties per Domain:#{Color::RESET}"
6917
+ opr = cfg['opr_api_key'] || cfg['openpagerank_api_key']
6918
+ if opr
6919
+ m_opr = opr.length > 8 ? "#{opr[0..7]}...#{opr[-4..-1]}" : "***"
6920
+ puts " OpenPageRank: #{Color.c(m_opr, Color::GREEN)} (Active)"
6921
+ else
6922
+ puts " OpenPageRank: #{Color.c('(not set - run: gsc config set opr_api_key <key>)', Color::GRAY)}"
6923
+ end
6924
+
6925
+ ps = cfg['pagespeed_api_key']
6926
+ if ps
6927
+ m_ps = ps.length > 8 ? "#{ps[0..7]}...#{ps[-4..-1]}" : "***"
6928
+ puts " PageSpeed API: #{Color.c(m_ps, Color::GREEN)} (Active)"
6929
+ end
6930
+
6931
+ ke = cfg['keywords_everywhere_api_key']
6932
+ if ke
6933
+ m_ke = ke.length > 8 ? "#{ke[0..7]}...#{ke[-4..-1]}" : "***"
6934
+ puts " Keywords Evr: #{Color.c(m_ke, Color::GREEN)} (Active)"
6935
+ end
6936
+
6937
+ puts "
6938
+ #{Color::BOLD}Linked GA4 Properties per Domain:#{Color::RESET}"
6779
6939
  ga4_props = cfg['ga4_properties'] || {}
6780
6940
  if ga4_props.empty?
6781
6941
  puts " #{Color.c('(None linked yet. Use: gsc config set-ga4 <id> -d <domain>)', Color::GRAY)}"
@@ -6789,6 +6949,8 @@ def self.handle_skills_command(subcommand, options)
6789
6949
  puts "#{Color::BOLD}Configuration shortcuts:#{Color::RESET}"
6790
6950
  puts " gsc connect Interactive setup wizard (drag & drop key)"
6791
6951
  puts " gsc use <domain> Set active default domain"
6952
+ puts " gsc config set <key> <val> Set API key (e.g. opr_api_key, pagespeed_api_key)"
6953
+ puts " gsc config get <key> Read a stored configuration value"
6792
6954
  puts " gsc config set-ga4 <id> Link a GA4 Property ID to domain"
6793
6955
  puts " gsc open Open config directory in Finder"
6794
6956
  puts " gsc where Show install path and paths summary"
@@ -0,0 +1,96 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'csv'
5
+ require 'fileutils'
6
+ require 'json'
7
+
8
+ module GSC
9
+ class BacklinksManager
10
+ attr_reader :domain, :storage_file
11
+
12
+ def initialize(domain)
13
+ @domain = clean_domain(domain)
14
+ @domain_dir = File.join(Dir.home, '.config', 'gsc', 'domains', @domain)
15
+ FileUtils.mkdir_p(@domain_dir)
16
+ @storage_file = File.join(@domain_dir, 'backlinks.json')
17
+ end
18
+
19
+ def load_data
20
+ return { 'sources' => [], 'targets' => [], 'updated_at' => nil } unless File.exist?(@storage_file)
21
+
22
+ JSON.parse(File.read(@storage_file, encoding: 'UTF-8'))
23
+ rescue StandardError
24
+ { 'sources' => [], 'targets' => [], 'updated_at' => nil }
25
+ end
26
+
27
+ def save_data(data)
28
+ data['updated_at'] = Time.now.utc.iso8601
29
+ File.write(@storage_file, JSON.pretty_generate(data))
30
+ end
31
+
32
+ def import_csv(content)
33
+ data = load_data
34
+ lines = content.to_s.strip.lines
35
+
36
+ return { error: 'Empty content' } if lines.empty?
37
+
38
+ # Detect header
39
+ header = lines.first.downcase
40
+ if header.include?('top linking sites') || header.include?('root domain')
41
+ # Sources CSV
42
+ sources = []
43
+ CSV.parse(content, headers: true, skip_blanks: true) do |row|
44
+ domain_name = row['Top linking sites'] || row['Site'] || row[0]
45
+ links_count = (row['Target pages'] || row['Links'] || row[1] || '1').to_s.gsub(',', '').to_i
46
+ sources << { 'domain' => domain_name.to_s.strip, 'links_count' => links_count } if domain_name
47
+ end
48
+ data['sources'] = sources
49
+ else
50
+ # Target pages CSV
51
+ targets = []
52
+ CSV.parse(content, headers: true, skip_blanks: true) do |row|
53
+ target_url = row['Target page'] || row['Page'] || row[0]
54
+ incoming = (row['Incoming links'] || row['Links'] || row[1] || '1').to_s.gsub(',', '').to_i
55
+ targets << { 'target_url' => target_url.to_s.strip, 'incoming_count' => incoming } if target_url
56
+ end
57
+ data['targets'] = targets
58
+ end
59
+
60
+ save_data(data)
61
+ {
62
+ status: 'success',
63
+ sources_count: data['sources'].length,
64
+ targets_count: data['targets'].length,
65
+ updated_at: data['updated_at']
66
+ }
67
+ rescue StandardError => e
68
+ { error: "Failed to parse CSV: #{e.message}" }
69
+ end
70
+
71
+ def summary(target_page: nil)
72
+ data = load_data
73
+ sources = data['sources'] || []
74
+ targets = data['targets'] || []
75
+
76
+ filtered_targets = target_page ? targets.select { |t| t['target_url'].include?(target_page) } : targets
77
+
78
+ {
79
+ domain: @domain,
80
+ total_referring_domains: sources.length,
81
+ total_external_links: sources.sum { |s| s['links_count'] || 0 },
82
+ top_referring_domains: sources.sort_by { |s| -(s['links_count'] || 0) }.first(15),
83
+ top_target_pages: filtered_targets.sort_by { |t| -(t['incoming_count'] || 0) }.first(15),
84
+ updated_at: data['updated_at']
85
+ }
86
+ end
87
+
88
+ private
89
+
90
+ def clean_domain(input)
91
+ d = input.to_s.strip.downcase
92
+ d = d.sub(%r{^https?://}, '').sub(%r{/.*$}, '').sub(/^www\./, '')
93
+ d
94
+ end
95
+ end
96
+ end
data/lib/gsc/cli.rb CHANGED
@@ -161,6 +161,22 @@ opts.on('--save', 'Save keyword research snapshot to ~/.config/gsc/domains/<doma
161
161
  options[:save] = true
162
162
  end
163
163
 
164
+ opts.on('--alphabet', 'Run alphabet soup harvest (a-z) for search suggestions') do
165
+ options[:alphabet] = true
166
+ end
167
+
168
+ opts.on('--numbers', 'Include numbers (0-9) in alphabet soup search suggestions') do
169
+ options[:numbers] = true
170
+ end
171
+
172
+ opts.on('--strategy STRAT', 'PageSpeed device strategy: mobile or desktop (default: mobile)') do |s|
173
+ options[:strategy] = s
174
+ end
175
+
176
+ opts.on('--bot NAME', 'User agent bot name for robots.txt testing (default: googlebot)') do |b|
177
+ options[:bot] = b
178
+ end
179
+
164
180
 
165
181
  opts.on('--delay MS', Integer, 'Delay between sequential requests in ms (default: 120)') do |delay|
166
182
  options[:delay] = delay
@@ -230,6 +246,72 @@ opts.on('--dry-run', 'Simulate API calls without mutating data') do
230
246
  exit 0 unless options[:in_dashboard]
231
247
  return
232
248
 
249
+ when 'suggest', 'autocomplete', 'sug'
250
+ handle_suggest_command(target, options)
251
+ exit 0 unless options[:in_dashboard]
252
+ return if options[:in_dashboard]
253
+
254
+ when 'questions', 'paa', 'faqs'
255
+ handle_questions_command(target, options)
256
+ exit 0 unless options[:in_dashboard]
257
+ return if options[:in_dashboard]
258
+
259
+ when 'authority', 'opr', 'da', 'domain-authority'
260
+ handle_authority_command(target, extra, options)
261
+ exit 0 unless options[:in_dashboard]
262
+ return if options[:in_dashboard]
263
+
264
+ when 'speed', 'vitals', 'pagespeed', 'psi'
265
+ handle_speed_command(target, options)
266
+ exit 0 unless options[:in_dashboard]
267
+ return if options[:in_dashboard]
268
+
269
+ when 'compare', 'diff-seo', 'vs'
270
+ handle_compare_command(target, extra, options)
271
+ exit 0 unless options[:in_dashboard]
272
+ return if options[:in_dashboard]
273
+
274
+ when 'content-gap', 'gap'
275
+ handle_content_gap_command(target, extra, options)
276
+ exit 0 unless options[:in_dashboard]
277
+ return if options[:in_dashboard]
278
+
279
+ when 'internal-links', 'orphans', 'links-audit'
280
+ handle_internal_links_command(target, options)
281
+ exit 0 unless options[:in_dashboard]
282
+ return if options[:in_dashboard]
283
+
284
+ when 'schema', 'rich-snippets', 'ld-json'
285
+ handle_schema_command(target, extra, options)
286
+ exit 0 unless options[:in_dashboard]
287
+ return if options[:in_dashboard]
288
+
289
+ when 'llms', 'ai-ready'
290
+ handle_llms_command(target, extra, options)
291
+ exit 0 unless options[:in_dashboard]
292
+ return if options[:in_dashboard]
293
+
294
+ when 'preview', 'serp-preview', 'social-preview'
295
+ handle_preview_command(target, options)
296
+ exit 0 unless options[:in_dashboard]
297
+ return if options[:in_dashboard]
298
+
299
+ when 'trace', 'redirects', 'hops'
300
+ handle_trace_command(target, options)
301
+ exit 0 unless options[:in_dashboard]
302
+ return if options[:in_dashboard]
303
+
304
+ when 'robots', 'robots-txt'
305
+ handle_robots_command(target, extra, options)
306
+ exit 0 unless options[:in_dashboard]
307
+ return if options[:in_dashboard]
308
+
309
+ when 'backlinks', 'links'
310
+ handle_backlinks_command(target, extra, options)
311
+ exit 0 unless options[:in_dashboard]
312
+ return if options[:in_dashboard]
313
+
314
+
233
315
  when 'trends', 'tr', 'google-trends', 'gtrends', 't'
234
316
  if target.nil? || target.strip.empty?
235
317
  # No target query given: pass through to GSC period decay/trends below
@@ -300,7 +382,7 @@ when 'version', '-v', '--version'
300
382
  exit 0 unless options[:in_dashboard]
301
383
  return if options[:in_dashboard]
302
384
  when 'config'
303
- handle_config_command(target, extra, options)
385
+ handle_config_command(target, extra, args[3], options)
304
386
  exit 0 unless options[:in_dashboard]
305
387
  return if options[:in_dashboard]
306
388
  when 'connect', 'setup', 'init', 'install'
@@ -3985,8 +4067,56 @@ def self.handle_skills_command(subcommand, options)
3985
4067
  puts
3986
4068
  end
3987
4069
 
3988
- def self.handle_config_command(subcommand, subvalue, options)
4070
+ def self.handle_config_command(subcommand, subvalue, subextra_or_options = nil, maybe_options = {})
4071
+ if subextra_or_options.is_a?(Hash)
4072
+ options = subextra_or_options
4073
+ subextra = nil
4074
+ else
4075
+ subextra = subextra_or_options
4076
+ options = maybe_options || {}
4077
+ end
3989
4078
  case subcommand
4079
+ when 'set'
4080
+ key = subvalue.to_s.strip
4081
+ val = subextra.to_s.strip
4082
+ if key.empty? || val.empty?
4083
+ if options[:json]
4084
+ puts JSON.pretty_generate({ error: 'Usage: gsc config set <key> <value>' })
4085
+ else
4086
+ puts Color.c("❌ Error: Please specify key and value.", Color::RED)
4087
+ puts "Example: gsc config set opr_api_key opr_live_xxxx"
4088
+ puts "Example: gsc config set pagespeed_api_key AIzaSyxxxx"
4089
+ end
4090
+ exit 1
4091
+ end
4092
+
4093
+ Config.set(key, val)
4094
+ if key == 'opr_api_key'
4095
+ Config.set('openpagerank_api_key', val)
4096
+ elsif key == 'openpagerank_api_key'
4097
+ Config.set('opr_api_key', val)
4098
+ end
4099
+
4100
+ masked = val.length > 8 ? "#{val[0..7]}...#{val[-4..-1]}" : "***"
4101
+ if options[:json]
4102
+ puts JSON.pretty_generate({ status: 'ok', key: key, value: masked, configFile: Config::CONFIG_FILE })
4103
+ else
4104
+ puts BANNER
4105
+ puts Color.c("✅ Saved configuration: #{Color.c(key, Color::CYAN)} = #{Color.c(masked, Color::GREEN)}", Color::GREEN, Color::BOLD)
4106
+ puts Color.c(" 📁 Stored in #{Config::CONFIG_FILE}", Color::GRAY)
4107
+ end
4108
+ return
4109
+
4110
+ when 'get'
4111
+ key = subvalue.to_s.strip
4112
+ val = Config.get(key)
4113
+ if options[:json]
4114
+ puts JSON.pretty_generate({ key: key, value: val })
4115
+ else
4116
+ puts val || "(not set)"
4117
+ end
4118
+ return
4119
+
3990
4120
  when 'set-domain', 'domain'
3991
4121
  if subvalue.nil? || subvalue.empty?
3992
4122
  if options[:json]
@@ -4092,7 +4222,28 @@ def self.handle_skills_command(subcommand, options)
4092
4222
  else
4093
4223
  puts " Key Location: #{Color.c('Not found', Color::RED)}"
4094
4224
  end
4095
- puts "\n #{Color::BOLD}Linked GA4 Properties per Domain:#{Color::RESET}"
4225
+ opr = cfg['opr_api_key'] || cfg['openpagerank_api_key']
4226
+ if opr
4227
+ m_opr = opr.length > 8 ? "#{opr[0..7]}...#{opr[-4..-1]}" : "***"
4228
+ puts " OpenPageRank: #{Color.c(m_opr, Color::GREEN)} (Active)"
4229
+ else
4230
+ puts " OpenPageRank: #{Color.c('(not set - run: gsc config set opr_api_key <key>)', Color::GRAY)}"
4231
+ end
4232
+
4233
+ ps = cfg['pagespeed_api_key']
4234
+ if ps
4235
+ m_ps = ps.length > 8 ? "#{ps[0..7]}...#{ps[-4..-1]}" : "***"
4236
+ puts " PageSpeed API: #{Color.c(m_ps, Color::GREEN)} (Active)"
4237
+ end
4238
+
4239
+ ke = cfg['keywords_everywhere_api_key']
4240
+ if ke
4241
+ m_ke = ke.length > 8 ? "#{ke[0..7]}...#{ke[-4..-1]}" : "***"
4242
+ puts " Keywords Evr: #{Color.c(m_ke, Color::GREEN)} (Active)"
4243
+ end
4244
+
4245
+ puts "
4246
+ #{Color::BOLD}Linked GA4 Properties per Domain:#{Color::RESET}"
4096
4247
  ga4_props = cfg['ga4_properties'] || {}
4097
4248
  if ga4_props.empty?
4098
4249
  puts " #{Color.c('(None linked yet. Use: gsc config set-ga4 <id> -d <domain>)', Color::GRAY)}"
@@ -4106,6 +4257,8 @@ def self.handle_skills_command(subcommand, options)
4106
4257
  puts "#{Color::BOLD}Configuration shortcuts:#{Color::RESET}"
4107
4258
  puts " gsc connect Interactive setup wizard (drag & drop key)"
4108
4259
  puts " gsc use <domain> Set active default domain"
4260
+ puts " gsc config set <key> <val> Set API key (e.g. opr_api_key, pagespeed_api_key)"
4261
+ puts " gsc config get <key> Read a stored configuration value"
4109
4262
  puts " gsc config set-ga4 <id> Link a GA4 Property ID to domain"
4110
4263
  puts " gsc open Open config directory in Finder"
4111
4264
  puts " gsc where Show install path and paths summary"