kward 0.74.0 → 0.75.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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -2
  5. data/Rakefile +18 -0
  6. data/doc/configuration.md +56 -3
  7. data/doc/extensibility.md +6 -19
  8. data/doc/getting-started.md +1 -1
  9. data/doc/lifecycle-hooks.md +424 -0
  10. data/doc/memory.md +1 -1
  11. data/doc/personas.md +2 -2
  12. data/doc/plugins.md +1 -1
  13. data/doc/releasing.md +7 -15
  14. data/doc/rpc.md +108 -16
  15. data/doc/session-management.md +9 -9
  16. data/doc/skills.md +199 -0
  17. data/doc/usage.md +7 -7
  18. data/lib/kward/agent.rb +72 -22
  19. data/lib/kward/auth/anthropic_oauth.rb +5 -87
  20. data/lib/kward/auth/oauth_helpers.rb +105 -0
  21. data/lib/kward/auth/openai_oauth.rb +4 -86
  22. data/lib/kward/cli/commands.rb +9 -2
  23. data/lib/kward/cli/compaction.rb +7 -0
  24. data/lib/kward/cli/git.rb +30 -3
  25. data/lib/kward/cli/hook_commands.rb +258 -0
  26. data/lib/kward/cli/memory_commands.rb +1 -5
  27. data/lib/kward/cli/plugins.rb +41 -1
  28. data/lib/kward/cli/prompt_interface.rb +32 -3
  29. data/lib/kward/cli/rendering.rb +1 -1
  30. data/lib/kward/cli/runtime_helpers.rb +44 -5
  31. data/lib/kward/cli/sessions.rb +13 -0
  32. data/lib/kward/cli/settings.rb +226 -152
  33. data/lib/kward/cli/slash_commands.rb +81 -42
  34. data/lib/kward/cli/tabs.rb +26 -6
  35. data/lib/kward/cli.rb +26 -3
  36. data/lib/kward/cli_transcript_formatter.rb +0 -7
  37. data/lib/kward/compaction/token_estimator.rb +112 -0
  38. data/lib/kward/compactor.rb +42 -116
  39. data/lib/kward/config_files.rb +117 -25
  40. data/lib/kward/deep_copy.rb +42 -0
  41. data/lib/kward/diff_view_mode.rb +36 -0
  42. data/lib/kward/events.rb +1 -0
  43. data/lib/kward/export_path.rb +2 -6
  44. data/lib/kward/frontmatter.rb +43 -0
  45. data/lib/kward/hooks/audit_log.rb +121 -0
  46. data/lib/kward/hooks/catalog.rb +110 -0
  47. data/lib/kward/hooks/command_handler.rb +66 -0
  48. data/lib/kward/hooks/config_loader.rb +104 -0
  49. data/lib/kward/hooks/decision.rb +102 -0
  50. data/lib/kward/hooks/event.rb +60 -0
  51. data/lib/kward/hooks/http_handler.rb +72 -0
  52. data/lib/kward/hooks/manager.rb +158 -0
  53. data/lib/kward/hooks/matcher.rb +75 -0
  54. data/lib/kward/hooks.rb +16 -0
  55. data/lib/kward/markdown_transcript.rb +4 -3
  56. data/lib/kward/mcp/client.rb +1 -1
  57. data/lib/kward/mcp/server_config.rb +7 -3
  58. data/lib/kward/mcp/stdio_transport.rb +7 -1
  59. data/lib/kward/memory/turn_context.rb +19 -0
  60. data/lib/kward/message_access.rb +2 -2
  61. data/lib/kward/message_text.rb +19 -0
  62. data/lib/kward/model/client.rb +13 -45
  63. data/lib/kward/model/context_usage.rb +10 -4
  64. data/lib/kward/model/copilot_models.rb +44 -0
  65. data/lib/kward/model/model_info.rb +24 -0
  66. data/lib/kward/model/payloads.rb +15 -17
  67. data/lib/kward/model/stream_parser.rb +178 -13
  68. data/lib/kward/pan/server.rb +51 -2
  69. data/lib/kward/path_guard.rb +13 -0
  70. data/lib/kward/plugin_registry.rb +86 -25
  71. data/lib/kward/prompt_interface/editor/controller.rb +60 -1
  72. data/lib/kward/prompt_interface/editor/renderer.rb +11 -0
  73. data/lib/kward/prompt_interface/editor/state.rb +5 -1
  74. data/lib/kward/prompt_interface/selection_prompt.rb +26 -6
  75. data/lib/kward/prompt_interface.rb +4 -1
  76. data/lib/kward/prompts/commands.rb +3 -3
  77. data/lib/kward/prompts.rb +7 -7
  78. data/lib/kward/rpc/mcp_status.rb +50 -0
  79. data/lib/kward/rpc/prompt_bridge.rb +38 -4
  80. data/lib/kward/rpc/runtime_payloads.rb +1 -1
  81. data/lib/kward/rpc/server.rb +94 -14
  82. data/lib/kward/rpc/session_manager.rb +258 -33
  83. data/lib/kward/rpc/session_metrics.rb +1 -1
  84. data/lib/kward/rpc/session_tree_rows.rb +13 -64
  85. data/lib/kward/rpc/tool_event_normalizer.rb +16 -2
  86. data/lib/kward/rpc/transcript_normalizer.rb +9 -10
  87. data/lib/kward/rpc/turn_context.rb +109 -0
  88. data/lib/kward/session_store.rb +6 -20
  89. data/lib/kward/session_tree_nodes.rb +98 -18
  90. data/lib/kward/session_tree_renderer.rb +6 -61
  91. data/lib/kward/skills/registry.rb +109 -17
  92. data/lib/kward/tools/mcp_tool.rb +4 -0
  93. data/lib/kward/tools/read_skill.rb +9 -4
  94. data/lib/kward/tools/registry.rb +266 -8
  95. data/lib/kward/tools/search/code.rb +4 -22
  96. data/lib/kward/tools/search/web.rb +28 -2
  97. data/lib/kward/update_check.rb +117 -0
  98. data/lib/kward/version.rb +1 -1
  99. data/lib/kward/workers/manager.rb +43 -3
  100. data/lib/kward/workspace.rb +3 -1
  101. data/templates/default/fulldoc/html/css/kward.css +22 -0
  102. data/templates/default/fulldoc/html/js/kward.js +2 -0
  103. data/templates/default/kward_navigation.rb +4 -2
  104. data/templates/default/layout/html/setup.rb +2 -0
  105. metadata +25 -1
@@ -1,5 +1,6 @@
1
1
  require_relative "../config_files"
2
2
  require_relative "../conversation"
3
+ require_relative "../hooks"
3
4
  require_relative "ask_user_question"
4
5
  require_relative "code_search"
5
6
  require_relative "context_budget_stats"
@@ -62,7 +63,7 @@ module Kward
62
63
  # @param web_search_enabled [Boolean, nil] override for web search exposure
63
64
  # @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
64
65
  # @param ask_user_question_enabled [Boolean, nil] override question exposure
65
- def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil, allowed_tool_names: nil, write_lock: nil, writer_id: nil, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new, context_budget_meter: nil, mcp_clients: nil)
66
+ def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil, allowed_tool_names: nil, write_lock: nil, writer_id: nil, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new, context_budget_meter: nil, mcp_clients: nil, tool_approval: nil, hook_manager: nil, hook_context: nil)
66
67
  @workspace = workspace
67
68
  @prompt = prompt
68
69
  @web_search = web_search
@@ -77,6 +78,9 @@ module Kward
77
78
  @tool_output_compactor = tool_output_compactor
78
79
  @telemetry_logger = telemetry_logger
79
80
  @context_budget_meter = context_budget_meter
81
+ @tool_approval = tool_approval
82
+ @hook_manager = hook_manager
83
+ @hook_context = hook_context
80
84
  @mcp_clients = if mcp_clients
81
85
  mcp_clients
82
86
  elsif @allowed_tool_names
@@ -85,7 +89,7 @@ module Kward
85
89
  MCP::ServerConfig.clients_from_config(ConfigFiles.read_config)
86
90
  end
87
91
  @tools = build_tools.freeze
88
- @schemas = build_schema_tools.map(&:schema).freeze
92
+ @schemas = build_schema_tools.map { |tool| schema_with_metadata(tool) }.freeze
89
93
  end
90
94
 
91
95
  # Executes a model-requested tool call and appends the result to the
@@ -105,10 +109,18 @@ module Kward
105
109
  tool = @tools[name]
106
110
 
107
111
  original_content = if tool
108
- if mutation_tool?(name) && !write_lock_owned?
112
+ before_tool = run_hook("tool_call_before", conversation, payload: tool_payload(name, args, tool_call))
113
+ args = hook_arguments(before_tool, args)
114
+ if before_tool.denied?
115
+ hook_denied_content(before_tool, "tool call denied: #{name}")
116
+ elsif before_tool.approval_required? && hook_approval_denied?(before_tool, tool_call, name, args, cancellation)
117
+ hook_denied_content(before_tool, "tool call approval denied: #{name}")
118
+ elsif mutation_tool?(name) && !write_lock_owned?
109
119
  "Workspace write denied: another worker owns the write lock."
120
+ elsif tool_approval_denied?(tool_call, name, args, cancellation)
121
+ "Declined: tool execution denied by user: #{name}"
110
122
  else
111
- tool.call(args, conversation, cancellation: cancellation)
123
+ execute_tool_with_hooks(tool, name, args, tool_call, conversation, cancellation)
112
124
  end
113
125
  else
114
126
  "Unknown tool: #{name}"
@@ -121,7 +133,7 @@ module Kward
121
133
  end
122
134
 
123
135
  artifact_id = nil
124
- model_content = @tool_output_compactor.compact(name, content) do
136
+ model_content = compact_tool_output(name, content, original_content, conversation) do
125
137
  artifact_id ||= conversation.store_tool_output_artifact(tool_name: name, content: original_content)
126
138
  end
127
139
  record_context_budget(conversation, name, before: original_content, after: model_content)
@@ -136,8 +148,73 @@ module Kward
136
148
  model_content
137
149
  end
138
150
 
151
+ def metadata_for(name)
152
+ tool = @tools[name.to_s]
153
+ return unknown_tool_metadata(name) unless tool
154
+
155
+ tool_metadata(tool)
156
+ end
157
+
139
158
  private
140
159
 
160
+ def schema_with_metadata(tool)
161
+ tool.schema.merge(metadata: tool_metadata(tool))
162
+ end
163
+
164
+ def tool_metadata(tool)
165
+ if tool.is_a?(Tools::MCPTool)
166
+ {
167
+ source: "mcp",
168
+ displayName: tool.display_name,
169
+ serverName: tool.server_name,
170
+ remoteName: tool.remote_name
171
+ }
172
+ else
173
+ {
174
+ source: source_for_tool(tool),
175
+ displayName: tool.name
176
+ }
177
+ end
178
+ end
179
+
180
+ def source_for_tool(tool)
181
+ case tool
182
+ when Tools::WebSearch, Tools::FetchContent, Tools::FetchRaw
183
+ "web"
184
+ when Tools::ReadSkill
185
+ "skill"
186
+ when Tools::AskUserQuestion
187
+ "ui"
188
+ else
189
+ "builtin"
190
+ end
191
+ end
192
+
193
+ def unknown_tool_metadata(name)
194
+ {
195
+ source: "unknown",
196
+ displayName: name.to_s
197
+ }
198
+ end
199
+
200
+ def compact_tool_output(name, content, original_content, conversation, &store_artifact)
201
+ before = run_hook("tool_output_compact_before", conversation, payload: {
202
+ tool_name: name,
203
+ bytes: content.bytesize,
204
+ duplicate: content != original_content
205
+ })
206
+ return content if before.denied? || before.approval_required?
207
+
208
+ compacted = @tool_output_compactor.compact(name, content, &store_artifact)
209
+ run_hook("tool_output_compact_after", conversation, payload: {
210
+ tool_name: name,
211
+ bytes_before: content.bytesize,
212
+ bytes_after: compacted.bytesize,
213
+ compacted: compacted != content
214
+ }) if compacted != content
215
+ compacted
216
+ end
217
+
141
218
  def record_context_budget(conversation, name, before:, after:)
142
219
  meter = conversation.respond_to?(:context_budget_meter) ? conversation.context_budget_meter : @context_budget_meter
143
220
  return unless meter
@@ -170,10 +247,184 @@ module Kward
170
247
  )
171
248
  end
172
249
 
250
+ def execute_tool_with_hooks(tool, name, args, tool_call, conversation, cancellation)
251
+ args = run_shell_before_hooks(name, args, tool_call, conversation, cancellation)
252
+ run_file_change_before_hooks(name, args, tool_call, conversation, cancellation)
253
+ run_mcp_before_hooks(name, args, tool_call, conversation, cancellation)
254
+ content = tool.call(args, conversation, cancellation: cancellation)
255
+ run_mcp_after_hooks(name, args, content, conversation)
256
+ run_shell_after_hooks(name, args, content, conversation)
257
+ run_file_change_after_hooks(name, args, content, conversation)
258
+ run_hook("tool_call_after", conversation, payload: tool_payload(name, args, tool_call).merge(content: content.to_s))
259
+ content
260
+ rescue HookDenied => e
261
+ e.message
262
+ rescue StandardError => e
263
+ run_mcp_error_hooks(name, args, e, conversation)
264
+ run_hook("tool_call_error", conversation, payload: tool_payload(name, args, tool_call).merge(error: e.message))
265
+ raise
266
+ end
267
+
268
+ def run_mcp_before_hooks(name, args, tool_call, conversation, cancellation)
269
+ return unless mcp_tool?(name)
270
+
271
+ result = run_hook("mcp_tool_before", conversation, payload: mcp_payload(name, args))
272
+ if result.denied?
273
+ raise HookDenied, hook_denied_content(result, "MCP tool denied")
274
+ end
275
+ if result.approval_required? && hook_approval_denied?(result, tool_call, name, args, cancellation)
276
+ raise HookDenied, hook_denied_content(result, "MCP tool approval denied")
277
+ end
278
+ end
279
+
280
+ def run_mcp_after_hooks(name, args, content, conversation)
281
+ return unless mcp_tool?(name)
282
+
283
+ run_hook("mcp_tool_after", conversation, payload: mcp_payload(name, args).merge(content: content.to_s))
284
+ end
285
+
286
+ def run_mcp_error_hooks(name, args, error, conversation)
287
+ return unless mcp_tool?(name)
288
+
289
+ run_hook("mcp_tool_error", conversation, payload: mcp_payload(name, args).merge(error: error.message))
290
+ end
291
+
292
+ def run_shell_before_hooks(name, args, tool_call, conversation, cancellation)
293
+ return args unless name.to_s == "run_shell_command"
294
+
295
+ result = run_hook("shell_command_before", conversation, payload: shell_payload(args))
296
+ if result.denied?
297
+ raise HookDenied, hook_denied_content(result, "shell command denied")
298
+ end
299
+ if result.approval_required? && hook_approval_denied?(result, tool_call, name, args, cancellation)
300
+ raise HookDenied, hook_denied_content(result, "shell command approval denied")
301
+ end
302
+ shell_arguments(result.payload, args)
303
+ end
304
+
305
+ def run_shell_after_hooks(name, args, content, conversation)
306
+ return unless name.to_s == "run_shell_command"
307
+
308
+ run_hook("shell_command_after", conversation, payload: shell_payload(args).merge(content: content.to_s))
309
+ end
310
+
311
+ def run_file_change_before_hooks(name, args, tool_call, conversation, cancellation)
312
+ return unless ToolCall.file_change_tool?(name)
313
+
314
+ result = run_hook("file_change_before", conversation, payload: file_change_payload(name, args))
315
+ if result.denied?
316
+ raise HookDenied, hook_denied_content(result, "file change denied")
317
+ end
318
+ if result.approval_required? && hook_approval_denied?(result, tool_call, name, args, cancellation)
319
+ raise HookDenied, hook_denied_content(result, "file change approval denied")
320
+ end
321
+ end
322
+
323
+ def run_file_change_after_hooks(name, args, content, conversation)
324
+ return unless ToolCall.file_change_tool?(name)
325
+ return unless content.to_s.start_with?("Wrote ", "Edited ")
326
+
327
+ run_hook("file_change_after", conversation, payload: file_change_payload(name, args).merge(content: content.to_s))
328
+ end
329
+
330
+ HookDenied = Class.new(StandardError)
331
+
332
+ def run_hook(name, conversation, payload: {})
333
+ return Hooks::Manager::Result.new(event: nil, decision: Hooks::Decision.allow, decisions: [], warnings: [], messages: [], payload: payload) unless @hook_manager
334
+
335
+ @hook_manager.run(Hooks::Event.new(
336
+ name: name,
337
+ workspace: { root: @workspace.respond_to?(:root) ? @workspace.root.to_s : nil },
338
+ payload: payload
339
+ ), context: @hook_context || default_hook_context(conversation))
340
+ end
341
+
342
+ def default_hook_context(conversation)
343
+ return nil unless defined?(PluginRegistry::Context)
344
+
345
+ PluginRegistry::Context.new(conversation: conversation, workspace_root: @workspace.respond_to?(:root) ? @workspace.root.to_s : Dir.pwd)
346
+ end
347
+
348
+ def tool_payload(name, args, tool_call)
349
+ metadata = metadata_for(name)
350
+ {
351
+ tool_name: name,
352
+ arguments: args,
353
+ tool_call_id: tool_call["id"] || tool_call[:id],
354
+ source: metadata[:source] || metadata["source"],
355
+ server_name: metadata[:serverName] || metadata["serverName"],
356
+ remote_name: metadata[:remoteName] || metadata["remoteName"]
357
+ }.compact
358
+ end
359
+
360
+ def hook_arguments(result, fallback)
361
+ payload_arguments = result.payload[:arguments] || result.payload["arguments"]
362
+ payload_arguments.is_a?(Hash) ? payload_arguments : fallback
363
+ end
364
+
365
+ def mcp_payload(name, args)
366
+ tool_payload(name, args, {}).slice(:tool_name, :arguments, :source, :server_name, :remote_name)
367
+ end
368
+
369
+ def shell_payload(args)
370
+ {
371
+ tool_name: "run_shell_command",
372
+ command: args[:command] || args["command"],
373
+ timeout_seconds: args[:timeout_seconds] || args["timeout_seconds"] || Workspace::DEFAULT_COMMAND_TIMEOUT_SECONDS,
374
+ cwd: @workspace.respond_to?(:root) ? @workspace.root.to_s : nil
375
+ }.compact
376
+ end
377
+
378
+ def file_change_payload(name, args)
379
+ operation = name.to_s.start_with?("edit") ? "edit" : "write"
380
+ path = args[:path] || args["path"]
381
+ payload = {
382
+ tool_name: name,
383
+ operation: operation,
384
+ path: path,
385
+ files: [{ path: path, operation: operation }]
386
+ }
387
+ if operation == "edit"
388
+ payload[:edits] = args[:edits] || args["edits"]
389
+ else
390
+ payload[:content] = args[:content] || args["content"]
391
+ end
392
+ payload.compact
393
+ end
394
+
395
+ def shell_arguments(payload, fallback)
396
+ fallback.merge(
397
+ command: payload[:command] || payload["command"] || fallback[:command] || fallback["command"],
398
+ timeout_seconds: payload[:timeout_seconds] || payload["timeout_seconds"] || fallback[:timeout_seconds] || fallback["timeout_seconds"]
399
+ )
400
+ end
401
+
402
+ def hook_denied_content(result, fallback)
403
+ "Declined: #{result.decision.message || fallback}"
404
+ end
405
+
406
+ def hook_approval_denied?(result, tool_call, name, args, cancellation)
407
+ return true unless @tool_approval
408
+
409
+ approval_args = args.merge("hook_message" => result.decision.message)
410
+ @tool_approval.call(tool_call: tool_call, name: name, args: approval_args, cancellation: cancellation) == false
411
+ end
412
+
413
+ def mcp_tool?(name)
414
+ metadata = metadata_for(name)
415
+ (metadata[:source] || metadata["source"]).to_s == "mcp"
416
+ end
417
+
173
418
  def mutation_tool?(name)
174
419
  ToolCall.write_lock_required?(name)
175
420
  end
176
421
 
422
+ def tool_approval_denied?(tool_call, name, args, cancellation)
423
+ return false unless @tool_approval
424
+
425
+ @tool_approval.call(tool_call: tool_call, name: name, args: args, cancellation: cancellation) == false
426
+ end
427
+
177
428
  def write_lock_owned?
178
429
  return true unless @write_lock
179
430
 
@@ -202,7 +453,7 @@ module Kward
202
453
  Tools::WebSearch.new(web_search: @web_search),
203
454
  Tools::FetchContent.new(web_fetch: @web_fetch),
204
455
  Tools::FetchRaw.new(web_fetch: @web_fetch),
205
- Tools::ReadSkill.new,
456
+ Tools::ReadSkill.new(skills: discovered_skills),
206
457
  Tools::AskUserQuestion.new(prompt: @prompt)
207
458
  ] + mcp_tool_values
208
459
  end
@@ -229,9 +480,16 @@ module Kward
229
480
  true
230
481
  end
231
482
 
483
+ def discovered_skills(workspace_root: nil)
484
+ return @skills unless @skills.nil?
485
+
486
+ @discovered_skills ||= {}
487
+ root = workspace_root || (@workspace.respond_to?(:root) ? @workspace.root : Dir.pwd)
488
+ @discovered_skills[root] ||= ConfigFiles.skills(workspace_root: root)
489
+ end
490
+
232
491
  def skills_available?
233
- skills = @skills.nil? ? ConfigFiles.skills : @skills
234
- skills.any?
492
+ discovered_skills.any?
235
493
  end
236
494
 
237
495
  def mcp_tool_values
@@ -5,7 +5,9 @@ require "net/http"
5
5
  require "open3"
6
6
  require "pathname"
7
7
  require "uri"
8
+ require_relative "../../path_guard"
8
9
  require_relative "../../config_files"
10
+ require_relative "web"
9
11
 
10
12
  # Namespace for the Kward CLI agent runtime.
11
13
  module Kward
@@ -24,7 +26,7 @@ module Kward
24
26
  ACTIONS = %w[package_search github_search repo_clone repo_search repo_read list_cache refresh_cache clear_cache].freeze
25
27
 
26
28
  # Creates an object for code search and repository cache operations.
27
- def initialize(cache_root: nil, http_client: NetHttpClient.new, git_runner: GitRunner.new, max_output_bytes: MAX_OUTPUT_BYTES)
29
+ def initialize(cache_root: nil, http_client: WebSearch::NetHttpClient.new(user_agent: "Kward code_search"), git_runner: GitRunner.new, max_output_bytes: MAX_OUTPUT_BYTES)
28
30
  @cache_root = File.expand_path(cache_root || ConfigFiles.code_search_cache_dir)
29
31
  @http_client = http_client
30
32
  @git_runner = git_runner
@@ -49,26 +51,6 @@ module Kward
49
51
  "Error: code_search failed: #{redact(e.message)}"
50
52
  end
51
53
 
52
- # HTTP adapter used by code search registry/package lookups.
53
- class NetHttpClient
54
- def get_json(url, headers: {})
55
- JSON.parse(get_text(url, headers: headers.merge("Accept" => "application/json")))
56
- end
57
-
58
- def get_text(url, headers: {})
59
- uri = URI(url)
60
- request = Net::HTTP::Get.new(uri)
61
- request["User-Agent"] = "Kward code_search"
62
- headers.each { |key, val| request[key] = val }
63
- response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: HTTP_TIMEOUT_SECONDS, read_timeout: HTTP_TIMEOUT_SECONDS) do |http|
64
- http.request(request)
65
- end
66
- raise "HTTP #{response.code} from #{uri.host}" unless response.is_a?(Net::HTTPSuccess)
67
-
68
- response.body
69
- end
70
- end
71
-
72
54
  # Git command adapter used by repository cache operations.
73
55
  class GitRunner
74
56
  def run(*args, chdir: nil)
@@ -376,7 +358,7 @@ module Kward
376
358
  end
377
359
 
378
360
  def inside_root?(root, path)
379
- path == root || path.start_with?(root + File::SEPARATOR)
361
+ PathGuard.inside?(path, root)
380
362
  end
381
363
 
382
364
  def inside_cache?(path)
@@ -54,7 +54,10 @@ module Kward
54
54
  return "Error: queries must be an array with 1-#{MAX_QUERIES} strings" unless valid_queries?(queries)
55
55
 
56
56
  max_results = bounded_max_results(args_value(args, "max_results") || args_value(args, "num_results"))
57
- provider = normalize_provider(args_value(args, "provider") || config_value("provider") || "auto")
57
+ provider_value = args_value(args, "provider")
58
+ provider_value = config_value("provider") if provider_value.nil?
59
+ provider_value = "auto" if provider_value.nil? || provider_value.to_s.empty?
60
+ provider = normalize_provider(provider_value)
58
61
  return "Error: provider must be one of: #{PROVIDERS.join(", ")}" unless provider
59
62
 
60
63
  options = {
@@ -549,8 +552,10 @@ module Kward
549
552
 
550
553
  def args_value(args, key)
551
554
  return nil unless args.is_a?(Hash)
555
+ return args[key] if args.key?(key)
556
+ return args[key.to_sym] if args.key?(key.to_sym)
552
557
 
553
- args[key] || args[key.to_sym]
558
+ nil
554
559
  end
555
560
 
556
561
  def success?(response)
@@ -717,10 +722,26 @@ module Kward
717
722
  class NetHttpClient
718
723
  Response = Struct.new(:code, :body, :headers, keyword_init: true)
719
724
 
725
+ def initialize(user_agent: nil)
726
+ @user_agent = user_agent
727
+ end
728
+
720
729
  def get(url, headers: {})
721
730
  request(url, Net::HTTP::Get, headers: headers)
722
731
  end
723
732
 
733
+ def get_json(url, headers: {})
734
+ JSON.parse(get_text(url, headers: headers.merge("Accept" => "application/json")))
735
+ end
736
+
737
+ def get_text(url, headers: {})
738
+ uri = URI.parse(url)
739
+ response = get(uri.to_s, headers: headers)
740
+ raise "HTTP #{response.code} from #{uri.host}" unless success?(response.code)
741
+
742
+ response.body
743
+ end
744
+
724
745
  def post(url, form:, headers: {})
725
746
  request(url, Net::HTTP::Post, headers: headers) do |http_request|
726
747
  http_request.set_form_data(form)
@@ -739,12 +760,17 @@ module Kward
739
760
  uri = URI.parse(url)
740
761
  Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: HTTP_TIMEOUT_SECONDS, read_timeout: HTTP_TIMEOUT_SECONDS) do |http|
741
762
  http_request = request_class.new(uri)
763
+ http_request["User-Agent"] = @user_agent if @user_agent
742
764
  headers.each { |key, value| http_request[key] = value }
743
765
  yield http_request if block_given?
744
766
  response = http.request(http_request)
745
767
  Response.new(code: response.code, body: response.body, headers: response.each_header.to_h)
746
768
  end
747
769
  end
770
+
771
+ def success?(code)
772
+ code.to_i.between?(200, 299)
773
+ end
748
774
  end
749
775
  end
750
776
  end
@@ -0,0 +1,117 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require "net/http"
4
+ require "time"
5
+ require "uri"
6
+
7
+ # Namespace for the Kward CLI agent runtime.
8
+ module Kward
9
+ # Cached RubyGems update check for the interactive startup screen.
10
+ class UpdateCheck
11
+ CHECK_INTERVAL_SECONDS = 24 * 60 * 60
12
+ LATEST_VERSION_URL = URI("https://rubygems.org/api/v1/versions/kward/latest.json")
13
+
14
+ Notice = Struct.new(:latest_version, keyword_init: true)
15
+
16
+ def initialize(current_version:, config: ConfigFiles.read_config, path: ConfigFiles.update_check_cache_path, now: Time.now)
17
+ @current_version = current_version.to_s
18
+ @config = config
19
+ @path = path
20
+ @now = now
21
+ end
22
+
23
+ def enabled?
24
+ return false if disabled_by_environment?
25
+
26
+ updates = @config["updates"]
27
+ return false if updates.is_a?(Hash) && updates["check"] == false
28
+
29
+ true
30
+ end
31
+
32
+ def notice
33
+ return nil unless enabled?
34
+
35
+ latest_version = cache["latest_version"].to_s
36
+ return nil if latest_version.empty?
37
+ return nil if dismissed_version == latest_version
38
+ return nil unless newer_than_current?(latest_version)
39
+
40
+ Notice.new(latest_version: latest_version)
41
+ end
42
+
43
+ def refresh_if_stale
44
+ return false unless enabled?
45
+ return false unless stale?
46
+
47
+ refresh
48
+ end
49
+
50
+ def refresh
51
+ latest_version = fetch_latest_version
52
+ write_cache(cache.merge(
53
+ "checked_at" => @now.utc.iso8601,
54
+ "latest_version" => latest_version
55
+ ))
56
+ true
57
+ rescue StandardError
58
+ false
59
+ end
60
+
61
+ private
62
+
63
+ def disabled_by_environment?
64
+ value = ENV["KWARD_DISABLE_UPDATE_CHECK"].to_s.strip.downcase
65
+ ["1", "true", "yes", "on"].include?(value)
66
+ end
67
+
68
+ def dismissed_version
69
+ cache["dismissed_version"].to_s
70
+ end
71
+
72
+ def stale?
73
+ checked_at = Time.iso8601(cache["checked_at"].to_s)
74
+ (@now - checked_at) >= CHECK_INTERVAL_SECONDS
75
+ rescue ArgumentError, TypeError
76
+ true
77
+ end
78
+
79
+ def cache
80
+ @cache ||= read_cache
81
+ end
82
+
83
+ def read_cache
84
+ return {} unless File.file?(@path)
85
+
86
+ data = JSON.parse(File.read(@path))
87
+ data.is_a?(Hash) ? data : {}
88
+ rescue JSON::ParserError, SystemCallError
89
+ {}
90
+ end
91
+
92
+ def write_cache(data)
93
+ FileUtils.mkdir_p(File.dirname(@path))
94
+ File.write(@path, JSON.pretty_generate(data) + "\n")
95
+ @cache = data
96
+ end
97
+
98
+ def fetch_latest_version
99
+ request = Net::HTTP::Get.new(LATEST_VERSION_URL)
100
+ request["User-Agent"] = "kward/#{Kward::VERSION}"
101
+ response = Net::HTTP.start(LATEST_VERSION_URL.hostname, LATEST_VERSION_URL.port, use_ssl: true, open_timeout: 2, read_timeout: 2) do |http|
102
+ http.request(request)
103
+ end
104
+ return "" unless response.is_a?(Net::HTTPSuccess)
105
+
106
+ JSON.parse(response.body)["version"].to_s
107
+ rescue JSON::ParserError
108
+ ""
109
+ end
110
+
111
+ def newer_than_current?(version)
112
+ Gem::Version.new(version) > Gem::Version.new(@current_version)
113
+ rescue ArgumentError
114
+ false
115
+ end
116
+ end
117
+ end
data/lib/kward/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # Namespace for the Kward CLI agent runtime.
2
2
  module Kward
3
3
  # Current gem version.
4
- VERSION = "0.74.0"
4
+ VERSION = "0.75.0"
5
5
  end