kward 0.67.1 → 0.68.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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +5 -5
  5. data/doc/authentication.md +24 -1
  6. data/doc/configuration.md +9 -2
  7. data/doc/extensibility.md +1 -1
  8. data/doc/getting-started.md +4 -6
  9. data/doc/plugins.md +0 -2
  10. data/doc/releasing.md +7 -8
  11. data/doc/rpc.md +6 -6
  12. data/doc/usage.md +5 -2
  13. data/doc/web-search.md +2 -2
  14. data/kward.gemspec +4 -0
  15. data/lib/kward/agent.rb +29 -2
  16. data/lib/kward/ansi.rb +3 -0
  17. data/lib/kward/auth/anthropic_oauth.rb +291 -0
  18. data/lib/kward/auth/file.rb +2 -0
  19. data/lib/kward/auth/github_oauth.rb +3 -0
  20. data/lib/kward/auth/openai_oauth.rb +4 -0
  21. data/lib/kward/auth/openrouter_api_key.rb +2 -0
  22. data/lib/kward/cancellation.rb +3 -0
  23. data/lib/kward/cli/auth_commands.rb +82 -0
  24. data/lib/kward/cli/commands.rb +222 -0
  25. data/lib/kward/cli/compaction.rb +25 -0
  26. data/lib/kward/cli/doctor.rb +121 -0
  27. data/lib/kward/cli/interactive_turn.rb +225 -0
  28. data/lib/kward/cli/memory_commands.rb +133 -0
  29. data/lib/kward/cli/plugins.rb +112 -0
  30. data/lib/kward/cli/prompt_interface.rb +132 -0
  31. data/lib/kward/cli/rendering.rb +389 -0
  32. data/lib/kward/cli/runtime_helpers.rb +159 -0
  33. data/lib/kward/cli/sessions.rb +376 -0
  34. data/lib/kward/cli/settings.rb +663 -0
  35. data/lib/kward/cli/slash_commands.rb +112 -0
  36. data/lib/kward/cli/stats.rb +64 -0
  37. data/lib/kward/cli/tool_summaries.rb +153 -0
  38. data/lib/kward/cli.rb +38 -2790
  39. data/lib/kward/cli_transcript_formatter.rb +4 -7
  40. data/lib/kward/clipboard.rb +1 -0
  41. data/lib/kward/compaction/file_operation_tracker.rb +3 -0
  42. data/lib/kward/compactor.rb +29 -7
  43. data/lib/kward/config_files.rb +33 -24
  44. data/lib/kward/conversation.rb +70 -5
  45. data/lib/kward/events.rb +2 -0
  46. data/lib/kward/export_path.rb +2 -0
  47. data/lib/kward/image_attachments.rb +2 -0
  48. data/lib/kward/markdown_transcript.rb +2 -0
  49. data/lib/kward/memory/manager.rb +13 -0
  50. data/lib/kward/message_access.rb +23 -2
  51. data/lib/kward/message_text.rb +45 -0
  52. data/lib/kward/model/chat_invocation.rb +2 -0
  53. data/lib/kward/model/client.rb +295 -77
  54. data/lib/kward/model/context_overflow.rb +2 -0
  55. data/lib/kward/model/context_usage.rb +3 -0
  56. data/lib/kward/model/model_info.rb +143 -4
  57. data/lib/kward/model/payloads.rb +166 -13
  58. data/lib/kward/model/retry_message.rb +2 -0
  59. data/lib/kward/model/stream_parser.rb +129 -0
  60. data/lib/kward/pan/server.rb +3 -1
  61. data/lib/kward/plugin_registry.rb +12 -0
  62. data/lib/kward/private_file.rb +2 -0
  63. data/lib/kward/prompt_interface/banner.rb +3 -0
  64. data/lib/kward/prompt_interface/composer_controller.rb +262 -0
  65. data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
  66. data/lib/kward/prompt_interface/composer_state.rb +221 -0
  67. data/lib/kward/prompt_interface/key_handler.rb +365 -0
  68. data/lib/kward/prompt_interface/layout.rb +31 -0
  69. data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
  70. data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
  71. data/lib/kward/prompt_interface/question_prompt.rb +328 -0
  72. data/lib/kward/prompt_interface/runtime_state.rb +59 -0
  73. data/lib/kward/prompt_interface/screen.rb +186 -0
  74. data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
  75. data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
  76. data/lib/kward/prompt_interface/stream_state.rb +65 -0
  77. data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
  78. data/lib/kward/prompt_interface/transcript_renderer.rb +142 -0
  79. data/lib/kward/prompt_interface.rb +69 -1832
  80. data/lib/kward/prompts/commands.rb +2 -0
  81. data/lib/kward/prompts/templates.rb +3 -0
  82. data/lib/kward/prompts.rb +2 -0
  83. data/lib/kward/question_contract.rb +66 -0
  84. data/lib/kward/resources/avatar_kward_logo.rb +2 -0
  85. data/lib/kward/resources/pixel_logo.rb +2 -0
  86. data/lib/kward/rpc/attachment_normalizer.rb +60 -0
  87. data/lib/kward/rpc/auth_manager.rb +65 -11
  88. data/lib/kward/rpc/config_manager.rb +11 -0
  89. data/lib/kward/rpc/prompt_bridge.rb +5 -26
  90. data/lib/kward/rpc/redactor.rb +3 -0
  91. data/lib/kward/rpc/runtime_payloads.rb +4 -1
  92. data/lib/kward/rpc/server.rb +36 -9
  93. data/lib/kward/rpc/session_manager.rb +121 -345
  94. data/lib/kward/rpc/session_metrics.rb +68 -0
  95. data/lib/kward/rpc/session_tree.rb +48 -0
  96. data/lib/kward/rpc/session_tree_rows.rb +208 -0
  97. data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
  98. data/lib/kward/rpc/tool_metadata.rb +3 -0
  99. data/lib/kward/rpc/transcript_normalizer.rb +3 -0
  100. data/lib/kward/rpc/transport.rb +3 -0
  101. data/lib/kward/session_diff.rb +2 -0
  102. data/lib/kward/session_store.rb +114 -24
  103. data/lib/kward/session_trash.rb +1 -0
  104. data/lib/kward/session_tree_renderer.rb +8 -41
  105. data/lib/kward/session_tree_tool_display.rb +56 -0
  106. data/lib/kward/skills/registry.rb +3 -0
  107. data/lib/kward/starter_pack_installer.rb +1 -0
  108. data/lib/kward/steering.rb +2 -0
  109. data/lib/kward/telemetry/logger.rb +3 -0
  110. data/lib/kward/telemetry/stats.rb +3 -0
  111. data/lib/kward/tools/ask_user_question.rb +20 -32
  112. data/lib/kward/tools/base.rb +8 -0
  113. data/lib/kward/tools/code_search.rb +5 -0
  114. data/lib/kward/tools/edit_file.rb +5 -0
  115. data/lib/kward/tools/list_directory.rb +5 -0
  116. data/lib/kward/tools/read_file.rb +5 -0
  117. data/lib/kward/tools/read_skill.rb +5 -0
  118. data/lib/kward/tools/registry.rb +33 -2
  119. data/lib/kward/tools/run_shell_command.rb +5 -0
  120. data/lib/kward/tools/search/code.rb +7 -0
  121. data/lib/kward/tools/search/web.rb +17 -14
  122. data/lib/kward/tools/tool_call.rb +25 -5
  123. data/lib/kward/tools/web_search.rb +7 -1
  124. data/lib/kward/tools/write_file.rb +5 -0
  125. data/lib/kward/transcript_export.rb +2 -0
  126. data/lib/kward/version.rb +2 -1
  127. data/lib/kward/workspace.rb +45 -5
  128. metadata +43 -1
@@ -0,0 +1,291 @@
1
+ require "base64"
2
+ require "digest"
3
+ require "json"
4
+ require "net/http"
5
+ require "securerandom"
6
+ require "socket"
7
+ require "time"
8
+ require "uri"
9
+ require_relative "file"
10
+ require_relative "../config_files"
11
+
12
+ # Namespace for the Kward CLI agent runtime.
13
+ module Kward
14
+ # OAuth helper for Anthropic Claude Pro/Max subscription credentials.
15
+ class AnthropicOAuth
16
+ AUTHORIZE_URL = "https://claude.ai/oauth/authorize"
17
+ TOKEN_URL = URI("https://platform.claude.com/v1/oauth/token")
18
+ DEFAULT_PORT = 53_692
19
+ CALLBACK_PATH = "/callback"
20
+ DEFAULT_CLIENT_ID = Base64.decode64("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl")
21
+ SCOPE = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"
22
+
23
+ attr_reader :auth_path
24
+
25
+ # Creates an object for Anthropic OAuth credentials.
26
+ def initialize(auth_path: AnthropicOAuth.default_auth_path, client_id: nil, config_path: ConfigFiles.config_path)
27
+ @auth_path = File.expand_path(auth_path)
28
+ @client_id = client_id
29
+ @config_path = File.expand_path(config_path)
30
+ end
31
+
32
+ def self.default_auth_path
33
+ File.expand_path(ENV["KWARD_ANTHROPIC_AUTH_PATH"] || "~/.kward/anthropic_auth.json")
34
+ end
35
+
36
+ def access_token
37
+ auth = current_auth
38
+ auth&.fetch("tokens", {})&.fetch("access_token", nil)
39
+ end
40
+
41
+ def logged_in?
42
+ !access_token.to_s.empty?
43
+ end
44
+
45
+ def login(prompt:, open_browser: true, timeout_seconds: 120)
46
+ flow = start_login_flow
47
+ pkce = flow[:pkce]
48
+ state = flow[:state]
49
+ server = flow[:server]
50
+ redirect_uri = flow[:redirect_uri]
51
+ url = flow[:authorization_url]
52
+
53
+ prompt.say("Anthropic login URL:\n#{url}\n")
54
+ prompt.say("Waiting for browser login. If it does not complete, paste the callback URL when prompted.")
55
+ browser_opened = open_browser && open_url(url)
56
+
57
+ code = wait_for_callback(server, expected_state: state, timeout_seconds: browser_opened ? timeout_seconds : 5)
58
+ unless code
59
+ input = prompt.ask("Paste callback URL or authorization code:")
60
+ code = authorization_code_from(input.to_s, expected_state: state)
61
+ end
62
+ raise "Missing authorization code" if code.to_s.empty?
63
+
64
+ complete_login_flow(code: code, redirect_uri: redirect_uri, code_verifier: pkce[:verifier])
65
+ auth_path
66
+ ensure
67
+ server&.close unless server&.closed?
68
+ end
69
+
70
+ def authorization_url(redirect_uri:, code_challenge:, state:)
71
+ query = URI.encode_www_form(
72
+ code: "true",
73
+ client_id: client_id,
74
+ response_type: "code",
75
+ redirect_uri: redirect_uri,
76
+ scope: SCOPE,
77
+ code_challenge: code_challenge,
78
+ code_challenge_method: "S256",
79
+ state: state
80
+ )
81
+ "#{AUTHORIZE_URL}?#{query}"
82
+ end
83
+
84
+ def start_login_flow
85
+ pkce = generate_pkce
86
+ state = pkce[:verifier]
87
+ server = start_callback_server
88
+ redirect_uri = "http://localhost:#{server.addr[1]}#{CALLBACK_PATH}"
89
+ {
90
+ pkce: pkce,
91
+ state: state,
92
+ server: server,
93
+ redirect_uri: redirect_uri,
94
+ authorization_url: authorization_url(redirect_uri: redirect_uri, code_challenge: pkce[:challenge], state: state)
95
+ }
96
+ end
97
+
98
+ def wait_for_login_callback(server, expected_state:, timeout_seconds:)
99
+ wait_for_callback(server, expected_state: expected_state, timeout_seconds: timeout_seconds)
100
+ end
101
+
102
+ def complete_login_flow(code:, redirect_uri:, code_verifier:)
103
+ tokens = exchange_code_for_tokens(code: code, redirect_uri: redirect_uri, code_verifier: code_verifier)
104
+ save_auth(tokens: tokens)
105
+ tokens
106
+ end
107
+
108
+ def authorization_code_from(input, expected_state: nil)
109
+ value = input.strip
110
+ return "" if value.empty?
111
+
112
+ uri = URI.parse(value)
113
+ params = URI.decode_www_form(uri.query.to_s).to_h
114
+ if params.key?("code")
115
+ raise "OAuth state mismatch" if expected_state && params["state"].to_s != expected_state.to_s
116
+
117
+ return params["code"]
118
+ end
119
+
120
+ if value.include?("#")
121
+ code, state = value.split("#", 2)
122
+ raise "OAuth state mismatch" if expected_state && !state.to_s.empty? && state != expected_state
123
+
124
+ return code
125
+ end
126
+
127
+ value
128
+ rescue URI::InvalidURIError
129
+ value
130
+ end
131
+
132
+ def save_auth(tokens: {})
133
+ data = {
134
+ "auth_mode" => "anthropic_oauth",
135
+ "tokens" => tokens,
136
+ "saved_at" => Time.now.utc.iso8601,
137
+ "expires_at" => expires_at_for(tokens)
138
+ }.compact
139
+
140
+ AuthFile.write_json(@auth_path, data)
141
+ end
142
+
143
+ # Performs refresh for Anthropic OAuth credentials.
144
+ def refresh!
145
+ auth = load_auth || raise("Anthropic OAuth login not found")
146
+ refresh_token = auth.fetch("tokens", {}).fetch("refresh_token", nil)
147
+ raise "Anthropic OAuth refresh token not found" if refresh_token.to_s.empty?
148
+
149
+ response = post_json(TOKEN_URL,
150
+ grant_type: "refresh_token",
151
+ client_id: client_id,
152
+ refresh_token: refresh_token)
153
+ refreshed = parse_successful_json(response, "Anthropic OAuth token refresh")
154
+ save_auth(tokens: auth.fetch("tokens", {}).merge(refreshed))
155
+ load_auth
156
+ end
157
+
158
+ private
159
+
160
+ def current_auth
161
+ auth = load_auth
162
+ tokens = auth&.fetch("tokens", {}) || {}
163
+ return nil if tokens.empty?
164
+
165
+ if token_expired?(auth) && !tokens["refresh_token"].to_s.empty?
166
+ auth = refresh!
167
+ end
168
+
169
+ auth
170
+ end
171
+
172
+ def load_auth
173
+ return nil unless File.exist?(@auth_path)
174
+
175
+ JSON.parse(File.read(@auth_path))
176
+ rescue JSON::ParserError
177
+ nil
178
+ end
179
+
180
+ def client_id
181
+ return @client_id unless @client_id.to_s.strip.empty?
182
+
183
+ value = ENV["ANTHROPIC_OAUTH_CLIENT_ID"].to_s.strip
184
+ return value unless value.empty?
185
+
186
+ value = ConfigFiles.config_value(ConfigFiles.read_config(@config_path), "anthropic_oauth_client_id")
187
+ return value unless value.to_s.empty?
188
+
189
+ DEFAULT_CLIENT_ID
190
+ end
191
+
192
+ def generate_pkce
193
+ verifier = random_urlsafe(64)
194
+ challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
195
+ { verifier: verifier, challenge: challenge }
196
+ end
197
+
198
+ def random_urlsafe(bytes)
199
+ Base64.urlsafe_encode64(SecureRandom.random_bytes(bytes), padding: false)
200
+ end
201
+
202
+ def start_callback_server
203
+ TCPServer.new("localhost", Integer(ENV.fetch("KWARD_ANTHROPIC_OAUTH_PORT", DEFAULT_PORT)))
204
+ rescue Errno::EADDRINUSE
205
+ TCPServer.new("localhost", 0)
206
+ end
207
+
208
+ def wait_for_callback(server, expected_state:, timeout_seconds:)
209
+ ready = IO.select([server], nil, nil, timeout_seconds)
210
+ return nil unless ready
211
+
212
+ socket = server.accept
213
+ request_line = socket.gets.to_s
214
+ path = request_line.split[1].to_s
215
+ params = URI.decode_www_form(URI.parse(path).query.to_s).to_h
216
+
217
+ code = nil
218
+ status = "200 OK"
219
+ body = "Login complete. You can close this window."
220
+ if params["error"]
221
+ body = "Login failed. Return to the terminal."
222
+ elsif params["state"].to_s != expected_state.to_s
223
+ status = "400 Bad Request"
224
+ body = "Invalid OAuth state. Return to the terminal."
225
+ else
226
+ code = params["code"]
227
+ end
228
+
229
+ socket.write("HTTP/1.1 #{status}\r\nContent-Type: text/plain\r\nContent-Length: #{body.bytesize}\r\n\r\n#{body}")
230
+ code
231
+ rescue URI::InvalidURIError
232
+ nil
233
+ ensure
234
+ socket&.close
235
+ end
236
+
237
+ def exchange_code_for_tokens(code:, redirect_uri:, code_verifier:)
238
+ response = post_json(TOKEN_URL,
239
+ grant_type: "authorization_code",
240
+ client_id: client_id,
241
+ code: code,
242
+ redirect_uri: redirect_uri,
243
+ code_verifier: code_verifier)
244
+ parse_successful_json(response, "Anthropic OAuth token exchange")
245
+ end
246
+
247
+ def post_json(uri, params)
248
+ request = Net::HTTP::Post.new(uri)
249
+ request["Content-Type"] = "application/json"
250
+ request["Accept"] = "application/json"
251
+ request.body = JSON.dump(params)
252
+
253
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
254
+ end
255
+
256
+ def token_expired?(auth)
257
+ expires_at = auth&.fetch("expires_at", nil)
258
+ return false unless expires_at
259
+
260
+ Time.parse(expires_at) <= Time.now.utc + 60
261
+ rescue ArgumentError
262
+ false
263
+ end
264
+
265
+ def expires_at_for(tokens)
266
+ expires_in = tokens["expires_in"] || tokens[:expires_in]
267
+ return tokens["expires_at"] || tokens[:expires_at] unless expires_in
268
+
269
+ (Time.now.utc + expires_in.to_i).iso8601
270
+ end
271
+
272
+ def parse_successful_json(response, label)
273
+ raise "#{label} failed with HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
274
+
275
+ JSON.parse(response.body)
276
+ rescue JSON::ParserError
277
+ raise "#{label} returned invalid JSON"
278
+ end
279
+
280
+ def open_url(url)
281
+ command = if RUBY_PLATFORM.match?(/darwin/)
282
+ "open"
283
+ elsif RUBY_PLATFORM.match?(/linux/)
284
+ "xdg-open"
285
+ end
286
+ return false unless command
287
+
288
+ system(command, url, out: File::NULL, err: File::NULL)
289
+ end
290
+ end
291
+ end
@@ -1,6 +1,8 @@
1
1
  require_relative "../private_file"
2
2
 
3
+ # Namespace for the Kward CLI agent runtime.
3
4
  module Kward
5
+ # Shared private-file storage helpers for auth credentials.
4
6
  module AuthFile
5
7
  module_function
6
8
 
@@ -5,7 +5,9 @@ require "uri"
5
5
  require_relative "file"
6
6
  require_relative "../config_files"
7
7
 
8
+ # Namespace for the Kward CLI agent runtime.
8
9
  module Kward
10
+ # OAuth helper for GitHub Copilot credentials.
9
11
  class GithubOAuth
10
12
  DEVICE_CODE_URL = URI("https://github.com/login/device/code")
11
13
  TOKEN_URL = URI("https://github.com/login/oauth/access_token")
@@ -21,6 +23,7 @@ module Kward
21
23
 
22
24
  attr_reader :auth_path
23
25
 
26
+ # Creates an object for GitHub Copilot OAuth credentials.
24
27
  def initialize(auth_path: GithubOAuth.default_auth_path, config_path: ConfigFiles.config_path)
25
28
  @auth_path = File.expand_path(auth_path)
26
29
  @config_path = File.expand_path(config_path)
@@ -8,7 +8,9 @@ require "time"
8
8
  require "uri"
9
9
  require_relative "file"
10
10
 
11
+ # Namespace for the Kward CLI agent runtime.
11
12
  module Kward
13
+ # OAuth helper for ChatGPT/OpenAI Codex credentials.
12
14
  class OpenAIOAuth
13
15
  ISSUER = "https://auth.openai.com"
14
16
  TOKEN_URL = URI("#{ISSUER}/oauth/token")
@@ -19,6 +21,7 @@ module Kward
19
21
 
20
22
  attr_reader :auth_path
21
23
 
24
+ # Creates an object for OpenAI OAuth credentials.
22
25
  def initialize(auth_path: OpenAIOAuth.default_auth_path, client_id: nil, config_path: OpenAIOAuth.default_config_path, issuer: ISSUER)
23
26
  @auth_path = File.expand_path(auth_path)
24
27
  @client_id = client_id
@@ -143,6 +146,7 @@ module Kward
143
146
  AuthFile.write_json(@auth_path, data)
144
147
  end
145
148
 
149
+ # Performs refresh for OpenAI OAuth credentials.
146
150
  def refresh!
147
151
  auth = load_auth || raise("OpenAI OAuth login not found")
148
152
  refresh_token = auth.fetch("tokens", {}).fetch("refresh_token", nil)
@@ -1,7 +1,9 @@
1
1
  require_relative "../config_files"
2
2
  require_relative "openai_oauth"
3
3
 
4
+ # Namespace for the Kward CLI agent runtime.
4
5
  module Kward
6
+ # Config helper for storing and removing OpenRouter API keys.
5
7
  class OpenRouterAPIKey
6
8
  attr_reader :config_path
7
9
 
@@ -1,7 +1,10 @@
1
1
  require "thread"
2
2
 
3
+ # Namespace for the Kward CLI agent runtime.
3
4
  module Kward
5
+ # Cooperative cancellation token shared by model calls, tools, and workers.
4
6
  class Cancellation
7
+ # Cooperative cancellation token shared by model calls, tools, and workers.
5
8
  class CancelledError < StandardError; end
6
9
 
7
10
  def initialize
@@ -0,0 +1,82 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
4
+ class CLI
5
+ # Login, logout, and credential-status commands mixed into the CLI frontend.
6
+ module AuthCommands
7
+ def handle_auth_command(arguments)
8
+ if help_option_arguments?(arguments)
9
+ print_command_help("auth")
10
+ return
11
+ end
12
+
13
+ case arguments
14
+ when ["status"]
15
+ print_auth_status
16
+ when ["logout"]
17
+ logout_auth
18
+ else
19
+ raise ArgumentError, command_usage("auth")
20
+ end
21
+ end
22
+
23
+ # Writes the auth status output for the terminal CLI flow.
24
+ def print_auth_status
25
+ config = safely_read_config.to_h
26
+ lines = ["#{colored("Auth Status", :green, :bold)}", ""]
27
+ lines << auth_status_line("OpenAI OAuth", File.exist?(OpenAIOAuth.default_auth_path), OpenAIOAuth.default_auth_path)
28
+ lines << auth_status_line("Anthropic OAuth", File.exist?(AnthropicOAuth.default_auth_path), AnthropicOAuth.default_auth_path)
29
+ 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
+ @prompt.say lines.join("\n")
32
+ end
33
+
34
+ def auth_status_line(label, configured, location)
35
+ status = configured ? :ok : :warning
36
+ message = configured ? "configured" : "not configured"
37
+ "#{doctor_mark(status)} #{label}: #{message} (#{location})"
38
+ end
39
+
40
+ def logout_auth
41
+ removed = []
42
+ [OpenAIOAuth.default_auth_path, AnthropicOAuth.default_auth_path, GithubOAuth.default_auth_path].each do |path|
43
+ next unless File.exist?(path)
44
+
45
+ File.delete(path)
46
+ removed << path
47
+ end
48
+ removed << "OpenRouter API key" if OpenRouterAPIKey.new.logout
49
+
50
+ if removed.empty?
51
+ @prompt.say "No saved credentials found."
52
+ else
53
+ @prompt.say "Removed #{removed.length} saved credential#{removed.length == 1 ? "" : "s"}."
54
+ end
55
+ end
56
+
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}")
63
+ return
64
+ end
65
+
66
+ oauth ||= case provider
67
+ when "github" then GithubOAuth.new
68
+ when "anthropic", "claude" then AnthropicOAuth.new
69
+ else OpenAIOAuth.new
70
+ end
71
+ path = oauth.login(prompt: @prompt)
72
+ name = case provider
73
+ when "github" then "GitHub"
74
+ when "anthropic", "claude" then "Anthropic"
75
+ else "OpenAI"
76
+ end
77
+ @prompt.say("#{colored("Saved", :green, :bold)} #{name} OAuth login to #{path}")
78
+ end
79
+
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,222 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
4
+ class CLI
5
+ # Top-level command help, option parsing, and command dispatch helpers mixed into the CLI frontend.
6
+ module Commands
7
+ private
8
+
9
+ def help_command?
10
+ ["help", "--help", "-h"].include?(@argv.first) && @argv.length <= 2
11
+ end
12
+
13
+ def version_command?
14
+ ["version", "--version", "-v"].include?(@argv.first) && @argv.length == 1
15
+ end
16
+
17
+ def help_option_arguments?(arguments)
18
+ arguments.length == 1 && ["help", "--help", "-h"].include?(arguments.first)
19
+ end
20
+
21
+ def one_shot_prompt_argument
22
+ prompt = @argv.join(" ").strip
23
+ prompt.empty? ? nil : prompt
24
+ end
25
+
26
+ # Writes the command help output for the terminal CLI flow.
27
+ def print_command_help(command_name = nil)
28
+ if command_name.to_s.empty? || ["--help", "-h"].include?(command_name)
29
+ print_help
30
+ return
31
+ end
32
+
33
+ help = command_help[command_name]
34
+ raise ArgumentError, "Unknown command: #{command_name}" unless help
35
+
36
+ @prompt.say render_command_help(command_name, help)
37
+ end
38
+
39
+ # Writes the help output for the terminal CLI flow.
40
+ def print_help
41
+ command = ->(text) { colored(text, :green, :bold) }
42
+ option = ->(text) { colored(text, :cyan) }
43
+ heading = ->(text) { colored(text, :blue, :bold) }
44
+
45
+ @prompt.say <<~HELP.rstrip
46
+ #{colored("Kward", :green, :bold)} - an extendable CLI coding agent
47
+
48
+ #{heading.call("Usage")}
49
+ #{command.call("kward")} Start an interactive chat
50
+ #{command.call("kward")} #{option.call('"Explain this project"')} Run a one-shot prompt
51
+ #{command.call("kward login")} Sign in or save provider credentials
52
+ #{command.call("kward auth status")} Show saved credential status
53
+ #{command.call("kward init")} Install starter prompts and AGENTS.md
54
+ #{command.call("kward doctor")} Check local Kward setup
55
+ #{command.call("kward pan")} Start Pan mode web UI
56
+ #{command.call("kward rpc")} Start the experimental JSON-RPC backend
57
+
58
+ #{heading.call("Commands")}
59
+ #{command.call("help")} Show this help
60
+ #{command.call("version")} Show the installed Kward version
61
+ #{command.call("login")} [anthropic|openrouter|github] Sign in with OpenAI, Anthropic, OpenRouter, or GitHub
62
+ #{command.call("auth status|logout")} Show or clear saved credentials
63
+ #{command.call("init")} Install starter prompts and AGENTS.md
64
+ #{command.call("doctor")} Check local Kward setup
65
+ #{command.call("stats tokens")} [range] [options] Export local token telemetry as CSV
66
+ #{command.call("pan")} Start Pan mode web UI
67
+ #{command.call("rpc")} Run the JSON-RPC backend for UI clients
68
+
69
+ #{heading.call("Options")}
70
+ #{option.call("--working-directory=PATH")} Run Kward from PATH
71
+ #{option.call("--help")}, #{option.call("-h")} Show this help
72
+ #{option.call("--version")}, #{option.call("-v")} Show the installed version
73
+
74
+ #{heading.call("Examples")}
75
+ #{command.call("kward")}
76
+ #{command.call("kward")} #{option.call('"Review this diff"')}
77
+ #{command.call("git diff | kward")} #{option.call('"Review this diff"')}
78
+ #{command.call("kward login openrouter")}
79
+ #{command.call("kward stats tokens today --bucket hour")}
80
+
81
+ Command names take precedence. Anything else is sent as a one-shot prompt.
82
+ HELP
83
+ end
84
+
85
+ def command_help
86
+ {
87
+ "help" => {
88
+ usage: "kward help [command]",
89
+ description: "Show the top-level command overview or help for one command.",
90
+ examples: ["kward help", "kward help pan"]
91
+ },
92
+ "version" => {
93
+ usage: "kward version",
94
+ description: "Show the installed Kward version.",
95
+ examples: ["kward version", "kward --version"]
96
+ },
97
+ "login" => {
98
+ usage: "kward login [anthropic|openrouter|github]",
99
+ description: "Sign in with OpenAI, Anthropic, OpenRouter, or GitHub.",
100
+ examples: ["kward login", "kward login anthropic", "kward login openrouter", "kward login github"]
101
+ },
102
+ "auth" => {
103
+ usage: "kward auth status|logout",
104
+ description: "Show or clear saved provider credentials without printing secrets.",
105
+ examples: ["kward auth status", "kward auth logout"]
106
+ },
107
+ "init" => {
108
+ usage: "kward init",
109
+ description: "Install starter prompts and base AGENTS.md into your config directory.",
110
+ examples: ["kward init"]
111
+ },
112
+ "doctor" => {
113
+ usage: "kward doctor",
114
+ description: "Check local Kward configuration, workspace, auth hints, and writable directories.",
115
+ examples: ["kward doctor", "kward --working-directory ~/code/project doctor"]
116
+ },
117
+ "stats" => {
118
+ usage: "kward stats tokens [range] [--bucket second|minute|hour|day|week|month|year] [--output path]",
119
+ description: "Export local token telemetry as CSV.",
120
+ examples: ["kward stats tokens today", "kward stats tokens today --bucket hour", "kward stats tokens week --output tokens.csv"]
121
+ },
122
+ "pan" => {
123
+ usage: "kward pan",
124
+ description: "Start Pan mode, a minimal LAN web UI with a prompt textarea and transcript.",
125
+ examples: ["kward pan", "kward --working-directory ~/code/project pan"]
126
+ },
127
+ "rpc" => {
128
+ usage: "kward rpc",
129
+ description: "Start the experimental JSON-RPC backend for UI clients.",
130
+ examples: ["kward rpc", "kward --working-directory ~/code/project rpc"]
131
+ }
132
+ }
133
+ end
134
+
135
+ def render_command_help(name, help)
136
+ heading = ->(text) { colored(text, :blue, :bold) }
137
+ command = ->(text) { colored(text, :green, :bold) }
138
+
139
+ lines = [
140
+ "#{command.call(name)} - #{help.fetch(:description)}",
141
+ "",
142
+ heading.call("Usage"),
143
+ " #{command.call(help.fetch(:usage))}"
144
+ ]
145
+ examples = help.fetch(:examples, [])
146
+ if examples.any?
147
+ lines << ""
148
+ lines << heading.call("Examples")
149
+ examples.each { |example| lines << " #{command.call(example)}" }
150
+ end
151
+ lines.join("\n")
152
+ end
153
+
154
+ def command_usage(name)
155
+ "Usage: #{command_help.fetch(name).fetch(:usage)}"
156
+ end
157
+
158
+ # Writes the version output for the terminal CLI flow.
159
+ def print_version
160
+ @prompt.say "kward #{VERSION}"
161
+ end
162
+
163
+ def install_starter_pack
164
+ result = StarterPackInstaller.install
165
+ installed_count = result.installed.length
166
+ skipped_count = result.skipped.length
167
+ @prompt.say("Installed #{installed_count} starter pack file#{installed_count == 1 ? "" : "s"}.")
168
+ @prompt.say("Skipped #{skipped_count} existing starter pack file#{skipped_count == 1 ? "" : "s"}.") if skipped_count.positive?
169
+ rescue StandardError => e
170
+ warn "Failed to install starter pack: #{e.message}"
171
+ exit 1
172
+ end
173
+
174
+ def pan_mode?
175
+ @argv.first == "pan"
176
+ end
177
+
178
+ def extract_global_options(arguments)
179
+ remaining = []
180
+ index = 0
181
+ while index < arguments.length
182
+ argument = arguments[index]
183
+ case argument
184
+ when "--"
185
+ @prompt_delimited = true
186
+ remaining.concat(arguments[(index + 1)..] || [])
187
+ break
188
+ when "--working-directory"
189
+ index += 1
190
+ raise ArgumentError, "Missing value for --working-directory" if index >= arguments.length
191
+
192
+ @working_directory = expanded_working_directory(arguments[index])
193
+ when /\A--working-directory=(.*)\z/
194
+ @working_directory = expanded_working_directory(Regexp.last_match(1))
195
+ else
196
+ remaining << argument
197
+ end
198
+ index += 1
199
+ end
200
+ remaining
201
+ end
202
+
203
+ def expanded_working_directory(path)
204
+ value = path.to_s.strip
205
+ raise ArgumentError, "Missing value for --working-directory" if value.empty?
206
+
207
+ expanded = File.expand_path(value)
208
+ raise ArgumentError, "Working directory does not exist: #{expanded}" unless Dir.exist?(expanded)
209
+ raise ArgumentError, "Working directory is not a directory: #{expanded}" unless File.directory?(expanded)
210
+
211
+ expanded
212
+ end
213
+
214
+ def with_working_directory
215
+ return yield unless @working_directory
216
+
217
+ Dir.chdir(@working_directory) { yield }
218
+ end
219
+
220
+ end
221
+ end
222
+ end