kward 0.79.0 → 0.80.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 (117) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +6 -0
  3. data/CHANGELOG.md +58 -1
  4. data/Gemfile.lock +2 -2
  5. data/README.md +31 -38
  6. data/doc/agent-tools.md +9 -10
  7. data/doc/api.md +3 -3
  8. data/doc/authentication.md +79 -110
  9. data/doc/code-search.md +1 -1
  10. data/doc/composer.md +1 -1
  11. data/doc/configuration.md +69 -26
  12. data/doc/context-budgeting.md +6 -6
  13. data/doc/context-tools.md +2 -2
  14. data/doc/editor.md +7 -7
  15. data/doc/extensibility.md +7 -29
  16. data/doc/files.md +6 -6
  17. data/doc/getting-started.md +2 -2
  18. data/doc/git.md +8 -2
  19. data/doc/lifecycle-hooks.md +5 -4
  20. data/doc/local-models.md +2 -2
  21. data/doc/mcp.md +5 -5
  22. data/doc/permissions.md +13 -4
  23. data/doc/plugins.md +39 -4
  24. data/doc/prompt-templates.md +105 -0
  25. data/doc/providers.md +140 -0
  26. data/doc/releasing.md +10 -2
  27. data/doc/rpc.md +54 -31
  28. data/doc/sandboxing.md +22 -0
  29. data/doc/security.md +22 -7
  30. data/doc/session-management.md +2 -4
  31. data/doc/tabs.md +37 -0
  32. data/doc/telegram.md +138 -0
  33. data/doc/transports.md +209 -0
  34. data/doc/usage.md +11 -3
  35. data/doc/web-search.md +3 -3
  36. data/doc/workspace-tools.md +2 -2
  37. data/examples/plugins/stardate_footer.rb +10 -0
  38. data/examples/plugins/telegram/plugin.rb +39 -0
  39. data/examples/plugins/telegram/telegram_api.rb +136 -0
  40. data/examples/plugins/telegram/telegram_transport.rb +304 -0
  41. data/lib/kward/ansi.rb +1 -0
  42. data/lib/kward/auth/api_key_store.rb +99 -0
  43. data/lib/kward/cli/auth_commands.rb +72 -13
  44. data/lib/kward/cli/commands.rb +7 -0
  45. data/lib/kward/cli/git.rb +31 -8
  46. data/lib/kward/cli/plugins.rb +1 -0
  47. data/lib/kward/cli/prompt_interface.rb +7 -0
  48. data/lib/kward/cli/rendering.rb +6 -2
  49. data/lib/kward/cli/runtime_helpers.rb +4 -2
  50. data/lib/kward/cli/sessions.rb +1 -1
  51. data/lib/kward/cli/settings.rb +97 -33
  52. data/lib/kward/cli/tabs.rb +102 -37
  53. data/lib/kward/cli/transports.rb +67 -0
  54. data/lib/kward/cli/worktrees.rb +360 -0
  55. data/lib/kward/cli.rb +20 -0
  56. data/lib/kward/config_files.rb +26 -3
  57. data/lib/kward/conversation.rb +13 -7
  58. data/lib/kward/git_worktree_manager.rb +291 -0
  59. data/lib/kward/model/azure_openai_config.rb +54 -0
  60. data/lib/kward/model/catalog.rb +113 -0
  61. data/lib/kward/model/client.rb +225 -20
  62. data/lib/kward/model/model_info.rb +49 -0
  63. data/lib/kward/model/payloads.rb +63 -4
  64. data/lib/kward/model/provider_catalog.rb +114 -0
  65. data/lib/kward/model/sources.rb +70 -0
  66. data/lib/kward/model/stream_parser.rb +83 -13
  67. data/lib/kward/permissions/policy.rb +18 -4
  68. data/lib/kward/plugin_chat_runtime.rb +374 -0
  69. data/lib/kward/plugin_registry.rb +80 -7
  70. data/lib/kward/private_file.rb +9 -3
  71. data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
  72. data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
  73. data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
  74. data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
  75. data/lib/kward/prompt_interface/editor/controller.rb +7 -7
  76. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  77. data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
  78. data/lib/kward/prompt_interface/editor/state.rb +3 -9
  79. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +184 -9
  80. data/lib/kward/prompt_interface/file_overlay.rb +2 -2
  81. data/lib/kward/prompt_interface/git_prompt.rb +7 -3
  82. data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
  83. data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
  84. data/lib/kward/prompt_interface/interactive/state.rb +2 -2
  85. data/lib/kward/prompt_interface/project_browser.rb +25 -6
  86. data/lib/kward/prompt_interface/runtime_state.rb +22 -6
  87. data/lib/kward/prompt_interface/screen.rb +3 -0
  88. data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
  89. data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
  90. data/lib/kward/prompt_interface.rb +53 -12
  91. data/lib/kward/prompts/commands.rb +1 -1
  92. data/lib/kward/prompts.rb +6 -5
  93. data/lib/kward/rpc/auth_manager.rb +112 -152
  94. data/lib/kward/rpc/config_manager.rb +46 -6
  95. data/lib/kward/rpc/plugin_chat_manager.rb +55 -196
  96. data/lib/kward/rpc/prompt_bridge.rb +8 -4
  97. data/lib/kward/rpc/redactor.rb +1 -1
  98. data/lib/kward/rpc/server.rb +60 -10
  99. data/lib/kward/rpc/session_manager.rb +109 -30
  100. data/lib/kward/session_store.rb +84 -24
  101. data/lib/kward/tab_driver.rb +6 -3
  102. data/lib/kward/tools/git_commit.rb +39 -0
  103. data/lib/kward/tools/registry.rb +7 -2
  104. data/lib/kward/tools/tool_call.rb +1 -0
  105. data/lib/kward/transport/gateway.rb +253 -0
  106. data/lib/kward/transport/host.rb +343 -0
  107. data/lib/kward/transport/manager.rb +179 -0
  108. data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
  109. data/lib/kward/transport/runtime.rb +41 -0
  110. data/lib/kward/transport/store.rb +101 -0
  111. data/lib/kward/transport.rb +204 -0
  112. data/lib/kward/version.rb +1 -1
  113. data/lib/kward/workspace_factory.rb +19 -2
  114. data/templates/default/fulldoc/html/js/kward.js +2 -0
  115. data/templates/default/kward_navigation.rb +7 -2
  116. data/templates/default/layout/html/setup.rb +4 -0
  117. metadata +26 -1
@@ -0,0 +1,136 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "uri"
4
+
5
+ module Kward
6
+ module Telegram
7
+ # Small dependency-free client for the Telegram Bot API.
8
+ class BotApi
9
+ API_BASE_URL = "https://api.telegram.org".freeze
10
+ MAX_RESPONSE_BYTES = 5 * 1024 * 1024
11
+
12
+ class Error < StandardError
13
+ attr_reader :method, :error_code, :parameters
14
+
15
+ def initialize(method, description, error_code: nil, parameters: nil)
16
+ @method = method
17
+ @error_code = error_code
18
+ @parameters = parameters
19
+ super("Telegram #{method} failed: #{description.to_s}")
20
+ end
21
+ end
22
+
23
+ class RateLimitError < Error
24
+ attr_reader :retry_after
25
+
26
+ def initialize(method, description, retry_after:, error_code: nil, parameters: nil)
27
+ @retry_after = Integer(retry_after)
28
+ super(method, description, error_code: error_code, parameters: parameters)
29
+ end
30
+ end
31
+
32
+ def initialize(token:, requester: nil, base_url: API_BASE_URL)
33
+ @token = token.to_s
34
+ raise ArgumentError, "Telegram bot token is required" if @token.empty?
35
+
36
+ @requester = requester || method(:request)
37
+ @base_url = base_url.to_s.sub(%r{/\z}, "")
38
+ end
39
+
40
+ def get_me
41
+ call("getMe")
42
+ end
43
+
44
+ def delete_webhook(drop_pending_updates: false)
45
+ call("deleteWebhook", "drop_pending_updates" => !!drop_pending_updates)
46
+ end
47
+
48
+ def get_updates(offset: nil, timeout: 25)
49
+ timeout = Integer(timeout)
50
+ raise ArgumentError, "Telegram polling timeout must be between 0 and 50 seconds" unless (0..50).cover?(timeout)
51
+
52
+ params = {
53
+ "timeout" => timeout,
54
+ "allowed_updates" => JSON.generate(%w[message callback_query])
55
+ }
56
+ params["offset"] = Integer(offset) unless offset.nil?
57
+ Array(call("getUpdates", params))
58
+ end
59
+
60
+ def answer_callback_query(callback_query_id:, text: nil, show_alert: false)
61
+ params = {
62
+ "callback_query_id" => callback_query_id.to_s,
63
+ "show_alert" => !!show_alert
64
+ }
65
+ params["text"] = text.to_s unless text.nil?
66
+ call("answerCallbackQuery", params)
67
+ end
68
+
69
+ def send_message(chat_id:, text:, reply_to_message_id: nil, reply_markup: nil)
70
+ text = text.to_s
71
+ raise ArgumentError, "Telegram message text is required" if text.empty?
72
+
73
+ params = { "chat_id" => Integer(chat_id), "text" => text }
74
+ unless reply_to_message_id.nil?
75
+ params["reply_parameters"] = JSON.generate({ "message_id" => Integer(reply_to_message_id) })
76
+ end
77
+ params["reply_markup"] = JSON.generate(reply_markup) unless reply_markup.nil?
78
+ call("sendMessage", params)
79
+ end
80
+
81
+ private
82
+
83
+ def call(method, params = {})
84
+ response = @requester.call(method, params)
85
+ unless response.is_a?(Hash) && response["ok"]
86
+ raise_error(method, response || {})
87
+ end
88
+
89
+ response["result"]
90
+ end
91
+
92
+ def raise_error(method, response)
93
+ description = (response["description"] || "unknown API error").to_s.gsub(@token, "[REDACTED]")
94
+ parameters = response["parameters"] || {}
95
+ if response["error_code"].to_i == 429 && parameters["retry_after"]
96
+ raise RateLimitError.new(
97
+ method,
98
+ description,
99
+ retry_after: parameters.fetch("retry_after"),
100
+ error_code: response["error_code"],
101
+ parameters: parameters
102
+ )
103
+ end
104
+
105
+ raise Error.new(
106
+ method,
107
+ description,
108
+ error_code: response["error_code"],
109
+ parameters: parameters
110
+ )
111
+ end
112
+
113
+ def request(method, params)
114
+ uri = URI.parse("#{@base_url}/bot#{@token}/#{method}")
115
+ request = Net::HTTP::Post.new(uri)
116
+ request.set_form_data(params.transform_values(&:to_s))
117
+
118
+ response = Net::HTTP.start(
119
+ uri.host,
120
+ uri.port,
121
+ use_ssl: uri.scheme == "https",
122
+ open_timeout: 5,
123
+ read_timeout: params.fetch("timeout", 10).to_i + 10
124
+ ) { |http| http.request(request) }
125
+ body = response.body.to_s
126
+ raise Error.new(method, "response was too large") if body.bytesize > MAX_RESPONSE_BYTES
127
+
128
+ JSON.parse(body)
129
+ rescue JSON::ParserError
130
+ raise Error.new(method, "response was not valid JSON")
131
+ rescue URI::InvalidURIError
132
+ raise Error.new(method, "invalid Telegram API URL")
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,304 @@
1
+ require "thread"
2
+ require_relative "telegram_api"
3
+
4
+ module Kward
5
+ module Telegram
6
+ # Long-polling Telegram adapter backed by normal Kward sessions.
7
+ class Transport
8
+ TELEGRAM_MESSAGE_LIMIT = 4096
9
+ DEFAULT_POLL_TIMEOUT = 25
10
+ MAX_RETRY_DELAY = 30
11
+
12
+ def initialize(host:, config:, api: nil, sleeper: nil)
13
+ @host = host
14
+ @config = config || {}
15
+ @token = host.secret("bot_token", env: "TELEGRAM_BOT_TOKEN")
16
+ @workspace = required_config("workspace")
17
+ @allowed_user_ids = required_ids("allowed_user_ids")
18
+ @allowed_chat_ids = required_ids("allowed_chat_ids")
19
+ @poll_timeout = Integer(@config.fetch("poll_timeout_seconds", DEFAULT_POLL_TIMEOUT))
20
+ raise ArgumentError, "poll_timeout_seconds must be between 0 and 50" unless (0..50).cover?(@poll_timeout)
21
+
22
+ @api = api || BotApi.new(token: @token)
23
+ @sleeper = sleeper || method(:sleep)
24
+ @state_mutex = Mutex.new
25
+ @stop = false
26
+ @thread = nil
27
+ @last_error = nil
28
+ end
29
+
30
+ def start
31
+ @api.delete_webhook
32
+ @api.get_me
33
+ @host.interactions.subscribe { |request| handle_interaction(request) }
34
+ @state_mutex.synchronize do
35
+ @stop = false
36
+ @last_error = nil
37
+ @thread = Thread.new { poll_loop }
38
+ end
39
+ nil
40
+ end
41
+
42
+ def stop
43
+ thread = @state_mutex.synchronize do
44
+ @stop = true
45
+ @thread
46
+ end
47
+ return nil unless thread
48
+
49
+ thread.join(@poll_timeout + 10)
50
+ thread.kill if thread.alive?
51
+ @state_mutex.synchronize { @thread = nil }
52
+ nil
53
+ end
54
+
55
+ def health
56
+ @state_mutex.synchronize do
57
+ {
58
+ state: @thread&.alive? ? "running" : "stopped",
59
+ error: @last_error
60
+ }
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def poll_loop
67
+ offset = Integer(@host.storage.get("telegram:offset") || 0)
68
+ until stopping?
69
+ begin
70
+ updates = @api.get_updates(offset: offset, timeout: @poll_timeout)
71
+ updates.each do |update|
72
+ update_id = Integer(update.fetch("update_id"))
73
+ handle_update(update)
74
+ offset = [offset, update_id + 1].max
75
+ @host.storage.put("telegram:offset", offset)
76
+ end
77
+ rescue BotApi::RateLimitError => error
78
+ record_error(error)
79
+ wait_for([error.retry_after, MAX_RETRY_DELAY].min)
80
+ rescue StandardError => error
81
+ record_error(error)
82
+ wait_for(2)
83
+ end
84
+ end
85
+ end
86
+
87
+ def handle_update(update)
88
+ if update["callback_query"]
89
+ handle_callback(update.fetch("callback_query"))
90
+ elsif update["message"]
91
+ handle_message(update.fetch("message"), update.fetch("update_id"))
92
+ end
93
+ rescue StandardError => error
94
+ record_error(error)
95
+ end
96
+
97
+ def handle_message(message, update_id)
98
+ text = message["text"].to_s
99
+ return if text.empty?
100
+
101
+ user_id = Integer(message.fetch("from").fetch("id"))
102
+ chat_id = Integer(message.fetch("chat").fetch("id"))
103
+ return unless allowed?(user_id, chat_id)
104
+ return unless @host.storage.claim("telegram:update:#{update_id}")
105
+
106
+ conversation = Kward::Transport.conversation_key(
107
+ transport_id: @host.transport_id,
108
+ external_id: "chat:#{chat_id}"
109
+ )
110
+ actor = Kward::Transport.actor(
111
+ id: "telegram:user:#{user_id}",
112
+ display_name: message.dig("from", "username") || user_id.to_s,
113
+ metadata: { provider: "telegram", user_id: user_id, chat_id: chat_id }
114
+ )
115
+ @host.authorize!(:telegram_message, actor: actor, conversation: conversation)
116
+
117
+ session = @host.sessions.resolve(
118
+ conversation: conversation,
119
+ actor: actor,
120
+ workspace_root: @workspace,
121
+ name: "Telegram chat #{chat_id}"
122
+ )
123
+ remember_session(session, conversation, chat_id, actor)
124
+
125
+ turn = session.start_turn(text)
126
+ @host.storage.put("telegram:turn:#{turn.id}", {
127
+ "chat_id" => chat_id,
128
+ "session_id" => session.id,
129
+ "reply_to_message_id" => message["message_id"]
130
+ })
131
+ turn.subscribe { |event| handle_turn_event(event) }
132
+ end
133
+
134
+ def handle_turn_event(event)
135
+ return unless %w[answer assistantMessage assistant_message].include?(event.type.to_s)
136
+
137
+ turn = @host.storage.get("telegram:turn:#{event.turn_id}")
138
+ return unless turn
139
+ return if @host.storage.get("telegram:delivered:#{event.turn_id}")
140
+
141
+ content = event.payload[:content] || event.payload["content"]
142
+ if content.to_s.empty? && %w[assistantMessage assistant_message].include?(event.type.to_s)
143
+ message = event.payload[:message] || event.payload["message"]
144
+ content = message[:content] || message["content"] if message.is_a?(Hash)
145
+ end
146
+ return if content.to_s.empty?
147
+
148
+ split_message(content.to_s).each do |part|
149
+ @api.send_message(
150
+ chat_id: turn.fetch("chat_id"),
151
+ text: part,
152
+ reply_to_message_id: turn["reply_to_message_id"]
153
+ )
154
+ end
155
+ @host.storage.put("telegram:delivered:#{event.turn_id}", true)
156
+ rescue StandardError => error
157
+ record_error(error)
158
+ end
159
+
160
+ def handle_interaction(request)
161
+ session = @host.storage.get("telegram:session:#{request.session_id}")
162
+ return unless session
163
+
164
+ record = {
165
+ "chat_id" => session.fetch("chat_id"),
166
+ "session_id" => request.session_id,
167
+ "external_id" => session.fetch("external_id"),
168
+ "actor_id" => session.fetch("actor_id"),
169
+ "actor_name" => session["actor_name"],
170
+ "kind" => request.kind.to_s
171
+ }
172
+ buttons = case request.kind.to_s
173
+ when "tool_approval"
174
+ record["answers"] = { "approve" => true, "deny" => false }
175
+ [[
176
+ { "text" => "Approve", "callback_data" => callback_data(request.id, "approve") },
177
+ { "text" => "Deny", "callback_data" => callback_data(request.id, "deny") }
178
+ ]]
179
+ when "question"
180
+ question, options = question_options(request)
181
+ return if options.empty?
182
+
183
+ record["question"] = question
184
+ record["answers"] = options.each_with_index.to_h { |option, index| [index.to_s, option] }
185
+ [options.each_with_index.map do |option, index|
186
+ { "text" => option, "callback_data" => callback_data(request.id, index.to_s) }
187
+ end]
188
+ else
189
+ return
190
+ end
191
+
192
+ @host.storage.put("telegram:interaction:#{request.id}", record)
193
+ @api.send_message(
194
+ chat_id: record.fetch("chat_id"),
195
+ text: request.prompt,
196
+ reply_markup: { "inline_keyboard" => buttons }
197
+ )
198
+ rescue StandardError => error
199
+ record_error(error)
200
+ end
201
+
202
+ def handle_callback(callback)
203
+ data = callback["data"].to_s
204
+ match = /\Akward:([^:]+):([^:]+)\z/.match(data)
205
+ return unless match
206
+
207
+ request_id, choice = match.captures
208
+ chat_id = Integer(callback.fetch("message").fetch("chat").fetch("id"))
209
+ user_id = Integer(callback.fetch("from").fetch("id"))
210
+ return unless allowed?(user_id, chat_id)
211
+
212
+ record = @host.storage.get("telegram:interaction:#{request_id}")
213
+ return unless record && record.fetch("chat_id") == chat_id
214
+ return unless record.fetch("actor_id") == "telegram:user:#{user_id}"
215
+ return if @host.storage.get("telegram:answered:#{request_id}")
216
+
217
+ conversation = Kward::Transport.conversation_key(
218
+ transport_id: @host.transport_id,
219
+ external_id: record.fetch("external_id")
220
+ )
221
+ actor = Kward::Transport.actor(
222
+ id: record.fetch("actor_id"),
223
+ display_name: record["actor_name"],
224
+ metadata: { provider: "telegram", user_id: user_id, chat_id: chat_id }
225
+ )
226
+ session = @host.sessions.resolve(
227
+ conversation: conversation,
228
+ actor: actor,
229
+ workspace_root: @workspace
230
+ )
231
+ answer = record.fetch("answers").fetch(choice)
232
+ answer = [{ question: record.fetch("question"), answer: answer }] if record.fetch("kind") == "question"
233
+ session.answer_interaction(request_id: request_id, answer: answer)
234
+ @host.storage.put("telegram:answered:#{request_id}", true)
235
+ @api.answer_callback_query(callback_query_id: callback.fetch("id"))
236
+ rescue StandardError => error
237
+ record_error(error)
238
+ end
239
+
240
+ def remember_session(session, conversation, chat_id, actor)
241
+ @host.storage.put("telegram:session:#{session.id}", {
242
+ "chat_id" => chat_id,
243
+ "external_id" => conversation.external_id,
244
+ "actor_id" => actor.id,
245
+ "actor_name" => actor.display_name
246
+ })
247
+ end
248
+
249
+ def question_options(request)
250
+ question = request.choices.first || {}
251
+ options = Array(question[:options] || question["options"]).map do |option|
252
+ (option[:label] || option["label"]).to_s
253
+ end.reject(&:empty?)
254
+ [question[:question] || question["question"] || request.prompt, options]
255
+ end
256
+
257
+ def callback_data(request_id, choice)
258
+ "kward:#{request_id}:#{choice}"
259
+ end
260
+
261
+ def split_message(text)
262
+ text.each_char.each_slice(TELEGRAM_MESSAGE_LIMIT).map(&:join)
263
+ end
264
+
265
+ def allowed?(user_id, chat_id)
266
+ @allowed_user_ids.include?(user_id) && @allowed_chat_ids.include?(chat_id)
267
+ end
268
+
269
+ def stopping?
270
+ @state_mutex.synchronize { @stop }
271
+ end
272
+
273
+ def wait_for(seconds)
274
+ @sleeper.call(seconds) unless stopping?
275
+ end
276
+
277
+ def record_error(error)
278
+ message = error.message.to_s.gsub(@token.to_s, "[REDACTED]")
279
+ summary = "#{error.class}: #{message}"
280
+ @state_mutex.synchronize { @last_error = summary }
281
+ @host.logger.error("Telegram transport error: #{summary}")
282
+ rescue StandardError
283
+ nil
284
+ end
285
+
286
+ def required_config(key)
287
+ value = @config[key] || @config[key.to_sym]
288
+ raise ArgumentError, "Telegram transport requires #{key}" if value.to_s.empty?
289
+
290
+ value.to_s
291
+ end
292
+
293
+ def required_ids(key)
294
+ values = @config[key] || @config[key.to_sym]
295
+ values = Array(values).map { |value| Integer(value) }
296
+ raise ArgumentError, "Telegram transport requires non-empty #{key}" if values.empty?
297
+
298
+ values.uniq.freeze
299
+ rescue ArgumentError, TypeError
300
+ raise ArgumentError, "Telegram transport #{key} must contain numeric IDs"
301
+ end
302
+ end
303
+ end
304
+ end
data/lib/kward/ansi.rb CHANGED
@@ -22,6 +22,7 @@ module Kward
22
22
  cyan: 36,
23
23
  gray: 90,
24
24
  grey: 90,
25
+ white: 97,
25
26
  primary_green: "38;2;138;160;106",
26
27
  bright_accent_green: "38;2;155;255;0",
27
28
  augen: "38;2;155;255;0",
@@ -0,0 +1,99 @@
1
+ require "json"
2
+ require_relative "../config_files"
3
+ require_relative "../private_file"
4
+ require_relative "../model/provider_catalog"
5
+
6
+ # Namespace for Kward credential storage.
7
+ module Kward
8
+ # Stores API keys outside the non-secret Kward configuration file.
9
+ class APIKeyStore
10
+ def initialize(path: APIKeyStore.default_path, config_path: ConfigFiles.config_path, env: ENV)
11
+ @path = File.expand_path(path)
12
+ @config_path = File.expand_path(config_path)
13
+ @env = env
14
+ end
15
+
16
+ attr_reader :path
17
+
18
+ def self.default_path
19
+ File.join(ConfigFiles.config_dir, "api_keys.json")
20
+ end
21
+
22
+ def fetch(provider_id)
23
+ provider = ProviderCatalog.fetch(provider_id)
24
+ environment_key(provider) || stored_key(provider.id)
25
+ end
26
+
27
+ def configured?(provider_id)
28
+ !fetch(provider_id).to_s.empty?
29
+ end
30
+
31
+ def store(provider_id, api_key)
32
+ provider = ProviderCatalog.fetch(provider_id)
33
+ raise ArgumentError, "#{provider.name} does not accept an API key" unless provider.api_key?
34
+
35
+ api_key = api_key.to_s.strip
36
+ raise ArgumentError, "API key must be a non-empty string" if api_key.empty?
37
+
38
+ keys = stored_keys
39
+ keys[provider.id] = api_key
40
+ write_keys(keys)
41
+ path
42
+ end
43
+
44
+ def delete(provider_id)
45
+ provider = ProviderCatalog.fetch(provider_id)
46
+ keys = stored_keys
47
+ return false unless keys.delete(provider.id)
48
+
49
+ write_keys(keys)
50
+ true
51
+ end
52
+
53
+ def stored?(provider_id)
54
+ !stored_key(provider_id).to_s.empty?
55
+ end
56
+
57
+ def stored_value(provider_id)
58
+ ProviderCatalog.fetch(provider_id)
59
+ stored_key(provider_id)
60
+ end
61
+
62
+ def migrate_openrouter_config_key!
63
+ return false if stored?("openrouter")
64
+
65
+ config = ConfigFiles.read_config(@config_path)
66
+ key = config["openrouter_api_key"].to_s.strip
67
+ return false if key.empty?
68
+
69
+ store("openrouter", key)
70
+ ConfigFiles.delete_config_key("openrouter_api_key", @config_path)
71
+ true
72
+ end
73
+
74
+ private
75
+
76
+ def environment_key(provider)
77
+ provider.api_key_env.map { |name| @env[name].to_s.strip }.find { |key| !key.empty? }
78
+ end
79
+
80
+ def stored_key(provider_id)
81
+ stored_keys[provider_id.to_s]
82
+ end
83
+
84
+ def stored_keys
85
+ return {} unless File.exist?(path)
86
+
87
+ data = JSON.parse(File.read(path))
88
+ raise "Invalid Kward API key file: #{path}" unless data.is_a?(Hash)
89
+
90
+ data.transform_keys(&:to_s).transform_values(&:to_s)
91
+ rescue JSON::ParserError => e
92
+ raise "Invalid Kward API key file: #{path}: #{e.message}"
93
+ end
94
+
95
+ def write_keys(keys)
96
+ PrivateFile.write_json(path, keys)
97
+ end
98
+ end
99
+ end
@@ -22,12 +22,15 @@ module Kward
22
22
 
23
23
  # Writes the auth status output for the terminal CLI flow.
24
24
  def print_auth_status
25
- config = safely_read_config.to_h
25
+ store = api_key_store
26
+ store.migrate_openrouter_config_key!
26
27
  lines = ["#{colored("Auth Status", :green, :bold)}", ""]
27
28
  lines << auth_status_line("OpenAI OAuth", File.exist?(OpenAIOAuth.default_auth_path), OpenAIOAuth.default_auth_path)
28
29
  lines << auth_status_line("Anthropic OAuth", File.exist?(AnthropicOAuth.default_auth_path), AnthropicOAuth.default_auth_path)
29
30
  lines << auth_status_line("GitHub OAuth", File.exist?(GithubOAuth.default_auth_path), GithubOAuth.default_auth_path)
30
- lines << auth_status_line("OpenRouter API key", !config["openrouter_api_key"].to_s.empty? || !ENV["OPENROUTER_API_KEY"].to_s.empty?, ConfigFiles.config_path)
31
+ ProviderCatalog.api_key_providers.each do |provider|
32
+ lines << auth_status_line("#{provider.name} API key", store.configured?(provider.id), store.path)
33
+ end
31
34
  @prompt.say lines.join("\n")
32
35
  end
33
36
 
@@ -45,7 +48,11 @@ module Kward
45
48
  File.delete(path)
46
49
  removed << path
47
50
  end
48
- removed << "OpenRouter API key" if OpenRouterAPIKey.new.logout
51
+ store = api_key_store
52
+ store.migrate_openrouter_config_key!
53
+ ProviderCatalog.api_key_providers.each do |provider|
54
+ removed << "#{provider.name} API key" if store.delete(provider.id)
55
+ end
49
56
 
50
57
  if removed.empty?
51
58
  @prompt.say "No saved credentials found."
@@ -54,29 +61,81 @@ module Kward
54
61
  end
55
62
  end
56
63
 
57
- def login(provider: nil, oauth: nil)
58
- provider = provider.to_s.downcase
59
- if provider == "openrouter"
60
- auth = oauth || OpenRouterAPIKey.new
61
- path = auth.login(prompt: @prompt)
62
- @prompt.say("#{colored("Saved", :green, :bold)} OpenRouter API key to #{path}")
64
+ def login(provider: nil, oauth: nil, auth_method: nil)
65
+ provider = normalized_login_provider(provider)
66
+ if api_key_login?(provider, auth_method)
67
+ login_with_api_key(provider)
63
68
  return
64
69
  end
65
70
 
66
71
  oauth ||= case provider
67
- when "github" then GithubOAuth.new
68
- when "anthropic", "claude" then AnthropicOAuth.new
72
+ when "github", "copilot" then GithubOAuth.new
73
+ when "anthropic" then AnthropicOAuth.new
69
74
  else OpenAIOAuth.new
70
75
  end
71
76
  path = oauth.login(prompt: @prompt)
72
77
  name = case provider
73
- when "github" then "GitHub"
74
- when "anthropic", "claude" then "Anthropic"
78
+ when "github", "copilot" then "GitHub"
79
+ when "anthropic" then "Anthropic"
75
80
  else "OpenAI"
76
81
  end
77
82
  @prompt.say("#{colored("Saved", :green, :bold)} #{name} OAuth login to #{path}")
78
83
  end
79
84
 
85
+ private
86
+
87
+ def api_key_store
88
+ @api_key_store ||= APIKeyStore.new
89
+ end
90
+
91
+ def normalized_login_provider(provider)
92
+ value = provider.to_s.downcase
93
+ return "openai" if value.empty?
94
+ return "anthropic" if ["anthropic", "claude"].include?(value)
95
+ return "copilot" if ["github", "copilot"].include?(value)
96
+
97
+ ProviderCatalog.fetch(value).id
98
+ end
99
+
100
+ def api_key_login?(provider, auth_method)
101
+ return true if auth_method == :api_key || auth_method == "api_key"
102
+
103
+ ProviderCatalog.fetch(provider).api_key? && !["openai", "anthropic", "copilot"].include?(provider)
104
+ end
105
+
106
+ def login_with_api_key(provider_id)
107
+ provider = ProviderCatalog.fetch(provider_id)
108
+ raise "#{provider.name} does not accept an API key" unless provider.api_key?
109
+
110
+ api_key = @prompt.ask("#{provider.name} API key:").to_s.strip
111
+ provider_config = provider_config_after_api_key_login(provider)
112
+ models = refresh_provider_models(provider, api_key)
113
+ path = api_key_store.store(provider.id, api_key)
114
+ ConfigFiles.update_config(provider_config) unless provider_config.empty?
115
+ @prompt.say("#{colored("Saved", :green, :bold)} #{provider.name} API key to #{path}")
116
+ @prompt.say("Loaded #{models.length} #{provider.name} model#{models.length == 1 ? "" : "s"}.") if models
117
+ end
118
+
119
+ def refresh_provider_models(provider, api_key)
120
+ return unless ProviderCatalog.runtime(provider.id).automatic_model_discovery?
121
+
122
+ model_catalog(provider_id: provider.id, api_key: api_key).refresh
123
+ end
124
+
125
+ def model_catalog(provider_id:, api_key:)
126
+ ModelCatalog.new(provider_id: provider_id, api_key: api_key)
127
+ end
128
+
129
+ def provider_config_after_api_key_login(provider)
130
+ return {} unless provider.id == "azure_openai"
131
+
132
+ AzureOpenAIConfig.new(
133
+ endpoint: @prompt.ask("Azure OpenAI endpoint:"),
134
+ deployment: @prompt.ask("Azure OpenAI deployment name:"),
135
+ api_version: @prompt.ask("Azure OpenAI API version:")
136
+ ).to_config
137
+ end
138
+
80
139
  end
81
140
  end
82
141
  end