kward 0.83.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -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/api.md +4 -0
  9. data/doc/composer.md +1 -1
  10. data/doc/configuration.md +68 -21
  11. data/doc/editor.md +28 -13
  12. data/doc/files.md +8 -4
  13. data/doc/getting-started.md +3 -0
  14. data/doc/pan.md +19 -15
  15. data/doc/platform-support.md +48 -0
  16. data/doc/security.md +3 -2
  17. data/doc/shell.md +62 -45
  18. data/doc/troubleshooting.md +12 -2
  19. data/doc/usage.md +5 -5
  20. data/kward.gemspec +5 -4
  21. data/lib/kward/agent.rb +6 -3
  22. data/lib/kward/ansi.rb +110 -10
  23. data/lib/kward/cli/auth_commands.rb +34 -13
  24. data/lib/kward/cli/commands.rb +83 -62
  25. data/lib/kward/cli/doctor.rb +39 -17
  26. data/lib/kward/cli/hook_commands.rb +22 -12
  27. data/lib/kward/cli/interactive_turn.rb +48 -7
  28. data/lib/kward/cli/project_skills_commands.rb +8 -4
  29. data/lib/kward/cli/prompt_interface.rb +27 -0
  30. data/lib/kward/cli/rendering.rb +15 -9
  31. data/lib/kward/cli/runtime_helpers.rb +118 -55
  32. data/lib/kward/cli/slash_commands.rb +12 -14
  33. data/lib/kward/cli/tabs.rb +83 -12
  34. data/lib/kward/cli/tool_summaries.rb +14 -0
  35. data/lib/kward/cli.rb +45 -7
  36. data/lib/kward/cli_transcript_formatter.rb +11 -4
  37. data/lib/kward/config_files.rb +82 -68
  38. data/lib/kward/detached_run.rb +44 -0
  39. data/lib/kward/interactive_pty_runner.rb +102 -28
  40. data/lib/kward/{ekwsh.rb → kwsh.rb} +35 -16
  41. data/lib/kward/kwshrc.rb +233 -0
  42. data/lib/kward/markdown_code_block.rb +136 -0
  43. data/lib/kward/model/client.rb +34 -22
  44. data/lib/kward/model/provider_catalog.rb +5 -0
  45. data/lib/kward/model/stream_parser.rb +20 -4
  46. data/lib/kward/pan/index.html.erb +3 -3
  47. data/lib/kward/pan/server.rb +23 -3
  48. data/lib/kward/persistent_shell_session.rb +119 -26
  49. data/lib/kward/project_files.rb +2 -2
  50. data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
  51. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  52. data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
  53. data/lib/kward/prompt_interface/editor/controller.rb +30 -33
  54. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  55. data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
  56. data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
  57. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  58. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  59. data/lib/kward/prompt_interface/editor/state.rb +10 -10
  60. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  61. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  62. data/lib/kward/prompt_interface/file_overlay.rb +71 -15
  63. data/lib/kward/prompt_interface/key_handler.rb +67 -0
  64. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  65. data/lib/kward/prompt_interface/project_browser.rb +415 -14
  66. data/lib/kward/prompt_interface/runtime_state.rb +50 -1
  67. data/lib/kward/prompt_interface/screen.rb +2 -2
  68. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  69. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  70. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  71. data/lib/kward/prompt_interface.rb +93 -18
  72. data/lib/kward/prompts/commands.rb +1 -1
  73. data/lib/kward/prompts.rb +1 -1
  74. data/lib/kward/pty_output_sink.rb +47 -0
  75. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  76. data/lib/kward/scratchpad_languages.rb +74 -0
  77. data/lib/kward/scratchpad_runner.rb +155 -29
  78. data/lib/kward/shell_prompt.rb +2 -0
  79. data/lib/kward/terminal_keys.rb +12 -0
  80. data/lib/kward/terminal_text.rb +121 -0
  81. data/lib/kward/text_matcher.rb +18 -0
  82. data/lib/kward/tools/open_editor.rb +41 -0
  83. data/lib/kward/tools/registry.rb +13 -4
  84. data/lib/kward/tools/tool_call.rb +2 -1
  85. data/lib/kward/version.rb +1 -1
  86. data/templates/default/fulldoc/html/css/kward.css +0 -125
  87. data/templates/default/fulldoc/html/images/kward_workflow.svg +52 -0
  88. data/templates/default/fulldoc/html/setup.rb +1 -1
  89. data/templates/default/kward_navigation.rb +1 -0
  90. data/templates/default/layout/html/footer.erb +10 -0
  91. data/templates/default/layout/html/headers.erb +23 -0
  92. data/templates/default/layout/html/layout.erb +6 -18
  93. data/templates/default/layout/html/setup.rb +41 -2
  94. metadata +36 -8
  95. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
data/kward.gemspec CHANGED
@@ -6,16 +6,16 @@ Gem::Specification.new do |spec|
6
6
  spec.authors = ["Kai Wood"]
7
7
  spec.email = ["kai.wood@icloud.com"]
8
8
 
9
- spec.summary = "An extendable Ruby CLI coding agent."
10
- spec.description = "Kward is a Ruby CLI coding agent with local workspace tools, configurable prompts, web search, sessions, and an experimental JSON-RPC backend."
11
- spec.homepage = "https://github.com/kaiwood/kward"
9
+ spec.summary = "An extensible Ruby coding agent for your terminal."
10
+ spec.description = "Kward is an extensible Ruby coding agent with workspace tools, resumable sessions, multiple model providers, a local browser UI, and JSON-RPC integrations."
11
+ spec.homepage = "https://kaiwood.github.io/kward/"
12
12
  spec.license = "MIT"
13
13
  spec.required_ruby_version = ">= 3.4"
14
14
 
15
15
  spec.metadata["rubygems_mfa_required"] = "true"
16
16
  spec.metadata["source_code_uri"] = "https://github.com/kaiwood/kward"
17
17
  spec.metadata["changelog_uri"] = "https://github.com/kaiwood/kward/blob/main/CHANGELOG.md"
18
- spec.metadata["documentation_uri"] = "https://github.com/kaiwood/kward#readme"
18
+ spec.metadata["documentation_uri"] = "https://kaiwood.github.io/kward/"
19
19
  spec.metadata["bug_tracker_uri"] = "https://github.com/kaiwood/kward/issues"
20
20
 
21
21
  spec.files = Dir.chdir(__dir__) do
@@ -34,4 +34,5 @@ Gem::Specification.new do |spec|
34
34
  spec.add_dependency "tty-prompt"
35
35
  spec.add_dependency "tty-reader"
36
36
  spec.add_dependency "tty-screen"
37
+ spec.add_dependency "unicode-display_width"
37
38
  end
data/lib/kward/agent.rb CHANGED
@@ -28,10 +28,11 @@ module Kward
28
28
  # lowest layer that owns the behavior, and use `Agent` only for cross-step turn
29
29
  # coordination.
30
30
  class Agent
31
- def initialize(client:, tool_registry: ToolRegistry.new, conversation: Conversation.new, telemetry_logger: nil, warning_sink: nil, hook_manager: nil, hook_context: nil)
31
+ def initialize(client:, tool_registry: ToolRegistry.new, conversation: Conversation.new, telemetry_logger: nil, warning_sink: nil, hook_manager: nil, hook_context: nil, strict_provider: false)
32
32
  @client = client
33
33
  @tool_registry = tool_registry
34
34
  @conversation = conversation
35
+ @strict_provider = strict_provider == true
35
36
  @warning_sink = warning_sink
36
37
  @telemetry_logger = telemetry_logger || TelemetryLogger.new(warning_sink: warning_sink)
37
38
  @hook_manager = hook_manager
@@ -241,7 +242,8 @@ module Kward
241
242
  tools: registry.schemas,
242
243
  provider: options[:provider] || @conversation.provider,
243
244
  model: options[:model] || @conversation.model,
244
- reasoning: options[:reasoning] || @conversation.reasoning_effort
245
+ reasoning: options[:reasoning] || @conversation.reasoning_effort,
246
+ provider_required: @strict_provider
245
247
  }
246
248
  before = run_hook("model_request_before", payload: request)
247
249
  request = DeepCopy.merge(request, before.payload) if before.decision.modify?
@@ -261,7 +263,8 @@ module Kward
261
263
  steering: steering,
262
264
  provider: request[:provider] || request["provider"],
263
265
  model: request[:model] || request["model"],
264
- reasoning: request[:reasoning] || request["reasoning"]
266
+ reasoning: request[:reasoning] || request["reasoning"],
267
+ provider_required: request[:provider_required] || request["provider_required"]
265
268
  }
266
269
  )
267
270
  run_hook("model_response_after_parse", payload: { message: response })
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)
@@ -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