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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +68 -15
- data/CONTRIBUTING.md +74 -0
- data/Gemfile.lock +8 -2
- data/README.md +21 -1
- data/Rakefile +46 -2
- data/SECURITY.md +31 -0
- data/doc/api.md +4 -0
- data/doc/composer.md +1 -1
- data/doc/configuration.md +68 -21
- data/doc/editor.md +28 -13
- data/doc/files.md +8 -4
- data/doc/getting-started.md +3 -0
- data/doc/pan.md +19 -15
- data/doc/platform-support.md +48 -0
- data/doc/security.md +3 -2
- data/doc/shell.md +62 -45
- data/doc/troubleshooting.md +12 -2
- data/doc/usage.md +5 -5
- data/kward.gemspec +5 -4
- data/lib/kward/agent.rb +6 -3
- data/lib/kward/ansi.rb +110 -10
- data/lib/kward/cli/auth_commands.rb +34 -13
- data/lib/kward/cli/commands.rb +83 -62
- data/lib/kward/cli/doctor.rb +39 -17
- data/lib/kward/cli/hook_commands.rb +22 -12
- data/lib/kward/cli/interactive_turn.rb +48 -7
- data/lib/kward/cli/project_skills_commands.rb +8 -4
- data/lib/kward/cli/prompt_interface.rb +27 -0
- data/lib/kward/cli/rendering.rb +15 -9
- data/lib/kward/cli/runtime_helpers.rb +118 -55
- data/lib/kward/cli/slash_commands.rb +12 -14
- data/lib/kward/cli/tabs.rb +83 -12
- data/lib/kward/cli/tool_summaries.rb +14 -0
- data/lib/kward/cli.rb +45 -7
- data/lib/kward/cli_transcript_formatter.rb +11 -4
- data/lib/kward/config_files.rb +82 -68
- data/lib/kward/detached_run.rb +44 -0
- data/lib/kward/interactive_pty_runner.rb +102 -28
- data/lib/kward/{ekwsh.rb → kwsh.rb} +35 -16
- data/lib/kward/kwshrc.rb +233 -0
- data/lib/kward/markdown_code_block.rb +136 -0
- data/lib/kward/model/client.rb +34 -22
- data/lib/kward/model/provider_catalog.rb +5 -0
- data/lib/kward/model/stream_parser.rb +20 -4
- data/lib/kward/pan/index.html.erb +3 -3
- data/lib/kward/pan/server.rb +23 -3
- data/lib/kward/persistent_shell_session.rb +119 -26
- data/lib/kward/project_files.rb +2 -2
- data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
- data/lib/kward/prompt_interface/composer_state.rb +33 -24
- data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
- data/lib/kward/prompt_interface/editor/controller.rb +30 -33
- data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
- data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
- data/lib/kward/prompt_interface/editor/runner.rb +362 -0
- data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
- data/lib/kward/prompt_interface/editor/state.rb +10 -10
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
- data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
- data/lib/kward/prompt_interface/file_overlay.rb +71 -15
- data/lib/kward/prompt_interface/key_handler.rb +67 -0
- data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
- data/lib/kward/prompt_interface/project_browser.rb +415 -14
- data/lib/kward/prompt_interface/runtime_state.rb +50 -1
- data/lib/kward/prompt_interface/screen.rb +2 -2
- data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
- data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
- data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
- data/lib/kward/prompt_interface.rb +93 -18
- data/lib/kward/prompts/commands.rb +1 -1
- data/lib/kward/prompts.rb +1 -1
- data/lib/kward/pty_output_sink.rb +47 -0
- data/lib/kward/rpc/transcript_normalizer.rb +7 -3
- data/lib/kward/scratchpad_languages.rb +74 -0
- data/lib/kward/scratchpad_runner.rb +155 -29
- data/lib/kward/shell_prompt.rb +2 -0
- data/lib/kward/terminal_keys.rb +12 -0
- data/lib/kward/terminal_text.rb +121 -0
- data/lib/kward/text_matcher.rb +18 -0
- data/lib/kward/tools/open_editor.rb +41 -0
- data/lib/kward/tools/registry.rb +13 -4
- data/lib/kward/tools/tool_call.rb +2 -1
- data/lib/kward/version.rb +1 -1
- data/templates/default/fulldoc/html/css/kward.css +0 -125
- data/templates/default/fulldoc/html/images/kward_workflow.svg +52 -0
- data/templates/default/fulldoc/html/setup.rb +1 -1
- data/templates/default/kward_navigation.rb +1 -0
- data/templates/default/layout/html/footer.erb +10 -0
- data/templates/default/layout/html/headers.erb +23 -0
- data/templates/default/layout/html/layout.erb +6 -18
- data/templates/default/layout/html/setup.rb +41 -2
- metadata +36 -8
- 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
|
|
10
|
-
spec.description = "Kward is
|
|
11
|
-
spec.homepage = "https://github.
|
|
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.
|
|
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
|
-
|
|
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 <<
|
|
199
|
+
rendered << render_pending_line(line) << "\n"
|
|
196
200
|
end
|
|
197
201
|
|
|
198
|
-
if final
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
215
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
lines = [
|
|
28
|
-
lines
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
lines <<
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
data/lib/kward/cli/commands.rb
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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" => {
|
data/lib/kward/cli/doctor.rb
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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(
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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: :
|
|
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
|
-
|
|
131
|
-
|
|
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: :
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
+
hook_command_output("Trusted workspace hooks: #{ConfigFiles.workspace_hooks_path(current_workspace_root)}")
|
|
230
240
|
rescue StandardError => e
|
|
231
|
-
|
|
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
|
-
|
|
246
|
+
hook_command_output("Untrusted workspace hooks: #{ConfigFiles.workspace_hooks_path(current_workspace_root)}")
|
|
237
247
|
rescue StandardError => e
|
|
238
|
-
|
|
248
|
+
hook_command_output("Workspace hook trust error: #{e.message}")
|
|
239
249
|
end
|
|
240
250
|
|
|
241
251
|
def hooks_log_path
|