kward 0.82.0 → 0.84.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -15
  3. data/CONTRIBUTING.md +74 -0
  4. data/Gemfile.lock +8 -2
  5. data/README.md +21 -1
  6. data/Rakefile +46 -2
  7. data/SECURITY.md +31 -0
  8. data/doc/agent-tools.md +1 -0
  9. data/doc/api.md +4 -0
  10. data/doc/composer.md +2 -2
  11. data/doc/configuration.md +82 -20
  12. data/doc/editor.md +47 -22
  13. data/doc/files.md +12 -7
  14. data/doc/getting-started.md +3 -0
  15. data/doc/pan.md +19 -15
  16. data/doc/permissions.md +1 -0
  17. data/doc/platform-support.md +48 -0
  18. data/doc/releasing.md +12 -4
  19. data/doc/rpc.md +2 -2
  20. data/doc/sandboxing.md +5 -1
  21. data/doc/security.md +4 -3
  22. data/doc/shell.md +101 -60
  23. data/doc/tabs.md +3 -0
  24. data/doc/troubleshooting.md +12 -2
  25. data/doc/usage.md +7 -6
  26. data/kward.gemspec +5 -4
  27. data/lib/kward/agent.rb +7 -3
  28. data/lib/kward/ansi.rb +110 -10
  29. data/lib/kward/auth/anthropic_oauth.rb +7 -7
  30. data/lib/kward/cli/auth_commands.rb +34 -13
  31. data/lib/kward/cli/commands.rb +83 -62
  32. data/lib/kward/cli/doctor.rb +39 -17
  33. data/lib/kward/cli/hook_commands.rb +22 -12
  34. data/lib/kward/cli/interactive_turn.rb +144 -21
  35. data/lib/kward/cli/project_skills_commands.rb +8 -4
  36. data/lib/kward/cli/prompt_interface.rb +27 -0
  37. data/lib/kward/cli/rendering.rb +15 -9
  38. data/lib/kward/cli/runtime_helpers.rb +260 -55
  39. data/lib/kward/cli/slash_commands.rb +21 -12
  40. data/lib/kward/cli/tabs.rb +91 -13
  41. data/lib/kward/cli/tool_summaries.rb +14 -0
  42. data/lib/kward/cli.rb +55 -7
  43. data/lib/kward/cli_transcript_formatter.rb +11 -4
  44. data/lib/kward/compaction/token_estimator.rb +12 -6
  45. data/lib/kward/config_files.rb +94 -63
  46. data/lib/kward/detached_run.rb +44 -0
  47. data/lib/kward/editor_prompt.rb +46 -0
  48. data/lib/kward/editor_prompt_session.rb +28 -0
  49. data/lib/kward/interactive_pty_runner.rb +102 -28
  50. data/lib/kward/{ekwsh.rb → kwsh.rb} +103 -24
  51. data/lib/kward/kwshrc.rb +233 -0
  52. data/lib/kward/markdown_code_block.rb +136 -0
  53. data/lib/kward/model/client.rb +35 -39
  54. data/lib/kward/model/model_info.rb +3 -2
  55. data/lib/kward/model/payloads.rb +0 -2
  56. data/lib/kward/model/provider_catalog.rb +5 -0
  57. data/lib/kward/model/stream_parser.rb +20 -4
  58. data/lib/kward/pan/index.html.erb +3 -3
  59. data/lib/kward/pan/server.rb +23 -3
  60. data/lib/kward/persistent_shell_session.rb +843 -0
  61. data/lib/kward/project_files.rb +19 -6
  62. data/lib/kward/prompt_interface/composer_renderer.rb +45 -41
  63. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  64. data/lib/kward/prompt_interface/editor/auto_indent.rb +27 -22
  65. data/lib/kward/prompt_interface/editor/controller.rb +99 -33
  66. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  67. data/lib/kward/prompt_interface/editor/modes/modern.rb +4 -0
  68. data/lib/kward/prompt_interface/editor/modes/vibe.rb +298 -47
  69. data/lib/kward/prompt_interface/editor/renderer.rb +109 -6
  70. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  71. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  72. data/lib/kward/prompt_interface/editor/state.rb +21 -10
  73. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  74. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  75. data/lib/kward/prompt_interface/editor/word_completion.rb +124 -0
  76. data/lib/kward/prompt_interface/file_overlay.rb +92 -22
  77. data/lib/kward/prompt_interface/key_handler.rb +75 -0
  78. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  79. data/lib/kward/prompt_interface/project_browser.rb +443 -17
  80. data/lib/kward/prompt_interface/runtime_state.rb +55 -2
  81. data/lib/kward/prompt_interface/screen.rb +2 -2
  82. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  83. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  84. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  85. data/lib/kward/prompt_interface.rb +169 -18
  86. data/lib/kward/prompts/commands.rb +2 -1
  87. data/lib/kward/prompts.rb +1 -1
  88. data/lib/kward/pty_output_sink.rb +47 -0
  89. data/lib/kward/rpc/auth_manager.rb +1 -1
  90. data/lib/kward/rpc/server.rb +2 -1
  91. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  92. data/lib/kward/scratchpad_languages.rb +74 -0
  93. data/lib/kward/scratchpad_runner.rb +155 -29
  94. data/lib/kward/shell_prompt.rb +52 -0
  95. data/lib/kward/shell_prompt_session.rb +58 -0
  96. data/lib/kward/terminal_keys.rb +13 -0
  97. data/lib/kward/terminal_text.rb +121 -0
  98. data/lib/kward/text_matcher.rb +18 -0
  99. data/lib/kward/tools/open_editor.rb +41 -0
  100. data/lib/kward/tools/prepare_shell_command.rb +28 -0
  101. data/lib/kward/tools/registry.rb +75 -8
  102. data/lib/kward/tools/replace_editor_buffer.rb +30 -0
  103. data/lib/kward/tools/run_shell_command.rb +24 -6
  104. data/lib/kward/tools/tool_call.rb +2 -1
  105. data/lib/kward/version.rb +1 -1
  106. data/templates/default/fulldoc/html/css/kward.css +0 -125
  107. data/templates/default/fulldoc/html/images/kward_workflow.svg +52 -0
  108. data/templates/default/fulldoc/html/setup.rb +1 -1
  109. data/templates/default/kward_navigation.rb +1 -0
  110. data/templates/default/layout/html/footer.erb +10 -0
  111. data/templates/default/layout/html/headers.erb +23 -0
  112. data/templates/default/layout/html/layout.erb +6 -18
  113. data/templates/default/layout/html/setup.rb +41 -2
  114. metadata +44 -8
  115. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
data/lib/kward/ansi.rb CHANGED
@@ -23,6 +23,12 @@ module Kward
23
23
  gray: 90,
24
24
  grey: 90,
25
25
  white: 97,
26
+ activity: 36,
27
+ success: 32,
28
+ caution: 33,
29
+ failure: 31,
30
+ tool: 35,
31
+ metadata: 90,
26
32
  primary_green: "38;2;138;160;106",
27
33
  bright_accent_green: "38;2;155;255;0",
28
34
  augen: "38;2;155;255;0",
@@ -181,23 +187,30 @@ module Kward
181
187
  @enabled = enabled
182
188
  @pending = +""
183
189
  @in_fence = false
190
+ @line_streaming = false
184
191
  end
185
192
 
186
193
  def render(delta, final: false)
187
- text = delta.to_s
188
- return ANSI.markdown(text, enabled: @enabled) if fast_markdown?(text, final)
189
-
190
- @pending << text
194
+ @pending << delta.to_s
191
195
  rendered = +""
192
196
  while (match = @pending.match(/\r\n|\r|\n/))
193
197
  line = @pending[0...match.begin(0)]
194
198
  @pending = @pending[(match.end(0))..] || +""
195
- rendered << render_line(line) << "\n"
199
+ rendered << render_pending_line(line) << "\n"
196
200
  end
197
201
 
198
- if final && !@pending.empty?
199
- rendered << render_line(@pending)
200
- @pending.clear
202
+ if final
203
+ unless @pending.empty?
204
+ rendered << render_pending_line(@pending)
205
+ @pending.clear
206
+ end
207
+ else
208
+ streamable = streamable_inline_prefix
209
+ unless streamable.empty?
210
+ rendered << ANSI.inline_markdown(streamable, enabled: @enabled)
211
+ @pending = @pending[streamable.length..] || +""
212
+ @line_streaming = true
213
+ end
201
214
  end
202
215
 
203
216
  if final && @in_fence
@@ -211,8 +224,95 @@ module Kward
211
224
 
212
225
  private
213
226
 
214
- def fast_markdown?(text, final)
215
- !final && !@in_fence && @pending.empty? && !text.match?(/[`*~_\[\]>]/)
227
+ def render_pending_line(line)
228
+ if @line_streaming
229
+ @line_streaming = false
230
+ ANSI.inline_markdown(line, enabled: @enabled)
231
+ else
232
+ render_line(line)
233
+ end
234
+ end
235
+
236
+ def streamable_inline_prefix
237
+ return "" if @pending.empty? || block_line_pending?
238
+
239
+ boundary = [
240
+ unmatched_marker_index("`"),
241
+ unmatched_marker_index("**"),
242
+ unmatched_marker_index("~~"),
243
+ unmatched_emphasis_index("*"),
244
+ unmatched_emphasis_index("_"),
245
+ incomplete_marker_prefix_index,
246
+ incomplete_link_index
247
+ ].compact.min
248
+ boundary ? @pending[0...boundary] : @pending.dup
249
+ end
250
+
251
+ def unmatched_marker_index(marker)
252
+ unmatched = nil
253
+ offset = 0
254
+ while (index = @pending.index(marker, offset))
255
+ unmatched = unmatched ? nil : index
256
+ offset = index + marker.length
257
+ end
258
+ unmatched
259
+ end
260
+
261
+ def unmatched_emphasis_index(marker)
262
+ unmatched = nil
263
+ offset = 0
264
+ while (index = @pending.index(marker, offset))
265
+ previous = index.positive? ? @pending[index - 1] : nil
266
+ following = @pending[index + 1]
267
+ offset = index + 1
268
+ next if previous == marker || following == marker
269
+
270
+ if unmatched
271
+ unmatched = nil if following.nil? || following.match?(/[\s\)\]},.!?:;]/)
272
+ elsif previous.nil? || previous.match?(/[\s\(\[{]/)
273
+ unmatched = index
274
+ end
275
+ end
276
+ unmatched
277
+ end
278
+
279
+ def incomplete_marker_prefix_index
280
+ trailing = @pending[/~+\z/]
281
+ return nil unless trailing&.length&.odd?
282
+
283
+ @pending.length - 1
284
+ end
285
+
286
+ def incomplete_link_index
287
+ offset = 0
288
+ while (opening = @pending.index("[", offset))
289
+ closing = @pending.index("]", opening + 1)
290
+ return opening unless closing
291
+ return opening if closing == @pending.length - 1
292
+
293
+ unless @pending[closing + 1] == "("
294
+ offset = closing + 1
295
+ next
296
+ end
297
+
298
+ closing_parenthesis = @pending.index(")", closing + 2)
299
+ return opening unless closing_parenthesis
300
+
301
+ offset = closing_parenthesis + 1
302
+ end
303
+ nil
304
+ end
305
+
306
+ def block_line_pending?
307
+ return true if @in_fence
308
+ return false if @line_streaming
309
+
310
+ line = @pending.lstrip
311
+ line.start_with?("```") || line.match?(/\A`{1,2}\z/) ||
312
+ line.match?(/\A\#{1,6}(?:\s|\z)/) ||
313
+ line.start_with?(">") ||
314
+ line.match?(/\A[-*]\s+\[[ xX]\](?:\s|\z)/) ||
315
+ line.match?(/\A[-*](?:\s*\z|\s+\[[ xX]?\z)/)
216
316
  end
217
317
 
218
318
  def render_line(line)
@@ -17,7 +17,7 @@ module Kward
17
17
  DEFAULT_PORT = 53_692
18
18
  CALLBACK_PATH = "/callback"
19
19
  DEFAULT_CLIENT_ID = Base64.decode64("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl")
20
- SCOPE = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"
20
+ SCOPE = "user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"
21
21
 
22
22
  attr_reader :auth_path
23
23
 
@@ -60,7 +60,7 @@ module Kward
60
60
  end
61
61
  raise "Missing authorization code" if code.to_s.empty?
62
62
 
63
- complete_login_flow(code: code, redirect_uri: redirect_uri, code_verifier: pkce[:verifier])
63
+ complete_login_flow(code: code, redirect_uri: redirect_uri, code_verifier: pkce[:verifier], state: state)
64
64
  auth_path
65
65
  ensure
66
66
  server&.close unless server&.closed?
@@ -68,7 +68,6 @@ module Kward
68
68
 
69
69
  def authorization_url(redirect_uri:, code_challenge:, state:)
70
70
  query = URI.encode_www_form(
71
- code: "true",
72
71
  client_id: client_id,
73
72
  response_type: "code",
74
73
  redirect_uri: redirect_uri,
@@ -98,8 +97,8 @@ module Kward
98
97
  wait_for_callback(server, expected_state: expected_state, timeout_seconds: timeout_seconds)
99
98
  end
100
99
 
101
- def complete_login_flow(code:, redirect_uri:, code_verifier:)
102
- tokens = exchange_code_for_tokens(code: code, redirect_uri: redirect_uri, code_verifier: code_verifier)
100
+ def complete_login_flow(code:, redirect_uri:, code_verifier:, state:)
101
+ tokens = exchange_code_for_tokens(code: code, redirect_uri: redirect_uri, code_verifier: code_verifier, state: state)
103
102
  save_auth(tokens: tokens)
104
103
  tokens
105
104
  end
@@ -192,13 +191,14 @@ module Kward
192
191
  oauth_callback_server(port_env: "KWARD_ANTHROPIC_OAUTH_PORT", default_port: DEFAULT_PORT)
193
192
  end
194
193
 
195
- def exchange_code_for_tokens(code:, redirect_uri:, code_verifier:)
194
+ def exchange_code_for_tokens(code:, redirect_uri:, code_verifier:, state:)
196
195
  response = post_json(TOKEN_URL,
197
196
  grant_type: "authorization_code",
198
197
  client_id: client_id,
199
198
  code: code,
200
199
  redirect_uri: redirect_uri,
201
- code_verifier: code_verifier)
200
+ code_verifier: code_verifier,
201
+ state: state)
202
202
  parse_successful_json(response, "Anthropic OAuth token exchange")
203
203
  end
204
204
 
@@ -13,6 +13,8 @@ module Kward
13
13
  case arguments
14
14
  when ["status"]
15
15
  print_auth_status
16
+ when ["status", "--all"]
17
+ print_auth_status(show_all: true)
16
18
  when ["logout"]
17
19
  logout_auth
18
20
  else
@@ -21,23 +23,42 @@ module Kward
21
23
  end
22
24
 
23
25
  # Writes the auth status output for the terminal CLI flow.
24
- def print_auth_status
25
- store = api_key_store
26
- store.migrate_openrouter_config_key!
27
- lines = ["#{colored("Auth Status", :green, :bold)}", ""]
28
- lines << auth_status_line("OpenAI OAuth", File.exist?(OpenAIOAuth.default_auth_path), OpenAIOAuth.default_auth_path)
29
- lines << auth_status_line("Anthropic OAuth", File.exist?(AnthropicOAuth.default_auth_path), AnthropicOAuth.default_auth_path)
30
- lines << auth_status_line("GitHub OAuth", File.exist?(GithubOAuth.default_auth_path), GithubOAuth.default_auth_path)
31
- ProviderCatalog.api_key_providers.each do |provider|
32
- lines << auth_status_line("#{provider.name} API key", store.configured?(provider.id), store.path)
26
+ def print_auth_status(show_all: false)
27
+ credentials = auth_credentials
28
+ configured, missing = credentials.partition { |credential| credential.fetch(:configured) }
29
+ lines = [colored("Authentication", :green, :bold), "", colored("Configured", :blue, :bold)]
30
+ lines.concat(auth_credential_lines(configured, status: :ok, empty_message: "None"))
31
+
32
+ if show_all
33
+ lines << ""
34
+ lines << colored("Not configured", :blue, :bold)
35
+ lines.concat(auth_credential_lines(missing, status: :optional, empty_message: "None"))
36
+ elsif missing.any?
37
+ lines << ""
38
+ lines << "#{missing.length} other provider#{missing.length == 1 ? " is" : "s are"} not configured. Run `kward auth status --all` for details."
33
39
  end
40
+
41
+ lines << ""
42
+ lines << colored("Credential directory", :blue, :bold)
43
+ lines << " #{ConfigFiles.config_dir}"
34
44
  @prompt.say lines.join("\n")
35
45
  end
36
46
 
37
- def auth_status_line(label, configured, location)
38
- status = configured ? :ok : :warning
39
- message = configured ? "configured" : "not configured"
40
- "#{doctor_mark(status)} #{label}: #{message} (#{location})"
47
+ def auth_credentials
48
+ store = api_key_store
49
+ store.migrate_openrouter_config_key!
50
+ credentials = [
51
+ { label: "OpenAI OAuth", configured: File.exist?(OpenAIOAuth.default_auth_path) },
52
+ { label: "Anthropic OAuth", configured: File.exist?(AnthropicOAuth.default_auth_path) },
53
+ { label: "GitHub OAuth", configured: File.exist?(GithubOAuth.default_auth_path) }
54
+ ]
55
+ credentials.concat ProviderCatalog.api_key_providers.map { |provider| { label: "#{provider.name} API key", configured: store.configured?(provider.id) } }
56
+ end
57
+
58
+ def auth_credential_lines(credentials, status:, empty_message:)
59
+ return [" #{empty_message}"] if credentials.empty?
60
+
61
+ credentials.map { |credential| " #{doctor_mark(status)} #{credential.fetch(:label)}" }
41
62
  end
42
63
 
43
64
  def logout_auth
@@ -38,67 +38,88 @@ module Kward
38
38
 
39
39
  # Writes the help output for the terminal CLI flow.
40
40
  def print_help
41
- command = ->(text) { colored(text, :green, :bold) }
42
- option = ->(text) { colored(text, :cyan) }
43
41
  heading = ->(text) { colored(text, :blue, :bold) }
42
+ lines = ["#{colored("Kward", :green, :bold)} - an extensible CLI coding agent", ""]
44
43
 
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 --filter")} #{option.call('"Translate"')} Filter stdin with an instruction
52
- #{command.call("kward login")} Sign in or save provider credentials
53
- #{command.call("kward auth status")} Show saved credential status
54
- #{command.call("kward init")} Install starter prompts and PRINCIPLES.md
55
- #{command.call("kward doctor")} Check local Kward setup
56
- #{command.call("kward hooks doctor")} Inspect lifecycle hook setup
57
- #{command.call("kward skills status")} Inspect project skill trust
58
- #{command.call("kward edit")} #{option.call("<filename>")} Open a file in the integrated editor
59
- #{command.call("kward sysprompt")} Inspect the effective system prompt
60
- #{command.call("kward openrouter refresh")} Refresh cached OpenRouter models
61
- #{command.call("kward pan")} Start Pan mode web UI
62
- #{command.call("kward rpc")} Start the JSON-RPC backend
63
- #{command.call("kward transport")} Manage transport plugins
64
-
65
- #{heading.call("Commands")}
66
- #{command.call("help")} Show this help
67
- #{command.call("version")} Show the installed Kward version
68
- #{command.call("login")} [anthropic|openrouter|github] Sign in with OpenAI, Anthropic, OpenRouter, or GitHub
69
- #{command.call("auth status|logout")} Show or clear saved credentials
70
- #{command.call("init")} Install starter prompts and PRINCIPLES.md
71
- #{command.call("doctor")} Check local Kward setup
72
- #{command.call("hooks list|events|logs|doctor|trust|untrust")} Inspect lifecycle hooks
73
- #{command.call("skills status|trust|untrust|review")} Manage project skill trust
74
- #{command.call("edit")} #{option.call("<filename>")} Open a file in the integrated editor
75
- #{command.call("sysprompt")} [--raw] Inspect the effective system prompt
76
- #{command.call("stats tokens")} [range] [options] Export local token telemetry as CSV
77
- #{command.call("openrouter refresh|list")} Refresh or list cached OpenRouter models
78
- #{command.call("pan")} Start Pan mode web UI
79
- #{command.call("rpc")} Run the JSON-RPC backend for UI clients
80
- #{command.call("transport list|status|run")} Manage transport plugins
81
-
82
- #{heading.call("Options")}
83
- #{option.call("--working-directory=PATH")} Run Kward from PATH
84
- #{option.call("--mode=MODE")} Execution mode: auto, chat, oneshot, filter
85
- #{option.call("--filter")} Shortcut for --mode filter
86
- #{option.call("--skip-config")} Ignore the main config file for this run
87
- #{option.call("--help")}, #{option.call("-h")} Show this help
88
- #{option.call("--version")}, #{option.call("-v")} Show the installed version
89
-
90
- #{heading.call("Examples")}
91
- #{command.call("kward")}
92
- #{command.call("kward")} #{option.call('"Explain this project"')}
93
- #{command.call("git diff | kward")} #{option.call('"Summarize the main changes"')}
94
- #{command.call("echo Hello | kward --filter")} #{option.call('"Translate to German"')}
95
- #{command.call("kward login openrouter")}
96
- #{command.call("kward edit lib/main.rb")}
97
- #{command.call("kward openrouter refresh")}
98
- #{command.call("kward stats tokens today --bucket hour")}
99
-
100
- Command names take precedence. Anything else is sent as a one-shot prompt.
101
- HELP
44
+ help_sections.each do |title, entries|
45
+ lines << heading.call(title)
46
+ lines.concat formatted_help_rows(entries, color: :green, bold: true)
47
+ lines << ""
48
+ end
49
+
50
+ lines << heading.call("Options")
51
+ lines.concat formatted_help_rows(help_options, color: :cyan)
52
+ lines << ""
53
+ lines << heading.call("Examples")
54
+ lines.concat help_examples.map { |example| " #{colored(example, :green, :bold)}" }
55
+ lines << ""
56
+ lines << "Command names take precedence. Anything else is sent as a one-shot prompt."
57
+ @prompt.say lines.join("\n")
58
+ end
59
+
60
+ def help_sections
61
+ {
62
+ "Getting started" => [
63
+ ["kward", "Start an interactive chat"],
64
+ ["kward login [PROVIDER]", "Sign in or save provider credentials"],
65
+ ["kward doctor", "Check local Kward setup"],
66
+ ["kward init", "Install starter prompts and PRINCIPLES.md"]
67
+ ],
68
+ "Work" => [
69
+ ["kward \"PROMPT\"", "Run a one-shot prompt"],
70
+ ["kward --filter \"INSTRUCTION\"", "Filter standard input"],
71
+ ["kward edit <filename>", "Open a file in the integrated editor"],
72
+ ["kward sysprompt [--raw]", "Inspect the effective system prompt"]
73
+ ],
74
+ "Manage" => [
75
+ ["kward auth status [--all]", "Show saved credential status"],
76
+ ["kward hooks <command>", "Inspect lifecycle hooks"],
77
+ ["kward skills <command>", "Manage project skill trust"],
78
+ ["kward openrouter <command>", "Manage cached OpenRouter models"],
79
+ ["kward stats tokens [range] [options]", "Export local token telemetry as CSV"]
80
+ ],
81
+ "Integrate" => [
82
+ ["kward pan", "Start the local Pan web UI"],
83
+ ["kward rpc", "Start the JSON-RPC backend"],
84
+ ["kward transport <command>", "Manage transport plugins"]
85
+ ],
86
+ "Reference" => [
87
+ ["kward help [command]", "Show help"],
88
+ ["kward version", "Show the installed version"]
89
+ ]
90
+ }
91
+ end
92
+
93
+ def help_options
94
+ [
95
+ ["--working-directory=PATH", "Run Kward from PATH"],
96
+ ["--mode=MODE", "Execution mode: auto, chat, oneshot, filter"],
97
+ ["--filter", "Shortcut for --mode filter"],
98
+ ["--skip-config", "Ignore the main config file for this run"],
99
+ ["--help, -h", "Show help"],
100
+ ["--version, -v", "Show the installed version"]
101
+ ]
102
+ end
103
+
104
+ def help_examples
105
+ [
106
+ "kward",
107
+ "kward \"Explain this project\"",
108
+ "git diff | kward \"Summarize the main changes\"",
109
+ "echo Hello | kward --filter \"Translate to German\"",
110
+ "kward login openrouter",
111
+ "kward edit lib/main.rb",
112
+ "kward stats tokens today --bucket hour"
113
+ ]
114
+ end
115
+
116
+ def formatted_help_rows(entries, color:, bold: false)
117
+ width = entries.map { |label, _description| label.length }.max
118
+ styles = [color]
119
+ styles << :bold if bold
120
+ entries.map do |label, description|
121
+ " #{colored(label.ljust(width), *styles)} #{description}"
122
+ end
102
123
  end
103
124
 
104
125
  def command_help
@@ -119,9 +140,9 @@ module Kward
119
140
  examples: ["kward login", "kward login anthropic", "kward login openrouter", "kward login github"]
120
141
  },
121
142
  "auth" => {
122
- usage: "kward auth status|logout",
143
+ usage: "kward auth status [--all]|logout",
123
144
  description: "Show or clear saved provider credentials without printing secrets.",
124
- examples: ["kward auth status", "kward auth logout"]
145
+ examples: ["kward auth status", "kward auth status --all", "kward auth logout"]
125
146
  },
126
147
  "init" => {
127
148
  usage: "kward init",
@@ -165,7 +186,7 @@ module Kward
165
186
  },
166
187
  "pan" => {
167
188
  usage: "kward pan",
168
- description: "Start Pan mode, a mobile-friendly LAN web UI with persistent sessions.",
189
+ description: "Start Pan mode, a mobile-friendly local web UI with persistent sessions.",
169
190
  examples: ["kward pan", "kward --working-directory ~/code/project pan"]
170
191
  },
171
192
  "rpc" => {
@@ -8,11 +8,32 @@ module Kward
8
8
 
9
9
  # Writes the doctor output for the terminal CLI flow.
10
10
  def print_doctor
11
- lines = ["#{colored("Kward Doctor", :green, :bold)}", ""]
12
- doctor_checks.each do |check|
13
- lines << "#{doctor_mark(check.fetch(:status))} #{check.fetch(:label)}: #{check.fetch(:message)}"
11
+ checks = doctor_checks
12
+ core_checks, optional_checks = checks.partition { |check| !check[:optional] }
13
+ lines = [colored("Kward Doctor", :green, :bold), "", colored("Core checks", :blue, :bold)]
14
+ lines.concat(doctor_check_lines(core_checks))
15
+ if optional_checks.any?
16
+ lines << ""
17
+ lines << colored("Optional", :blue, :bold)
18
+ lines.concat(doctor_check_lines(optional_checks))
14
19
  end
20
+ lines << ""
21
+ lines << doctor_summary(checks)
15
22
  @prompt.say lines.join("\n")
23
+ checks.none? { |check| check.fetch(:status) == :error && !check[:optional] }
24
+ end
25
+
26
+ def doctor_check_lines(checks)
27
+ checks.map { |check| "#{doctor_mark(check.fetch(:status))} #{check.fetch(:label)}: #{check.fetch(:message)}" }
28
+ end
29
+
30
+ def doctor_summary(checks)
31
+ errors = checks.count { |check| check.fetch(:status) == :error && !check[:optional] }
32
+ warnings = checks.count { |check| check.fetch(:status) == :warning && !check[:optional] }
33
+ return "Kward needs attention: #{errors} core check#{errors == 1 ? "" : "s"} failed." if errors.positive?
34
+ return "Kward is ready with #{warnings} warning#{warnings == 1 ? "" : "s"}." if warnings.positive?
35
+
36
+ "Kward is ready."
16
37
  end
17
38
 
18
39
  def doctor_checks
@@ -108,29 +129,28 @@ module Kward
108
129
  { status: :error, label: "Local endpoint", message: "invalid URL: #{url}" }
109
130
  end
110
131
 
111
- def doctor_auth_check(config)
112
- openai_auth = OpenAIOAuth.default_auth_path
113
- github_auth = GithubOAuth.default_auth_path
114
- has_openrouter = !config.to_h["openrouter_api_key"].to_s.empty? || !ENV["OPENROUTER_API_KEY"].to_s.empty?
132
+ def doctor_auth_check(_config)
115
133
  local_provider = @client.respond_to?(:current_provider) && @client.current_provider == "Local"
116
- paths = []
117
- paths << "OpenAI OAuth" if File.exist?(openai_auth)
118
- paths << "GitHub OAuth" if File.exist?(github_auth)
119
- paths << "OpenRouter API key" if has_openrouter
120
- paths << "Local endpoint (no authentication required)" if local_provider
121
- return { status: :ok, label: "Auth", message: paths.join(", ") } if paths.any?
134
+ configured = auth_credentials.select { |credential| credential.fetch(:configured) }.map { |credential| credential.fetch(:label) }
135
+ configured << "Local endpoint (no authentication required)" if local_provider
136
+ return { status: :ok, label: "Auth", message: configured.join(", ") } if configured.any?
122
137
 
123
138
  { status: :warning, label: "Auth", message: "no saved credentials found; run `kward login`" }
139
+ rescue ConfigFiles::ConfigError
140
+ { status: :warning, label: "Auth", message: "skipped because config is invalid" }
124
141
  end
125
142
 
126
143
  def doctor_pan_check(config_result)
127
- return { status: :warning, label: "Pan mode", message: "skipped because config is invalid" } if config_result.is_a?(ConfigFiles::ConfigError)
144
+ return { status: :optional, label: "Pan mode", message: "skipped because config is invalid", optional: true } if config_result.is_a?(ConfigFiles::ConfigError)
128
145
 
129
146
  pan = config_result.to_h["pan_mode"] || {}
130
- if !pan["username"].to_s.empty? && !pan["password"].to_s.empty?
131
- { status: :ok, label: "Pan mode", message: "credentials configured" }
147
+ environment_password = ENV["KWARD_PAN_PASSWORD"].to_s
148
+ password = environment_password.empty? ? pan["password"].to_s : environment_password
149
+ if !pan["username"].to_s.empty? && !password.empty?
150
+ source = environment_password.empty? ? "config" : "environment"
151
+ { status: :ok, label: "Pan mode", message: "credentials configured (password from #{source})", optional: true }
132
152
  else
133
- { status: :warning, label: "Pan mode", message: "username/password not configured" }
153
+ { status: :optional, label: "Pan mode", message: "not configured", optional: true }
134
154
  end
135
155
  end
136
156
 
@@ -140,6 +160,8 @@ module Kward
140
160
  colored("✓", :green, :bold)
141
161
  when :warning
142
162
  colored("!", :yellow, :bold)
163
+ when :optional
164
+ colored("•", :gray, :bold)
143
165
  else
144
166
  colored("✗", :red, :bold)
145
167
  end
@@ -9,7 +9,9 @@ module Kward
9
9
  module HookCommands
10
10
  private
11
11
 
12
- def handle_hooks_command(argument)
12
+ def handle_hooks_command(argument, command_line: false)
13
+ previous_output_mode = @hook_command_line_output
14
+ @hook_command_line_output = command_line
13
15
  subcommand, rest = argument.to_s.strip.split(/\s+/, 2)
14
16
  subcommand = "list" if subcommand.to_s.empty?
15
17
 
@@ -27,8 +29,16 @@ module Kward
27
29
  when "untrust"
28
30
  untrust_workspace_hooks
29
31
  else
30
- runtime_output("Usage: /hooks [list|events|logs|doctor|trust|untrust]")
32
+ raise ArgumentError, command_usage("hooks") if command_line
33
+
34
+ hook_command_output("Usage: /hooks [list|events|logs|doctor|trust|untrust]")
31
35
  end
36
+ ensure
37
+ @hook_command_line_output = previous_output_mode
38
+ end
39
+
40
+ def hook_command_output(text)
41
+ @hook_command_line_output ? @prompt.say(text) : runtime_output(text)
32
42
  end
33
43
 
34
44
  def print_hooks_list
@@ -46,7 +56,7 @@ module Kward
46
56
  lines << "- #{details.join(' ')}"
47
57
  end
48
58
  end
49
- runtime_output(lines.join("\n"))
59
+ hook_command_output(lines.join("\n"))
50
60
  end
51
61
 
52
62
  def print_hooks_events
@@ -57,7 +67,7 @@ module Kward
57
67
  suffix = fields.empty? ? "" : " modifies=#{fields.join(',')}"
58
68
  lines << "- #{event_name} failure_policy=#{Hooks::Catalog.failure_policy(event_name)}#{suffix}"
59
69
  end
60
- runtime_output(lines.join("\n"))
70
+ hook_command_output(lines.join("\n"))
61
71
  end
62
72
 
63
73
  def print_hooks_logs(argument)
@@ -65,7 +75,7 @@ module Kward
65
75
  count = 20 unless count.positive?
66
76
  path = hooks_log_path
67
77
  unless File.file?(path)
68
- runtime_output("No lifecycle hook audit log found at #{path}.")
78
+ hook_command_output("No lifecycle hook audit log found at #{path}.")
69
79
  return
70
80
  end
71
81
 
@@ -75,7 +85,7 @@ module Kward
75
85
  nil
76
86
  end
77
87
  if records.empty?
78
- runtime_output("No readable lifecycle hook audit records found at #{path}.")
88
+ hook_command_output("No readable lifecycle hook audit records found at #{path}.")
79
89
  return
80
90
  end
81
91
 
@@ -83,7 +93,7 @@ module Kward
83
93
  records.each do |record|
84
94
  lines << format_hook_log_record(record)
85
95
  end
86
- runtime_output(lines.join("\n"))
96
+ hook_command_output(lines.join("\n"))
87
97
  end
88
98
 
89
99
  def print_hooks_doctor
@@ -105,7 +115,7 @@ module Kward
105
115
  rescue StandardError => e
106
116
  lines << "Error: #{e.message}"
107
117
  end
108
- runtime_output(lines.join("\n"))
118
+ hook_command_output(lines.join("\n"))
109
119
  end
110
120
 
111
121
  def configured_hook_entries
@@ -226,16 +236,16 @@ module Kward
226
236
 
227
237
  def trust_workspace_hooks
228
238
  ConfigFiles.trust_workspace_hooks!(current_workspace_root)
229
- runtime_output("Trusted workspace hooks: #{ConfigFiles.workspace_hooks_path(current_workspace_root)}")
239
+ hook_command_output("Trusted workspace hooks: #{ConfigFiles.workspace_hooks_path(current_workspace_root)}")
230
240
  rescue StandardError => e
231
- runtime_output("Workspace hook trust error: #{e.message}")
241
+ hook_command_output("Workspace hook trust error: #{e.message}")
232
242
  end
233
243
 
234
244
  def untrust_workspace_hooks
235
245
  ConfigFiles.untrust_workspace_hooks!(current_workspace_root)
236
- runtime_output("Untrusted workspace hooks: #{ConfigFiles.workspace_hooks_path(current_workspace_root)}")
246
+ hook_command_output("Untrusted workspace hooks: #{ConfigFiles.workspace_hooks_path(current_workspace_root)}")
237
247
  rescue StandardError => e
238
- runtime_output("Workspace hook trust error: #{e.message}")
248
+ hook_command_output("Workspace hook trust error: #{e.message}")
239
249
  end
240
250
 
241
251
  def hooks_log_path