kward 0.73.1 → 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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -2
  5. data/Rakefile +18 -0
  6. data/doc/configuration.md +70 -4
  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/mcp.md +72 -0
  11. data/doc/memory.md +1 -1
  12. data/doc/personas.md +2 -2
  13. data/doc/plugins.md +1 -1
  14. data/doc/releasing.md +7 -15
  15. data/doc/rpc.md +108 -15
  16. data/doc/session-management.md +9 -9
  17. data/doc/skills.md +199 -0
  18. data/doc/usage.md +14 -7
  19. data/lib/kward/agent.rb +72 -22
  20. data/lib/kward/auth/anthropic_oauth.rb +5 -87
  21. data/lib/kward/auth/oauth_helpers.rb +105 -0
  22. data/lib/kward/auth/openai_oauth.rb +4 -86
  23. data/lib/kward/cli/commands.rb +12 -2
  24. data/lib/kward/cli/compaction.rb +7 -0
  25. data/lib/kward/cli/doctor.rb +18 -10
  26. data/lib/kward/cli/git.rb +30 -3
  27. data/lib/kward/cli/hook_commands.rb +258 -0
  28. data/lib/kward/cli/interactive_turn.rb +9 -3
  29. data/lib/kward/cli/memory_commands.rb +1 -5
  30. data/lib/kward/cli/plugins.rb +41 -1
  31. data/lib/kward/cli/prompt_interface.rb +32 -3
  32. data/lib/kward/cli/rendering.rb +1 -1
  33. data/lib/kward/cli/runtime_helpers.rb +44 -5
  34. data/lib/kward/cli/sessions.rb +13 -0
  35. data/lib/kward/cli/settings.rb +226 -152
  36. data/lib/kward/cli/slash_commands.rb +81 -42
  37. data/lib/kward/cli/tabs.rb +32 -15
  38. data/lib/kward/cli.rb +64 -4
  39. data/lib/kward/cli_transcript_formatter.rb +0 -7
  40. data/lib/kward/compaction/token_estimator.rb +112 -0
  41. data/lib/kward/compactor.rb +42 -116
  42. data/lib/kward/config_files.rb +152 -27
  43. data/lib/kward/deep_copy.rb +42 -0
  44. data/lib/kward/diff_view_mode.rb +36 -0
  45. data/lib/kward/events.rb +1 -0
  46. data/lib/kward/export_path.rb +2 -6
  47. data/lib/kward/frontmatter.rb +43 -0
  48. data/lib/kward/hooks/audit_log.rb +121 -0
  49. data/lib/kward/hooks/catalog.rb +110 -0
  50. data/lib/kward/hooks/command_handler.rb +66 -0
  51. data/lib/kward/hooks/config_loader.rb +104 -0
  52. data/lib/kward/hooks/decision.rb +102 -0
  53. data/lib/kward/hooks/event.rb +60 -0
  54. data/lib/kward/hooks/http_handler.rb +72 -0
  55. data/lib/kward/hooks/manager.rb +158 -0
  56. data/lib/kward/hooks/matcher.rb +75 -0
  57. data/lib/kward/hooks.rb +16 -0
  58. data/lib/kward/markdown_transcript.rb +4 -3
  59. data/lib/kward/mcp/client.rb +56 -0
  60. data/lib/kward/mcp/server_config.rb +59 -0
  61. data/lib/kward/mcp/stdio_transport.rb +112 -0
  62. data/lib/kward/memory/turn_context.rb +19 -0
  63. data/lib/kward/message_access.rb +2 -2
  64. data/lib/kward/message_text.rb +19 -0
  65. data/lib/kward/model/client.rb +13 -45
  66. data/lib/kward/model/context_usage.rb +10 -4
  67. data/lib/kward/model/copilot_models.rb +44 -0
  68. data/lib/kward/model/model_info.rb +24 -0
  69. data/lib/kward/model/payloads.rb +15 -17
  70. data/lib/kward/model/stream_parser.rb +178 -13
  71. data/lib/kward/pan/server.rb +51 -2
  72. data/lib/kward/path_guard.rb +13 -0
  73. data/lib/kward/plugin_registry.rb +86 -25
  74. data/lib/kward/prompt_interface/editor/controller.rb +124 -4
  75. data/lib/kward/prompt_interface/editor/modes/emacs.rb +4 -0
  76. data/lib/kward/prompt_interface/editor/modes/modern.rb +10 -4
  77. data/lib/kward/prompt_interface/editor/modes/vibe.rb +448 -20
  78. data/lib/kward/prompt_interface/editor/renderer.rb +27 -0
  79. data/lib/kward/prompt_interface/editor/search.rb +70 -13
  80. data/lib/kward/prompt_interface/editor/state.rb +72 -13
  81. data/lib/kward/prompt_interface/editor/vibe_state.rb +12 -1
  82. data/lib/kward/prompt_interface/selection_prompt.rb +26 -6
  83. data/lib/kward/prompt_interface.rb +4 -1
  84. data/lib/kward/prompts/commands.rb +3 -3
  85. data/lib/kward/prompts.rb +7 -7
  86. data/lib/kward/rpc/mcp_status.rb +50 -0
  87. data/lib/kward/rpc/prompt_bridge.rb +38 -4
  88. data/lib/kward/rpc/runtime_payloads.rb +1 -1
  89. data/lib/kward/rpc/server.rb +94 -13
  90. data/lib/kward/rpc/session_manager.rb +258 -33
  91. data/lib/kward/rpc/session_metrics.rb +1 -1
  92. data/lib/kward/rpc/session_tree_rows.rb +13 -64
  93. data/lib/kward/rpc/tool_event_normalizer.rb +16 -2
  94. data/lib/kward/rpc/transcript_normalizer.rb +9 -10
  95. data/lib/kward/rpc/turn_context.rb +109 -0
  96. data/lib/kward/session_store.rb +6 -20
  97. data/lib/kward/session_tree_nodes.rb +98 -18
  98. data/lib/kward/session_tree_renderer.rb +6 -61
  99. data/lib/kward/skills/registry.rb +109 -17
  100. data/lib/kward/tools/mcp_tool.rb +122 -0
  101. data/lib/kward/tools/read_skill.rb +9 -4
  102. data/lib/kward/tools/registry.rb +297 -9
  103. data/lib/kward/tools/search/code.rb +4 -22
  104. data/lib/kward/tools/search/web.rb +28 -2
  105. data/lib/kward/update_check.rb +117 -0
  106. data/lib/kward/version.rb +1 -1
  107. data/lib/kward/workers/manager.rb +43 -3
  108. data/lib/kward/workspace.rb +3 -1
  109. data/templates/default/fulldoc/html/css/kward.css +22 -0
  110. data/templates/default/fulldoc/html/js/kward.js +2 -0
  111. data/templates/default/kward_navigation.rb +4 -2
  112. data/templates/default/layout/html/setup.rb +2 -0
  113. metadata +30 -1
@@ -1,6 +1,7 @@
1
1
  require "json"
2
2
  require_relative "model/chat_invocation"
3
3
  require_relative "compaction/file_operation_tracker"
4
+ require_relative "compaction/token_estimator"
4
5
  require_relative "config_files"
5
6
  require_relative "message_access"
6
7
  require_relative "prompts"
@@ -74,111 +75,6 @@ module Kward
74
75
  end
75
76
  end
76
77
 
77
- # Compaction support object used by conversation summarization.
78
- class TokenEstimator
79
- def estimate_tokens(text)
80
- (text.to_s.length / 4.0).ceil
81
- end
82
-
83
- def messages_tokens(messages)
84
- Array(messages).sum { |message| message_tokens(message) }
85
- end
86
-
87
- def context_tokens(messages)
88
- messages = Array(messages)
89
- usage_info = last_assistant_usage_info(messages)
90
- return messages_tokens(messages) unless usage_info
91
-
92
- usage_tokens = usage_tokens(usage_info[:usage])
93
- trailing_tokens = messages[(usage_info[:index] + 1)..].to_a.sum { |message| message_tokens(message) }
94
- usage_tokens + trailing_tokens
95
- end
96
-
97
- def message_tokens(message)
98
- role = value(message, :role)
99
- parts = [role]
100
- if role.to_s == "compactionSummary"
101
- parts << value(message, :summary)
102
- else
103
- parts << content_text(value(message, :content))
104
- end
105
- parts << value(message, :reasoning_summary)
106
- tool_calls(message).each do |tool_call|
107
- parts << tool_call_name(tool_call)
108
- parts << tool_call_arguments(tool_call)
109
- end
110
- parts << value(message, :tool_call_id)
111
- parts << value(message, :name)
112
- estimate_tokens(parts.compact.join("\n"))
113
- end
114
-
115
- private
116
-
117
- def content_text(content)
118
- return content.to_s unless content.is_a?(Array)
119
-
120
- content.filter_map do |part|
121
- type = value(part, :type)
122
- if type == "text"
123
- value(part, :text)
124
- elsif type == "image"
125
- path = value(part, :path)
126
- media_type = value(part, :media_type) || value(part, :mimeType) || "image"
127
- "[#{media_type}#{path ? ": #{path}" : ""}]"
128
- end
129
- end.join("\n")
130
- end
131
-
132
- def tool_calls(message)
133
- MessageAccess.tool_calls(message)
134
- end
135
-
136
- def tool_call_name(tool_call)
137
- function = value(tool_call, :function) || {}
138
- value(function, :name)
139
- end
140
-
141
- def tool_call_arguments(tool_call)
142
- function = value(tool_call, :function) || {}
143
- arguments = value(function, :arguments)
144
- arguments.is_a?(Hash) ? JSON.dump(arguments) : arguments.to_s
145
- end
146
-
147
- def last_assistant_usage_info(messages)
148
- messages.each_with_index.reverse_each do |message, index|
149
- next unless value(message, :role).to_s == "assistant"
150
-
151
- usage = value(message, :usage)
152
- tokens = usage_tokens(usage)
153
- return { usage: usage, index: index } if tokens.positive?
154
- end
155
- nil
156
- end
157
-
158
- def usage_tokens(usage)
159
- return 0 unless usage.respond_to?(:key?)
160
-
161
- total = usage_value(usage, :total_tokens, "totalTokens")
162
- return total if total.positive?
163
-
164
- usage_value(usage, :input_tokens, "input", "prompt_tokens") +
165
- usage_value(usage, :output_tokens, "output", "completion_tokens") +
166
- usage_value(usage, :cache_read_tokens, "cacheRead", "cacheReadTokens", "cache_read", "cached_tokens") +
167
- usage_value(usage, :cache_write_tokens, "cacheWrite", "cacheWriteTokens", "cache_write")
168
- end
169
-
170
- def usage_value(usage, *keys)
171
- key = keys.find { |candidate| usage.key?(candidate) || usage.key?(candidate.to_s) }
172
- return 0 unless key
173
-
174
- (usage[key] || usage[key.to_s]).to_i
175
- end
176
-
177
- def value(object, key)
178
- ToolCall.value(object, key)
179
- end
180
- end
181
-
182
78
  # Compaction support object used by conversation summarization.
183
79
  class ConversationSerializer
184
80
  TOOL_RESULT_LIMIT = 2_000
@@ -324,17 +220,15 @@ module Kward
324
220
  end
325
221
 
326
222
  def tool_call_id(tool_call)
327
- tool_call["id"] || tool_call[:id]
223
+ ToolCall.id(tool_call)
328
224
  end
329
225
 
330
226
  def tool_call_name(tool_call)
331
- function = tool_call["function"] || tool_call[:function] || {}
332
- function["name"] || function[:name] || "unknown_tool"
227
+ ToolCall.name(tool_call) || "unknown_tool"
333
228
  end
334
229
 
335
230
  def tool_call_args(tool_call)
336
- function = tool_call["function"] || tool_call[:function] || {}
337
- ToolCall.parse_arguments(function["arguments"] || function[:arguments])
231
+ ToolCall.arguments(tool_call)
338
232
  end
339
233
 
340
234
  def tool_command(tool_call)
@@ -449,7 +343,7 @@ module Kward
449
343
  def call
450
344
  branch_entries = entry_messages(@conversation.messages)
451
345
  raise NothingToCompact, "Nothing to compact" if branch_entries.empty?
452
- raise AlreadyCompacted, "Already compacted" if compaction_entry?(branch_entries.last) || already_compacted?
346
+ raise AlreadyCompacted, "Already compacted" if compaction_entry?(branch_entries.last)
453
347
 
454
348
  previous_index = latest_previous_compaction_index(branch_entries)
455
349
  previous_entry = previous_index ? branch_entries[previous_index] : nil
@@ -464,7 +358,8 @@ module Kward
464
358
  first_kept_entry_id = entry_id(branch_entries[first_kept_index], first_kept_index)
465
359
  summarized_for_file_ops = cut.messages_to_summarize + cut.turn_prefix_messages
466
360
  file_ops = @file_operation_tracker.call(summarized_for_file_ops, previous_details: compaction_details(previous_entry))
467
- kept_messages = Array(cut.preserved_messages) + (branch_entries[cut.first_kept_index..] || [])
361
+ preserved_skill_messages = activated_skill_messages(summarized_for_file_ops)
362
+ kept_messages = preserved_skill_messages + Array(cut.preserved_messages) + (branch_entries[cut.first_kept_index..] || [])
468
363
 
469
364
  PreparationResult.new(
470
365
  first_kept_entry_id: first_kept_entry_id,
@@ -485,10 +380,6 @@ module Kward
485
380
  Array(messages).reject { |message| message_role(message) == "system" }
486
381
  end
487
382
 
488
- def already_compacted?
489
- @conversation.respond_to?(:last_entry_compaction?) && @conversation.last_entry_compaction?
490
- end
491
-
492
383
  def latest_previous_compaction_index(entries)
493
384
  (0...entries.length).to_a.reverse.find { |index| compaction_entry?(entries[index]) }
494
385
  end
@@ -503,6 +394,41 @@ module Kward
503
394
  previous_index + 1
504
395
  end
505
396
 
397
+ def activated_skill_messages(messages)
398
+ Array(messages).each_with_index.flat_map do |message, index|
399
+ next [] unless read_skill_tool_message?(message)
400
+
401
+ tool_call_id = MessageAccess.tool_call_id(message).to_s
402
+ tool_call_id = "preserved_read_skill_#{index}" if tool_call_id.empty?
403
+ skill_name = skill_name_from_content(MessageAccess.content(message)) || "unknown"
404
+ [
405
+ {
406
+ "role" => "assistant",
407
+ "content" => nil,
408
+ "tool_calls" => [
409
+ {
410
+ "id" => tool_call_id,
411
+ "type" => "function",
412
+ "function" => {
413
+ "name" => "read_skill",
414
+ "arguments" => JSON.generate(name: skill_name)
415
+ }
416
+ }
417
+ ]
418
+ },
419
+ message
420
+ ]
421
+ end
422
+ end
423
+
424
+ def read_skill_tool_message?(message)
425
+ message_role(message) == "tool" && MessageAccess.tool_name(message) == "read_skill" && MessageAccess.content(message).to_s.include?("<skill_content")
426
+ end
427
+
428
+ def skill_name_from_content(content)
429
+ content.to_s[/<skill_content name="([^"]+)"/, 1]
430
+ end
431
+
506
432
  def compaction_entry?(message)
507
433
  message_role(message) == "compactionSummary"
508
434
  end
@@ -2,9 +2,12 @@ require "digest"
2
2
  require "fileutils"
3
3
  require "json"
4
4
  require "yaml"
5
+ require_relative "frontmatter"
5
6
  require_relative "private_file"
7
+ require_relative "path_guard"
6
8
  require_relative "ekwsh"
7
9
  require_relative "editor_mode"
10
+ require_relative "diff_view_mode"
8
11
  require_relative "prompts/templates"
9
12
  require_relative "skills/registry"
10
13
 
@@ -22,6 +25,17 @@ module Kward
22
25
  # config, prompt, skill, plugin, cache, memory, and session locations instead of
23
26
  # reconstructing `~/.kward` paths independently.
24
27
  module ConfigFiles
28
+ class ConfigError < StandardError
29
+ attr_reader :path, :format, :detail
30
+
31
+ def initialize(path:, format:, detail:)
32
+ @path = path
33
+ @format = format
34
+ @detail = detail
35
+ super("Invalid Kward config #{format}: #{path}: #{detail}")
36
+ end
37
+ end
38
+
25
39
  MAX_SKILL_FILE_BYTES = 100_000
26
40
  MAX_PROMPT_FILE_BYTES = 32 * 1024
27
41
  DEFAULT_OVERLAY_SETTINGS = { "alignment" => "center", "width" => "maximum" }.freeze
@@ -30,7 +44,7 @@ module Kward
30
44
  {
31
45
  "key" => "kward",
32
46
  "label" => "Kward",
33
- "instruction" => "Your name is Kward, the grim Andruid - robotic keeper of the Forrest of Code, protecting the nature of good engineering priciples. Speak like an old druid, be suspicous of everyone, but with a good intend."
47
+ "instruction" => "Your name is Kward, the grim Andruid - robotic keeper of the Forest of Code, protecting the nature of good engineering principles. Speak like an old druid, be suspicious of everyone, but with good intent."
34
48
  }
35
49
  ],
36
50
  "default" => "kward"
@@ -38,15 +52,25 @@ module Kward
38
52
  OVERLAY_ALIGNMENTS = %w[left center right].freeze
39
53
  OVERLAY_WIDTHS = %w[capped maximum].freeze
40
54
 
41
- Skill = Struct.new(:name, :description, :folder, :path, keyword_init: true)
55
+ Skill = Struct.new(:name, :description, :folder, :path, :license, :compatibility, :metadata, :allowed_tools, keyword_init: true)
42
56
  PromptTemplate = Struct.new(:command, :description, :argument_hint, :body, :path, keyword_init: true) do
43
57
  def expand(arguments)
44
58
  body.gsub("$ARGUMENTS", arguments.to_s)
45
59
  end
46
60
  end
47
61
 
62
+ @skip_config = false
63
+
48
64
  module_function
49
65
 
66
+ def skip_config=(value)
67
+ @skip_config = value
68
+ end
69
+
70
+ def skip_config?
71
+ @skip_config == true
72
+ end
73
+
50
74
  # Directory that contains Kward's user config and adjacent prompt/skill
51
75
  # data. Defaults to `~/.kward`, or the directory of `KWARD_CONFIG_PATH`.
52
76
  #
@@ -71,6 +95,14 @@ module Kward
71
95
  File.join(config_dir, "ekwsh.yml")
72
96
  end
73
97
 
98
+ def workspace_hooks_path(root = Dir.pwd)
99
+ File.join(File.expand_path(root), ".kward", "hooks.json")
100
+ end
101
+
102
+ def trusted_workspace_hooks_path
103
+ File.join(config_dir, "trusted_workspace_hooks.json")
104
+ end
105
+
74
106
  def default_config
75
107
  {
76
108
  "personas" => JSON.parse(JSON.generate(DEFAULT_PERSONAS)),
@@ -88,12 +120,23 @@ module Kward
88
120
  "auto_close_pairs" => true,
89
121
  "soft_wrap" => true,
90
122
  "bar_cursor" => true,
91
- "line_numbers" => "absolute"
123
+ "line_numbers" => "absolute",
124
+ "diff_view" => "auto"
125
+ },
126
+ "overlay" => DEFAULT_OVERLAY_SETTINGS.dup,
127
+ "web_search" => {
128
+ "enabled" => true,
129
+ "provider" => "auto",
130
+ "allow_model_providers" => false
92
131
  },
93
132
  "sessions" => {
94
133
  "auto_resume" => false
95
134
  },
135
+ "skills" => {
136
+ "trust_project" => false
137
+ },
96
138
  "enforce_workspace_agents_file" => false,
139
+ "mcpServers" => {},
97
140
  "tools" => {
98
141
  "workspace_guardrails" => true
99
142
  }
@@ -103,6 +146,7 @@ module Kward
103
146
  # Performs ensure default config for configuration file and path handling.
104
147
  def ensure_default_config!(path = config_path)
105
148
  path = File.expand_path(path)
149
+ return false if skip_config? && path == config_path
106
150
  return false if File.exist?(path)
107
151
 
108
152
  write_config(default_config, path)
@@ -117,6 +161,10 @@ module Kward
117
161
  File.join(cache_dir, "openrouter_models.json")
118
162
  end
119
163
 
164
+ def update_check_cache_path
165
+ File.join(cache_dir, "update_check.json")
166
+ end
167
+
120
168
  def project_browser_state_path
121
169
  File.join(cache_dir, "project_browser_state.json")
122
170
  end
@@ -156,11 +204,12 @@ module Kward
156
204
  # @return [Hash] parsed config object
157
205
  def read_config(path = config_path)
158
206
  path = File.expand_path(path)
207
+ return {} if skip_config? && path == config_path
159
208
  return {} unless File.exist?(path)
160
209
 
161
210
  JSON.parse(File.read(path))
162
- rescue JSON::ParserError
163
- raise "Invalid Kward config JSON: #{path}"
211
+ rescue JSON::ParserError => e
212
+ raise ConfigError.new(path: path, format: "JSON", detail: e.message)
164
213
  end
165
214
 
166
215
  # Writes config JSON using private file permissions.
@@ -168,9 +217,78 @@ module Kward
168
217
  # @param config [Hash] config object to persist
169
218
  # @param path [String] config file path
170
219
  def write_config(config, path = config_path)
220
+ path = File.expand_path(path)
221
+ raise "Cannot write Kward config while --skip-config is active: #{path}" if skip_config? && path == config_path
222
+
171
223
  PrivateFile.write_json(path, config)
172
224
  end
173
225
 
226
+ def lifecycle_hooks_config(workspace_root = Dir.pwd)
227
+ config = read_config
228
+ workspace_config = read_trusted_workspace_hooks_config(workspace_root)
229
+ return config if workspace_config.empty?
230
+
231
+ merge_hooks_config(config, workspace_config)
232
+ end
233
+
234
+ def read_trusted_workspace_hooks_config(workspace_root = Dir.pwd)
235
+ path = workspace_hooks_path(workspace_root)
236
+ return {} unless workspace_hooks_trusted?(workspace_root)
237
+
238
+ read_config(path)
239
+ end
240
+
241
+ def workspace_hooks_trusted?(workspace_root = Dir.pwd)
242
+ path = workspace_hooks_path(workspace_root)
243
+ return false unless File.file?(path)
244
+
245
+ trusted = read_trusted_workspace_hooks
246
+ trusted[File.expand_path(path)] == workspace_hooks_digest(workspace_root)
247
+ end
248
+
249
+ def trust_workspace_hooks!(workspace_root = Dir.pwd)
250
+ path = workspace_hooks_path(workspace_root)
251
+ raise "No workspace hook config found: #{path}" unless File.file?(path)
252
+
253
+ trusted = read_trusted_workspace_hooks
254
+ trusted[File.expand_path(path)] = workspace_hooks_digest(workspace_root)
255
+ PrivateFile.write_json(trusted_workspace_hooks_path, trusted)
256
+ end
257
+
258
+ def untrust_workspace_hooks!(workspace_root = Dir.pwd)
259
+ path = File.expand_path(workspace_hooks_path(workspace_root))
260
+ trusted = read_trusted_workspace_hooks
261
+ trusted.delete(path)
262
+ PrivateFile.write_json(trusted_workspace_hooks_path, trusted)
263
+ end
264
+
265
+ def workspace_hooks_digest(workspace_root = Dir.pwd)
266
+ Digest::SHA256.file(workspace_hooks_path(workspace_root)).hexdigest
267
+ end
268
+
269
+ def read_trusted_workspace_hooks
270
+ config = read_config(trusted_workspace_hooks_path)
271
+ config.is_a?(Hash) ? config : {}
272
+ rescue ConfigError
273
+ {}
274
+ end
275
+
276
+ def merge_hooks_config(config, workspace_config)
277
+ merged = JSON.parse(JSON.generate(config || {}))
278
+ merged["hooks"] = merge_hook_maps(merged["hooks"], workspace_config["hooks"] || workspace_config[:hooks])
279
+ merged
280
+ end
281
+
282
+ def merge_hook_maps(left, right)
283
+ left = left.is_a?(Hash) ? left : {}
284
+ right = right.is_a?(Hash) ? right : {}
285
+ merged = JSON.parse(JSON.generate(left))
286
+ right.each do |event, entries|
287
+ merged[event.to_s] = Array(merged[event.to_s]) + Array(entries)
288
+ end
289
+ merged
290
+ end
291
+
174
292
  def read_ekwsh_config(path = ekwsh_config_path)
175
293
  path = File.expand_path(path)
176
294
  return normalize_ekwsh_config(nil) unless File.exist?(path)
@@ -337,12 +455,24 @@ module Kward
337
455
  EditorMode.normalize_line_numbers(editor["line_numbers"])
338
456
  end
339
457
 
458
+ # Returns the integrated diff viewer display mode.
459
+ def diff_view(config = read_config)
460
+ editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
461
+ DiffViewMode.normalize(editor["diff_view"])
462
+ end
463
+
340
464
  # Returns whether file tools must stay inside the active workspace root.
341
465
  def workspace_guardrails_enabled?(config = read_config)
342
466
  tools = config["tools"].is_a?(Hash) ? config["tools"] : {}
343
467
  tools["workspace_guardrails"] != false
344
468
  end
345
469
 
470
+ # Returns whether project-level Agent Skills should be loaded from the workspace.
471
+ def project_skills_trusted?(config = read_config)
472
+ skills = config["skills"].is_a?(Hash) ? config["skills"] : {}
473
+ skills["trust_project"] == true
474
+ end
475
+
346
476
  # Returns whether new frontends should resume the last active session automatically.
347
477
  def session_auto_resume_enabled?(config = read_config)
348
478
  sessions = config["sessions"].is_a?(Hash) ? config["sessions"] : {}
@@ -361,12 +491,18 @@ module Kward
361
491
  value.is_a?(Hash) ? value : {}
362
492
  end
363
493
 
494
+ # Returns configured MCP stdio servers, or an empty config when absent.
495
+ def mcp_servers(config = read_config)
496
+ value = config["mcpServers"] || config.dig("mcp", "servers")
497
+ value.is_a?(Hash) ? value : {}
498
+ end
499
+
364
500
  # Validates and persists terminal overlay settings.
365
501
  def update_overlay_settings(values)
366
502
  raise "Overlay settings must be an object" unless values.is_a?(Hash)
367
503
 
368
504
  config = read_config
369
- overlay = config["overlay"].is_a?(Hash) ? config["overlay"].dup : {}
505
+ overlay = overlay_settings(config)
370
506
  values.each do |key, value|
371
507
  key = key.to_s
372
508
  value = value.to_s
@@ -641,8 +777,8 @@ module Kward
641
777
  # Lists configured skills discovered under the config directory.
642
778
  #
643
779
  # @return [Array<Skill>] skill metadata available to the model
644
- def skills
645
- skills_registry.skills
780
+ def skills(workspace_root: Dir.pwd)
781
+ skills_registry(workspace_root: workspace_root).skills
646
782
  end
647
783
 
648
784
  # @return [String] trusted user plugin directory
@@ -679,16 +815,18 @@ module Kward
679
815
  # @param name [String] configured skill name
680
816
  # @param relative_path [String, nil] path inside the skill directory
681
817
  # @return [String] file contents or an error string
682
- def read_skill_file(name, relative_path = nil)
683
- skills_registry.read_skill_file(name, relative_path)
818
+ def read_skill_file(name, relative_path = nil, workspace_root: Dir.pwd)
819
+ skills_registry(workspace_root: workspace_root).read_skill_file(name, relative_path)
684
820
  end
685
821
 
686
- def skills_registry
822
+ def skills_registry(workspace_root: Dir.pwd)
687
823
  Skills::Registry.new(
688
824
  config_dir: config_dir,
825
+ workspace_root: workspace_root,
826
+ project_skills_trusted: project_skills_trusted?,
689
827
  skill_class: Skill,
690
828
  max_file_bytes: MAX_SKILL_FILE_BYTES,
691
- markdown_parser: method(:markdown_parts),
829
+ markdown_parser: ->(path) { Frontmatter.markdown_parts(path, lenient: true) },
692
830
  inside_directory: method(:inside_directory?)
693
831
  )
694
832
  end
@@ -697,25 +835,12 @@ module Kward
697
835
  Prompts::Templates.new(
698
836
  config_dir: config_dir,
699
837
  template_class: PromptTemplate,
700
- markdown_parser: method(:markdown_parts)
838
+ markdown_parser: ->(path) { Frontmatter.markdown_parts(path) }
701
839
  )
702
840
  end
703
841
 
704
- def markdown_parts(path)
705
- content = File.read(path)
706
- return [{}, content] unless content.start_with?("---\n", "---\r\n")
707
-
708
- _opening, rest = content.split(/\A---\r?\n/, 2)
709
- yaml_text, body = rest.to_s.split(/\r?\n---\r?\n/, 2)
710
- raise "missing frontmatter closing delimiter" if body.nil?
711
-
712
- data = yaml_text.to_s.empty? ? {} : YAML.safe_load(yaml_text, permitted_classes: [], aliases: false)
713
- frontmatter = data.is_a?(Hash) ? data.transform_keys(&:to_s) : {}
714
- [frontmatter, body]
715
- end
716
-
717
842
  def inside_directory?(path, base)
718
- path == base || path.start_with?(base + File::SEPARATOR)
843
+ PathGuard.inside?(path, base)
719
844
  end
720
845
 
721
846
  def presence(value)
@@ -0,0 +1,42 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Small recursive copy/freeze helpers for plain Hash/Array payload objects.
4
+ module DeepCopy
5
+ module_function
6
+
7
+ def dup(value)
8
+ case value
9
+ when Hash
10
+ value.each_with_object({}) { |(key, item), result| result[key] = dup(item) }
11
+ when Array
12
+ value.map { |item| dup(item) }
13
+ else
14
+ value.dup
15
+ end
16
+ rescue TypeError
17
+ value
18
+ end
19
+
20
+ def freeze(value)
21
+ case value
22
+ when Hash
23
+ value.each_value { |item| freeze(item) }
24
+ when Array
25
+ value.each { |item| freeze(item) }
26
+ end
27
+ value.freeze
28
+ end
29
+
30
+ def merge(left, right)
31
+ left = dup(left)
32
+ right.each do |key, value|
33
+ left[key] = if left[key].is_a?(Hash) && value.is_a?(Hash)
34
+ merge(left[key], value)
35
+ else
36
+ dup(value)
37
+ end
38
+ end
39
+ left
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,36 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Normalizes and resolves the integrated diff viewer display mode.
4
+ module DiffViewMode
5
+ AUTO = "auto"
6
+ UNIFIED = "unified"
7
+ SIDE_BY_SIDE = "side_by_side"
8
+ MODES = [AUTO, UNIFIED, SIDE_BY_SIDE].freeze
9
+ SIDE_BY_SIDE_MIN_WIDTH = 120
10
+
11
+ module_function
12
+
13
+ def normalize(value)
14
+ text = value.to_s.downcase.tr("-", "_")
15
+ MODES.include?(text) ? text : AUTO
16
+ end
17
+
18
+ def label(value)
19
+ case normalize(value)
20
+ when SIDE_BY_SIDE
21
+ "side-by-side"
22
+ when UNIFIED
23
+ "unified"
24
+ else
25
+ "auto"
26
+ end
27
+ end
28
+
29
+ def resolve(value, terminal_width: nil)
30
+ mode = normalize(value)
31
+ return mode unless mode == AUTO
32
+
33
+ terminal_width.to_i >= SIDE_BY_SIDE_MIN_WIDTH ? SIDE_BY_SIDE : UNIFIED
34
+ end
35
+ end
36
+ end
data/lib/kward/events.rb CHANGED
@@ -9,6 +9,7 @@ module Kward
9
9
  Steering = Struct.new(:input, :created_at, keyword_init: true)
10
10
  SteeringApplied = Struct.new(:count, keyword_init: true)
11
11
  ToolCall = Struct.new(:tool_call, keyword_init: true)
12
+ ToolUpdate = Struct.new(:tool_call, :content, :elapsed_ms, keyword_init: true)
12
13
  ToolResult = Struct.new(:tool_call, :content, keyword_init: true)
13
14
  Answer = Struct.new(:content, keyword_init: true)
14
15
  end
@@ -1,4 +1,5 @@
1
1
  require "pathname"
2
+ require_relative "path_guard"
2
3
 
3
4
  # Namespace for the Kward CLI agent runtime.
4
5
  module Kward
@@ -11,7 +12,7 @@ module Kward
11
12
  resolved = File.expand_path(explicit, workspace_root)
12
13
  allowed_roots = [workspace_root, session_dir].compact.map { |root| Pathname.new(root).expand_path }
13
14
  expanded = Pathname.new(resolved).expand_path
14
- unless allowed_roots.any? { |root| inside?(expanded, root) }
15
+ unless allowed_roots.any? { |root| PathGuard.inside?(expanded, root) }
15
16
  raise ArgumentError, "export path outside workspace or session directory: #{path}"
16
17
  end
17
18
 
@@ -21,10 +22,5 @@ module Kward
21
22
 
22
23
  resolved
23
24
  end
24
-
25
- def self.inside?(path, root)
26
- path.to_s == root.to_s || path.to_s.start_with?("#{root}/")
27
- end
28
- private_class_method :inside?
29
25
  end
30
26
  end
@@ -0,0 +1,43 @@
1
+ require "yaml"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Markdown frontmatter parsing shared by prompt templates and skills.
6
+ module Frontmatter
7
+ module_function
8
+
9
+ def markdown_parts(path, lenient: false)
10
+ content = File.read(path)
11
+ return [{}, content] unless content.start_with?("---\n", "---\r\n")
12
+
13
+ _opening, rest = content.split(/\A---\r?\n/, 2)
14
+ yaml_text, body = rest.to_s.split(/\r?\n---\r?\n/, 2)
15
+ raise "missing frontmatter closing delimiter" if body.nil?
16
+
17
+ data = yaml_text.to_s.empty? ? {} : load_frontmatter(yaml_text, lenient: lenient)
18
+ frontmatter = data.is_a?(Hash) ? data.transform_keys(&:to_s) : {}
19
+ [frontmatter, body]
20
+ end
21
+
22
+ def load_frontmatter(yaml_text, lenient: false)
23
+ YAML.safe_load(yaml_text, permitted_classes: [], aliases: false)
24
+ rescue Psych::SyntaxError
25
+ raise unless lenient
26
+
27
+ lenient_frontmatter(yaml_text)
28
+ end
29
+
30
+ def lenient_frontmatter(yaml_text)
31
+ yaml_text.each_line.each_with_object({}) do |line, result|
32
+ match = line.chomp.match(/\A([A-Za-z0-9_-]+):\s*(.*)\z/)
33
+ next unless match
34
+
35
+ key = match[1]
36
+ value = match[2].strip
37
+ next if value.empty?
38
+
39
+ result[key] = value.delete_prefix('"').delete_suffix('"')
40
+ end
41
+ end
42
+ end
43
+ end