kward 0.66.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 (93) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +9 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Gemfile +8 -0
  5. data/Gemfile.lock +90 -0
  6. data/LICENSE +21 -0
  7. data/README.md +101 -0
  8. data/Rakefile +20 -0
  9. data/doc/authentication.md +105 -0
  10. data/doc/code-search.md +56 -0
  11. data/doc/configuration.md +310 -0
  12. data/doc/extensibility.md +186 -0
  13. data/doc/getting-started.md +127 -0
  14. data/doc/memory.md +192 -0
  15. data/doc/plugins.md +223 -0
  16. data/doc/releasing.md +36 -0
  17. data/doc/rpc.md +635 -0
  18. data/doc/usage.md +179 -0
  19. data/doc/web-search.md +28 -0
  20. data/exe/kward +5 -0
  21. data/kward.gemspec +33 -0
  22. data/lib/kward/agent.rb +234 -0
  23. data/lib/kward/ansi.rb +276 -0
  24. data/lib/kward/auth/file.rb +11 -0
  25. data/lib/kward/auth/github_oauth.rb +222 -0
  26. data/lib/kward/auth/openai_oauth.rb +323 -0
  27. data/lib/kward/auth/openrouter_api_key.rb +40 -0
  28. data/lib/kward/cancellation.rb +54 -0
  29. data/lib/kward/cli.rb +2122 -0
  30. data/lib/kward/clipboard.rb +84 -0
  31. data/lib/kward/compactor.rb +998 -0
  32. data/lib/kward/config_files.rb +564 -0
  33. data/lib/kward/conversation.rb +148 -0
  34. data/lib/kward/events.rb +13 -0
  35. data/lib/kward/export_path.rb +28 -0
  36. data/lib/kward/image_attachments.rb +331 -0
  37. data/lib/kward/markdown_transcript.rb +72 -0
  38. data/lib/kward/memory/manager.rb +652 -0
  39. data/lib/kward/message_access.rb +42 -0
  40. data/lib/kward/model/chat_invocation.rb +23 -0
  41. data/lib/kward/model/client.rb +875 -0
  42. data/lib/kward/model/context_overflow.rb +55 -0
  43. data/lib/kward/model/context_usage.rb +104 -0
  44. data/lib/kward/model/model_info.rb +188 -0
  45. data/lib/kward/model/retry_message.rb +11 -0
  46. data/lib/kward/model/stream_parser.rb +205 -0
  47. data/lib/kward/pan/index.html.erb +143 -0
  48. data/lib/kward/pan/server.rb +397 -0
  49. data/lib/kward/plugin_registry.rb +327 -0
  50. data/lib/kward/private_file.rb +18 -0
  51. data/lib/kward/prompt_interface.rb +2437 -0
  52. data/lib/kward/prompts/commands.rb +50 -0
  53. data/lib/kward/prompts/templates.rb +60 -0
  54. data/lib/kward/prompts.rb +58 -0
  55. data/lib/kward/resources/avatar_kward_logo.rb +48 -0
  56. data/lib/kward/resources/pixel_logo.rb +230 -0
  57. data/lib/kward/rpc/auth_manager.rb +265 -0
  58. data/lib/kward/rpc/config_manager.rb +58 -0
  59. data/lib/kward/rpc/prompt_bridge.rb +104 -0
  60. data/lib/kward/rpc/redactor.rb +47 -0
  61. data/lib/kward/rpc/server.rb +639 -0
  62. data/lib/kward/rpc/session_manager.rb +1122 -0
  63. data/lib/kward/rpc/tool_event_normalizer.rb +68 -0
  64. data/lib/kward/rpc/tool_metadata.rb +80 -0
  65. data/lib/kward/rpc/transcript_normalizer.rb +307 -0
  66. data/lib/kward/rpc/transport.rb +58 -0
  67. data/lib/kward/session_diff.rb +125 -0
  68. data/lib/kward/session_store.rb +493 -0
  69. data/lib/kward/skills/registry.rb +76 -0
  70. data/lib/kward/starter_pack_installer.rb +110 -0
  71. data/lib/kward/steering.rb +56 -0
  72. data/lib/kward/telemetry/logger.rb +195 -0
  73. data/lib/kward/telemetry/stats.rb +466 -0
  74. data/lib/kward/tools/ask_user_question.rb +107 -0
  75. data/lib/kward/tools/base.rb +45 -0
  76. data/lib/kward/tools/code_search.rb +65 -0
  77. data/lib/kward/tools/edit_file.rb +41 -0
  78. data/lib/kward/tools/list_directory.rb +21 -0
  79. data/lib/kward/tools/read_file.rb +30 -0
  80. data/lib/kward/tools/read_skill.rb +27 -0
  81. data/lib/kward/tools/registry.rb +117 -0
  82. data/lib/kward/tools/run_shell_command.rb +28 -0
  83. data/lib/kward/tools/search/code.rb +445 -0
  84. data/lib/kward/tools/search/web.rb +747 -0
  85. data/lib/kward/tools/tool_call.rb +87 -0
  86. data/lib/kward/tools/web_search.rb +48 -0
  87. data/lib/kward/tools/write_file.rb +29 -0
  88. data/lib/kward/transcript_export.rb +40 -0
  89. data/lib/kward/version.rb +4 -0
  90. data/lib/kward/workspace.rb +377 -0
  91. data/lib/kward.rb +6 -0
  92. data/lib/main.rb +3 -0
  93. metadata +232 -0
data/lib/kward/ansi.rb ADDED
@@ -0,0 +1,276 @@
1
+ module Kward
2
+ module ANSI
3
+ ESCAPE_PATTERN = /\e\[[0-9;?]*[ -\/]*[@-~]/.freeze
4
+ SGR_PATTERN = /\e\[[0-9;:]*m/.freeze
5
+ OSC_PATTERN = /\e\][^\a]*(?:\a|\e\\)/m.freeze
6
+ STRING_ESCAPE_PATTERN = /\e[P_X^][\s\S]*?\e\\/m.freeze
7
+ STYLES = {
8
+ reset: 0,
9
+ bold: 1,
10
+ dim: 2,
11
+ italic: 3,
12
+ strikethrough: 9,
13
+ red: 31,
14
+ green: 32,
15
+ yellow: 33,
16
+ blue: 34,
17
+ magenta: 35,
18
+ cyan: 36,
19
+ gray: 90,
20
+ grey: 90,
21
+ primary_green: "38;2;138;160;106",
22
+ bright_accent_green: "38;2;155;255;0",
23
+ augen: "38;2;155;255;0",
24
+ dark_forest_green: "38;2;78;88;53",
25
+ stone: "38;2;196;192;178",
26
+ metal_dark: "38;2;42;42;42",
27
+ background: "38;2;22;24;22"
28
+ }.freeze
29
+
30
+ module_function
31
+
32
+ def enabled?(output = $stdout, env: ENV)
33
+ setting = env["KWARD_COLOR"].to_s.downcase
34
+ return true if %w[always force forced true yes 1].include?(setting)
35
+ return false if %w[never false no 0].include?(setting)
36
+ return true if forced_color?(env)
37
+ return false if disabled_color?(env)
38
+
39
+ output.respond_to?(:tty?) && output.tty?
40
+ end
41
+
42
+ def colorize(text, *styles, enabled: enabled?)
43
+ string = text.to_s
44
+ return string unless enabled
45
+
46
+ codes = styles.flatten.map { |style| STYLES.fetch(style, style) }.compact
47
+ return string if codes.empty?
48
+
49
+ "\e[#{codes.join(";")}m#{string}\e[0m"
50
+ end
51
+
52
+ def strip(text)
53
+ text.to_s.gsub(ESCAPE_PATTERN, "")
54
+ end
55
+
56
+ def sanitize_transcript(text)
57
+ string = text.to_s.gsub(OSC_PATTERN, "").gsub(STRING_ESCAPE_PATTERN, "")
58
+ string.gsub(/\e(?:\[[0-9;:?]*[ -\/]*[@-~]|.)/m) do |sequence|
59
+ sequence.match?(SGR_PATTERN) ? sequence : ""
60
+ end
61
+ end
62
+
63
+ def wrap_visible(text, width)
64
+ line_width = [width.to_i, 1].max
65
+ rows = []
66
+ current = +""
67
+ visible_width = 0
68
+ string = text.to_s
69
+ index = 0
70
+
71
+ while index < string.length
72
+ if string[index] == "\e" && (match = string[index..].match(/\A\e\[[0-9;:]*m/))
73
+ if current.empty? && rows.any?
74
+ rows[-1] << match[0]
75
+ else
76
+ current << match[0]
77
+ end
78
+ index += match[0].length
79
+ next
80
+ end
81
+
82
+ char = string[index]
83
+ current << char
84
+ visible_width += 1
85
+ index += 1
86
+ if visible_width >= line_width
87
+ rows << current
88
+ current = +""
89
+ visible_width = 0
90
+ end
91
+ end
92
+
93
+ rows << current unless current.empty?
94
+ rows
95
+ end
96
+
97
+ def markdown(text, enabled: enabled?)
98
+ string = text.to_s
99
+ lines = string.lines(chomp: true)
100
+ rendered = []
101
+ in_fence = false
102
+
103
+ lines.each do |line|
104
+ if (match = line.match(/\A\s*```([^`]*)\s*\z/))
105
+ if in_fence
106
+ rendered << colorize("└" + "─" * 39, :gray, enabled: enabled)
107
+ in_fence = false
108
+ else
109
+ language = match[1].to_s.strip
110
+ label = language.empty? ? "code" : "code #{language}"
111
+ rendered << colorize("┌─ #{label}", :gray, enabled: enabled)
112
+ in_fence = true
113
+ end
114
+ next
115
+ end
116
+
117
+ if in_fence
118
+ rendered << colorize("│ #{line}", :dim, enabled: enabled)
119
+ else
120
+ rendered << markdown_line(line, enabled: enabled)
121
+ end
122
+ end
123
+
124
+ rendered << colorize("└" + "─" * 39, :gray, enabled: enabled) if in_fence
125
+ rendered.join("\n") + (string.end_with?("\n") ? "\n" : "")
126
+ end
127
+
128
+ class MarkdownStream
129
+ def initialize(enabled: ANSI.enabled?)
130
+ @enabled = enabled
131
+ @pending = +""
132
+ @in_fence = false
133
+ end
134
+
135
+ def render(delta, final: false)
136
+ text = delta.to_s
137
+ return ANSI.markdown(text, enabled: @enabled) if fast_markdown?(text, final)
138
+
139
+ @pending << text
140
+ rendered = +""
141
+ while (match = @pending.match(/\r\n|\r|\n/))
142
+ line = @pending[0...match.begin(0)]
143
+ @pending = @pending[(match.end(0))..] || +""
144
+ rendered << render_line(line) << "\n"
145
+ end
146
+
147
+ if final && !@pending.empty?
148
+ rendered << render_line(@pending)
149
+ @pending.clear
150
+ end
151
+
152
+ if final && @in_fence
153
+ rendered << "\n" unless rendered.empty? || rendered.end_with?("\n")
154
+ rendered << ANSI.colorize("└" + "─" * 39, :gray, enabled: @enabled)
155
+ @in_fence = false
156
+ end
157
+
158
+ rendered
159
+ end
160
+
161
+ private
162
+
163
+ def fast_markdown?(text, final)
164
+ !final && !@in_fence && @pending.empty? && !text.match?(/[`*~_\[\]>]/)
165
+ end
166
+
167
+ def render_line(line)
168
+ if (match = line.match(/\A\s*```([^`]*)\s*\z/))
169
+ if @in_fence
170
+ @in_fence = false
171
+ ANSI.colorize("└" + "─" * 39, :gray, enabled: @enabled)
172
+ else
173
+ language = match[1].to_s.strip
174
+ label = language.empty? ? "code" : "code #{language}"
175
+ @in_fence = true
176
+ ANSI.colorize("┌─ #{label}", :gray, enabled: @enabled)
177
+ end
178
+ elsif @in_fence
179
+ ANSI.colorize("│ #{line}", :dim, enabled: @enabled)
180
+ else
181
+ ANSI.markdown_line(line, enabled: @enabled)
182
+ end
183
+ end
184
+ end
185
+
186
+ def markdown_line(line, enabled: enabled?)
187
+ if (match = line.match(/\A(\#{1,6}\s+)(.+)\z/))
188
+ markdown_heading(match[1], match[2], enabled: enabled)
189
+ elsif (match = line.match(/\A(\s*)[-*]\s+\[([ xX])\]\s+(.+)\z/))
190
+ task_list_item(match[1], match[2], match[3], enabled: enabled)
191
+ elsif (match = line.match(/\A>\s?(.*)\z/))
192
+ blockquote(match[1], enabled: enabled)
193
+ else
194
+ inline_markdown(line, enabled: enabled)
195
+ end
196
+ end
197
+
198
+ def markdown_heading(marker, text, enabled: enabled?)
199
+ "#{marker}#{colorize(text, :bold, enabled: enabled)}"
200
+ end
201
+
202
+ def task_list_item(indent, marker, text, enabled: enabled?)
203
+ checked = marker.downcase == "x"
204
+ box = checked ? colorize("☑", :green, enabled: enabled) : colorize("☐", :gray, enabled: enabled)
205
+ "#{indent}#{box} #{inline_markdown(text, enabled: enabled)}"
206
+ end
207
+
208
+ def blockquote(text, enabled: enabled?)
209
+ "#{colorize("│", :gray, enabled: enabled)} #{inline_markdown(text, enabled: enabled)}"
210
+ end
211
+
212
+ def inline_markdown(line, enabled: enabled?)
213
+ line.to_s.split(/(`[^`\n]+`)/).map do |part|
214
+ if part.start_with?("`") && part.end_with?("`") && part.length > 1
215
+ "`#{colorize(part[1...-1], :dim, enabled: enabled)}`"
216
+ else
217
+ inline_links(part, enabled: enabled)
218
+ end
219
+ end.join
220
+ end
221
+
222
+ def inline_links(text, enabled: enabled?)
223
+ text.split(/(\[[^\]\n]+\]\([^)\s\n]+\))/).map do |part|
224
+ if (match = part.match(/\A\[([^\]\n]+)\]\(([^)\s\n]+)\)\z/))
225
+ "#{colorize(match[1], :cyan, enabled: enabled)} (#{colorize(match[2], :dim, enabled: enabled)})"
226
+ else
227
+ inline_emphasis(part, enabled: enabled)
228
+ end
229
+ end.join
230
+ end
231
+
232
+ def inline_emphasis(text, enabled: enabled?)
233
+ rendered = inline_bold(text, enabled: enabled)
234
+ rendered = inline_strikethrough(rendered, enabled: enabled)
235
+ inline_italic(rendered, enabled: enabled)
236
+ end
237
+
238
+ def inline_bold(text, enabled: enabled?)
239
+ text.gsub(/\*\*([^\n]+?)\*\*/) do
240
+ colorize(Regexp.last_match(1), :bold, enabled: enabled)
241
+ end
242
+ end
243
+
244
+ def inline_strikethrough(text, enabled: enabled?)
245
+ text.gsub(/~~([^\n]+?)~~/) do
246
+ colorize(Regexp.last_match(1), :strikethrough, enabled: enabled)
247
+ end
248
+ end
249
+
250
+ def inline_italic(text, enabled: enabled?)
251
+ rendered = text.gsub(/(^|[\s\(\[{])\*([^*\n]+?)\*(?=$|[\s\)\]},.!?:;])/) do
252
+ "#{Regexp.last_match(1)}#{colorize(Regexp.last_match(2), :italic, enabled: enabled)}"
253
+ end
254
+ rendered.gsub(/(^|[\s\(\[{])_([^_\n]+?)_(?=$|[\s\)\]},.!?:;])/) do
255
+ "#{Regexp.last_match(1)}#{colorize(Regexp.last_match(2), :italic, enabled: enabled)}"
256
+ end
257
+ end
258
+
259
+ def inline_code(line, enabled: enabled?)
260
+ inline_markdown(line, enabled: enabled)
261
+ end
262
+
263
+ def forced_color?(env)
264
+ force_color = env["FORCE_COLOR"]
265
+ clicolor_force = env["CLICOLOR_FORCE"]
266
+ (force_color && force_color != "0") || (clicolor_force && clicolor_force != "0")
267
+ end
268
+
269
+ def disabled_color?(env)
270
+ return true if env.key?("NO_COLOR") && !env["NO_COLOR"].to_s.empty?
271
+ return true if env["CLICOLOR"] == "0"
272
+
273
+ env["TERM"] == "dumb"
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,11 @@
1
+ require_relative "../private_file"
2
+
3
+ module Kward
4
+ module AuthFile
5
+ module_function
6
+
7
+ def write_json(path, data)
8
+ PrivateFile.write_json(path, data)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,222 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "time"
4
+ require "uri"
5
+ require_relative "file"
6
+ require_relative "../config_files"
7
+
8
+ module Kward
9
+ class GithubOAuth
10
+ DEVICE_CODE_URL = URI("https://github.com/login/device/code")
11
+ TOKEN_URL = URI("https://github.com/login/oauth/access_token")
12
+ COPILOT_TOKEN_URL = URI("https://api.github.com/copilot_internal/v2/token")
13
+ DEFAULT_SCOPE = "read:user"
14
+ DEFAULT_CLIENT_ID = "Iv1.b507a08c87ecfe98"
15
+ COPILOT_HEADERS = {
16
+ "User-Agent" => "GitHubCopilotChat/0.35.0",
17
+ "Editor-Version" => "vscode/1.107.0",
18
+ "Editor-Plugin-Version" => "copilot-chat/0.35.0",
19
+ "Copilot-Integration-Id" => "vscode-chat"
20
+ }.freeze
21
+
22
+ attr_reader :auth_path
23
+
24
+ def initialize(auth_path: GithubOAuth.default_auth_path, config_path: ConfigFiles.config_path)
25
+ @auth_path = File.expand_path(auth_path)
26
+ @config_path = File.expand_path(config_path)
27
+ end
28
+
29
+ def self.default_auth_path
30
+ File.expand_path(ENV["KWARD_GITHUB_AUTH_PATH"] || "~/.kward/github_auth.json")
31
+ end
32
+
33
+ def access_token
34
+ env_token = ENV["COPILOT_GITHUB_TOKEN"].to_s
35
+ return env_token unless env_token.empty?
36
+
37
+ auth = load_auth
38
+ return nil unless auth
39
+
40
+ token = auth.dig("tokens", "copilot_access_token") || auth.dig("tokens", "access")
41
+ return token if token && !token_expired?(auth)
42
+
43
+ github_token = auth.dig("tokens", "github_access_token") || auth.dig("tokens", "refresh") || auth.dig("tokens", "access_token")
44
+ return token unless github_token
45
+
46
+ refreshed = refresh_copilot_token(github_token)
47
+ save_auth(tokens: auth.fetch("tokens", {}).merge(refreshed))
48
+ refreshed["copilot_access_token"]
49
+ end
50
+
51
+ def base_url
52
+ token = access_token.to_s
53
+ match = token.match(/proxy-ep=([^;]+)/)
54
+ return "https://#{match[1].sub(/\Aproxy\./, "api.")}" if match
55
+
56
+ "https://api.individual.githubcopilot.com"
57
+ end
58
+
59
+ def logged_in?
60
+ !access_token.to_s.empty?
61
+ end
62
+
63
+ def login(prompt:, timeout_seconds: 900)
64
+ device = request_device_code
65
+ prompt.say("GitHub login URL: #{device.fetch("verification_uri")}")
66
+ prompt.say("GitHub device code: #{device.fetch("user_code")}")
67
+ prompt.say("Authorize Kward in your browser, then wait for login to complete.")
68
+
69
+ github_tokens = poll_for_token(
70
+ device_code: device.fetch("device_code"),
71
+ interval: positive_integer(device["interval"]) || 5,
72
+ timeout_seconds: timeout_seconds
73
+ )
74
+ copilot_tokens = refresh_copilot_token(github_tokens.fetch("access_token"))
75
+ tokens = github_tokens.merge("github_access_token" => github_tokens.fetch("access_token")).merge(copilot_tokens)
76
+ save_auth(tokens: tokens)
77
+ auth_path
78
+ end
79
+
80
+ def save_auth(tokens: {})
81
+ data = {
82
+ "auth_mode" => "github_oauth",
83
+ "tokens" => tokens,
84
+ "saved_at" => Time.now.utc.iso8601,
85
+ "expires_at" => expires_at_for(tokens)
86
+ }.compact
87
+
88
+ AuthFile.write_json(@auth_path, data)
89
+ end
90
+
91
+ private
92
+
93
+ def refresh_copilot_token(github_token)
94
+ response = get_json(COPILOT_TOKEN_URL, "Authorization" => "Bearer #{github_token}")
95
+ data = parse_successful_json(response, "GitHub Copilot token")
96
+ token = data["token"].to_s
97
+ expires_at = data["expires_at"].to_i
98
+ raise "GitHub Copilot token response missing token" if token.empty?
99
+
100
+ {
101
+ "copilot_access_token" => token,
102
+ "access" => token,
103
+ "copilot_expires_at" => expires_at.positive? ? Time.at(expires_at).utc.iso8601 : nil
104
+ }.compact
105
+ end
106
+
107
+ def load_auth
108
+ return nil unless File.exist?(@auth_path)
109
+
110
+ JSON.parse(File.read(@auth_path))
111
+ rescue JSON::ParserError
112
+ nil
113
+ end
114
+
115
+ def request_device_code
116
+ response = post_form(DEVICE_CODE_URL,
117
+ client_id: client_id,
118
+ scope: scope)
119
+ parse_successful_json(response, "GitHub OAuth device code")
120
+ end
121
+
122
+ def poll_for_token(device_code:, interval:, timeout_seconds:)
123
+ deadline = Time.now + timeout_seconds.to_i
124
+ loop do
125
+ response = post_form(TOKEN_URL,
126
+ client_id: client_id,
127
+ device_code: device_code,
128
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code")
129
+ data = parse_json(response, "GitHub OAuth token poll")
130
+ return data if response.is_a?(Net::HTTPSuccess) && data["access_token"].to_s != ""
131
+
132
+ case data["error"].to_s
133
+ when "authorization_pending"
134
+ # keep polling
135
+ when "slow_down"
136
+ interval += 5
137
+ when "expired_token"
138
+ raise "GitHub OAuth device code expired"
139
+ else
140
+ raise "GitHub OAuth token poll failed: #{data["error_description"] || data["error"] || response.body}"
141
+ end
142
+
143
+ raise "GitHub OAuth login timed out" if Time.now >= deadline
144
+
145
+ sleep interval
146
+ end
147
+ end
148
+
149
+ def post_form(uri, params)
150
+ request = Net::HTTP::Post.new(uri)
151
+ request["Content-Type"] = "application/x-www-form-urlencoded"
152
+ request["Accept"] = "application/json"
153
+ request.body = URI.encode_www_form(params)
154
+
155
+ COPILOT_HEADERS.each { |key, value| request[key] ||= value }
156
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
157
+ end
158
+
159
+ def get_json(uri, headers = {})
160
+ request = Net::HTTP::Get.new(uri)
161
+ request["Accept"] = "application/json"
162
+ COPILOT_HEADERS.merge(headers).each { |key, value| request[key] = value }
163
+
164
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
165
+ end
166
+
167
+ def parse_successful_json(response, label)
168
+ data = parse_json(response, label)
169
+ return data if response.is_a?(Net::HTTPSuccess) && data["error"].to_s.empty?
170
+
171
+ raise "#{label} failed: #{data["error_description"] || data["error"] || response.body}"
172
+ end
173
+
174
+ def parse_json(response, label)
175
+ JSON.parse(response.body.to_s)
176
+ rescue JSON::ParserError => e
177
+ raise "#{label} returned invalid JSON: #{e.message}"
178
+ end
179
+
180
+ def client_id
181
+ value = ENV["GITHUB_OAUTH_CLIENT_ID"].to_s.strip
182
+ return value unless value.empty?
183
+
184
+ value = ConfigFiles.config_value(load_config, "github_oauth_client_id")
185
+ return value unless value.to_s.empty?
186
+
187
+ DEFAULT_CLIENT_ID
188
+ end
189
+
190
+ def scope
191
+ ENV["GITHUB_OAUTH_SCOPE"].to_s.strip.empty? ? ConfigFiles.config_value(load_config, "github_oauth_scope") || DEFAULT_SCOPE : ENV["GITHUB_OAUTH_SCOPE"].to_s.strip
192
+ end
193
+
194
+ def load_config
195
+ ConfigFiles.read_config(@config_path)
196
+ end
197
+
198
+ def expires_at_for(tokens)
199
+ copilot_expires_at = tokens["copilot_expires_at"] || tokens[:copilot_expires_at]
200
+ return copilot_expires_at if copilot_expires_at
201
+
202
+ expires_in = tokens["expires_in"] || tokens[:expires_in]
203
+ return nil unless expires_in.to_i.positive?
204
+
205
+ (Time.now.utc + expires_in.to_i).iso8601
206
+ end
207
+
208
+ def token_expired?(auth)
209
+ expires_at = auth&.fetch("expires_at", nil)
210
+ return false unless expires_at
211
+
212
+ Time.parse(expires_at) <= Time.now.utc + 60
213
+ rescue ArgumentError
214
+ false
215
+ end
216
+
217
+ def positive_integer(value)
218
+ integer = value.to_i
219
+ integer.positive? ? integer : nil
220
+ end
221
+ end
222
+ end