kward 0.83.0 → 0.85.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 (193) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +102 -15
  3. data/CONTRIBUTING.md +74 -0
  4. data/Gemfile.lock +8 -2
  5. data/README.md +21 -1
  6. data/Rakefile +46 -2
  7. data/SECURITY.md +31 -0
  8. data/doc/agent-tools.md +13 -1
  9. data/doc/api.md +21 -2
  10. data/doc/composer.md +2 -2
  11. data/doc/configuration.md +95 -25
  12. data/doc/editor.md +28 -13
  13. data/doc/extensibility.md +2 -1
  14. data/doc/files.md +8 -4
  15. data/doc/getting-started.md +3 -0
  16. data/doc/git.md +3 -1
  17. data/doc/pan.md +25 -15
  18. data/doc/permissions.md +4 -4
  19. data/doc/platform-support.md +48 -0
  20. data/doc/plugins.md +464 -15
  21. data/doc/rpc.md +154 -16
  22. data/doc/sandboxing.md +11 -5
  23. data/doc/security.md +10 -3
  24. data/doc/session-management.md +5 -4
  25. data/doc/shell.md +62 -45
  26. data/doc/tabs.md +6 -2
  27. data/doc/transports.md +15 -0
  28. data/doc/troubleshooting.md +12 -2
  29. data/doc/usage.md +9 -6
  30. data/doc/workspace-tools.md +9 -0
  31. data/examples/plugins/space_invaders.rb +1 -1
  32. data/examples/plugins/stardate_footer.rb +2 -2
  33. data/examples/plugins/telegram/plugin.rb +1 -1
  34. data/kward.gemspec +5 -4
  35. data/lib/kward/agent.rb +30 -14
  36. data/lib/kward/cli/auth_commands.rb +34 -13
  37. data/lib/kward/cli/commands.rb +83 -62
  38. data/lib/kward/cli/compaction.rb +9 -3
  39. data/lib/kward/cli/doctor.rb +39 -17
  40. data/lib/kward/cli/hook_commands.rb +22 -12
  41. data/lib/kward/cli/interactive_turn.rb +48 -7
  42. data/lib/kward/cli/plugins.rb +81 -12
  43. data/lib/kward/cli/project_skills_commands.rb +8 -4
  44. data/lib/kward/cli/prompt_interface.rb +52 -5
  45. data/lib/kward/cli/rendering.rb +18 -9
  46. data/lib/kward/cli/runtime_helpers.rb +228 -71
  47. data/lib/kward/cli/sessions.rb +9 -5
  48. data/lib/kward/cli/settings/menus.rb +745 -0
  49. data/lib/kward/cli/settings/model.rb +327 -0
  50. data/lib/kward/cli/settings.rb +6 -1055
  51. data/lib/kward/cli/slash_commands.rb +56 -17
  52. data/lib/kward/cli/tabs.rb +244 -33
  53. data/lib/kward/cli/tool_summaries.rb +14 -0
  54. data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +14 -7
  55. data/lib/kward/cli/worktrees.rb +65 -2
  56. data/lib/kward/cli.rb +70 -30
  57. data/lib/kward/compactor.rb +18 -7
  58. data/lib/kward/config/core.rb +389 -0
  59. data/lib/kward/config/extensions.rb +96 -0
  60. data/lib/kward/config/prompts.rb +313 -0
  61. data/lib/kward/config/settings.rb +250 -0
  62. data/lib/kward/config_files.rb +14 -994
  63. data/lib/kward/conversation.rb +31 -2
  64. data/lib/kward/image_attachments.rb +1 -1
  65. data/lib/kward/model/client.rb +36 -24
  66. data/lib/kward/model/copilot_models.rb +2 -2
  67. data/lib/kward/model/model_info.rb +20 -3
  68. data/lib/kward/{openrouter_model_cache.rb → model/openrouter_model_cache.rb} +3 -3
  69. data/lib/kward/model/payloads.rb +12 -3
  70. data/lib/kward/model/provider_catalog.rb +5 -0
  71. data/lib/kward/model/stream_parser.rb +20 -4
  72. data/lib/kward/model/typesafe_client.rb +78 -0
  73. data/lib/kward/pan/index.html.erb +3 -3
  74. data/lib/kward/pan/server.rb +33 -10
  75. data/lib/kward/permissions/policy.rb +6 -2
  76. data/lib/kward/plugin_registry.rb +2 -659
  77. data/lib/kward/plugins/actions.rb +453 -0
  78. data/lib/kward/plugins/chat_contract.rb +121 -0
  79. data/lib/kward/{plugin_chat_runtime.rb → plugins/chat_runtime.rb} +62 -18
  80. data/lib/kward/plugins/host.rb +232 -0
  81. data/lib/kward/plugins/registry.rb +1190 -0
  82. data/lib/kward/plugins/resources.rb +206 -0
  83. data/lib/kward/plugins/turn_request.rb +36 -0
  84. data/lib/kward/plugins/ui.rb +219 -0
  85. data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
  86. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  87. data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
  88. data/lib/kward/prompt_interface/editor/controller.rb +30 -33
  89. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  90. data/lib/kward/prompt_interface/editor/markdown_code_block.rb +136 -0
  91. data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
  92. data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
  93. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  94. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  95. data/lib/kward/prompt_interface/editor/scratchpad_languages.rb +74 -0
  96. data/lib/kward/prompt_interface/editor/scratchpad_runner.rb +182 -0
  97. data/lib/kward/prompt_interface/editor/state.rb +11 -11
  98. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  99. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  100. data/lib/kward/prompt_interface/file_overlay.rb +71 -15
  101. data/lib/kward/prompt_interface/key_handler.rb +67 -0
  102. data/lib/kward/prompt_interface/layout.rb +1 -1
  103. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  104. data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
  105. data/lib/kward/prompt_interface/project_browser.rb +415 -14
  106. data/lib/kward/prompt_interface/runtime_state.rb +56 -2
  107. data/lib/kward/prompt_interface/screen.rb +11 -4
  108. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  109. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  110. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  111. data/lib/kward/prompt_interface.rb +151 -27
  112. data/lib/kward/prompts/commands.rb +3 -2
  113. data/lib/kward/prompts.rb +1 -1
  114. data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
  115. data/lib/kward/pty/detached_run.rb +44 -0
  116. data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +104 -30
  117. data/lib/kward/{local_command_runner.rb → pty/local_command_runner.rb} +1 -1
  118. data/lib/kward/{local_pty_command_runner.rb → pty/local_pty_runner.rb} +3 -9
  119. data/lib/kward/{pty_output_sink.rb → pty/output_sink.rb} +52 -0
  120. data/lib/kward/{pty_transcript_normalizer.rb → pty/transcript_normalizer.rb} +1 -1
  121. data/lib/kward/rpc/plugin_chat_manager.rb +30 -10
  122. data/lib/kward/rpc/prompt_bridge.rb +25 -0
  123. data/lib/kward/rpc/server.rb +91 -12
  124. data/lib/kward/rpc/session_manager.rb +147 -44
  125. data/lib/kward/rpc/session_tree_rows.rb +2 -2
  126. data/lib/kward/rpc/tool_metadata.rb +1 -1
  127. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  128. data/lib/kward/sandbox/command_runner.rb +1 -1
  129. data/lib/kward/{session_catalog.rb → sessions/catalog.rb} +1 -1
  130. data/lib/kward/{session_store.rb → sessions/store.rb} +9 -9
  131. data/lib/kward/{session_tree_nodes.rb → sessions/tree_nodes.rb} +3 -3
  132. data/lib/kward/{session_tree_renderer.rb → sessions/tree_renderer.rb} +4 -4
  133. data/lib/kward/{session_tree_tool_display.rb → sessions/tree_tool_display.rb} +1 -1
  134. data/lib/kward/{ekwsh.rb → shell/kwsh.rb} +38 -19
  135. data/lib/kward/shell/kwshrc.rb +233 -0
  136. data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +121 -28
  137. data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +2 -0
  138. data/lib/kward/{shell_prompt_session.rb → shell/prompt_session.rb} +1 -1
  139. data/lib/kward/skills/trust_store.rb +1 -1
  140. data/lib/kward/tabs/driver.rb +194 -0
  141. data/lib/kward/{tab_store.rb → tabs/store.rb} +2 -2
  142. data/lib/kward/{ansi.rb → terminal/ansi.rb} +110 -10
  143. data/lib/kward/{clipboard.rb → terminal/clipboard.rb} +1 -1
  144. data/lib/kward/{terminal_image_support.rb → terminal/image_support.rb} +1 -1
  145. data/lib/kward/{terminal_keys.rb → terminal/keys.rb} +12 -0
  146. data/lib/kward/terminal/text.rb +121 -0
  147. data/lib/kward/text_matcher.rb +18 -0
  148. data/lib/kward/tools/base.rb +18 -0
  149. data/lib/kward/tools/context_for_task.rb +15 -6
  150. data/lib/kward/tools/edit_file.rb +9 -6
  151. data/lib/kward/tools/git_commit.rb +13 -7
  152. data/lib/kward/tools/list_directory.rb +4 -4
  153. data/lib/kward/tools/open_editor.rb +41 -0
  154. data/lib/kward/tools/plugin_tool.rb +41 -0
  155. data/lib/kward/tools/prepare_shell_command.rb +1 -1
  156. data/lib/kward/tools/read_file.rb +7 -6
  157. data/lib/kward/tools/registry.rb +109 -17
  158. data/lib/kward/tools/run_shell_command.rb +10 -8
  159. data/lib/kward/tools/search/code.rb +1 -1
  160. data/lib/kward/tools/summarize_file_structure.rb +5 -5
  161. data/lib/kward/tools/tool_call.rb +3 -1
  162. data/lib/kward/tools/typesafe_evaluate.rb +81 -0
  163. data/lib/kward/tools/workspace_targets.rb +58 -0
  164. data/lib/kward/tools/write_file.rb +9 -6
  165. data/lib/kward/{export_path.rb → transcripts/export_path.rb} +1 -1
  166. data/lib/kward/{markdown_transcript.rb → transcripts/markdown_transcript.rb} +2 -2
  167. data/lib/kward/transport/contracts.rb +200 -0
  168. data/lib/kward/transport/gateway.rb +79 -35
  169. data/lib/kward/transport/plugin_chat_gateway.rb +3 -2
  170. data/lib/kward/transport.rb +1 -200
  171. data/lib/kward/version.rb +1 -1
  172. data/lib/kward/{workspace_factory.rb → workspace/factory.rb} +2 -2
  173. data/lib/kward/{project_files.rb → workspace/files.rb} +2 -2
  174. data/lib/kward/{git_worktree_manager.rb → workspace/git_worktree_manager.rb} +28 -0
  175. data/lib/kward/{workspace.rb → workspace/workspace.rb} +3 -3
  176. data/templates/default/kward_navigation.rb +1 -0
  177. data/templates/default/layout/html/footer.erb +10 -0
  178. data/templates/default/layout/html/headers.erb +23 -0
  179. data/templates/default/layout/html/layout.erb +2 -2
  180. data/templates/default/layout/html/setup.rb +41 -2
  181. metadata +94 -47
  182. data/lib/kward/scratchpad_runner.rb +0 -56
  183. data/lib/kward/tab_driver.rb +0 -90
  184. /data/lib/kward/{editor_prompt.rb → cli/editor_prompt.rb} +0 -0
  185. /data/lib/kward/{editor_prompt_session.rb → cli/editor_prompt_session.rb} +0 -0
  186. /data/lib/kward/{diff_view_mode.rb → prompt_interface/editor/diff_view_mode.rb} +0 -0
  187. /data/lib/kward/{editor_mode.rb → prompt_interface/editor/editor_mode.rb} +0 -0
  188. /data/lib/kward/{session_diff.rb → sessions/diff.rb} +0 -0
  189. /data/lib/kward/{session_naming.rb → sessions/naming.rb} +0 -0
  190. /data/lib/kward/{session_trash.rb → sessions/trash.rb} +0 -0
  191. /data/lib/kward/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
  192. /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
  193. /data/lib/kward/{path_guard.rb → workspace/path_guard.rb} +0 -0
@@ -0,0 +1,389 @@
1
+ # Core configuration paths, persistence, and shared configuration values.
2
+ module Kward
3
+ module ConfigFiles
4
+ class ConfigError < StandardError
5
+ attr_reader :path, :format, :detail
6
+
7
+ def initialize(path:, format:, detail:)
8
+ @path = path
9
+ @format = format
10
+ @detail = detail
11
+ super("Invalid Kward config #{format}: #{path}: #{detail}")
12
+ end
13
+ end
14
+
15
+ MAX_SKILL_FILE_BYTES = 100_000
16
+ MAX_PROMPT_FILE_BYTES = 32 * 1024
17
+ DEFAULT_OVERLAY_SETTINGS = { "alignment" => "center", "width" => "maximum" }.freeze
18
+ PROJECT_BROWSER_ICON_THEMES = %w[off nerd-font].freeze
19
+ DEFAULT_PERSONAS = {
20
+ "characters" => [
21
+ {
22
+ "key" => "kward",
23
+ "label" => "Kward",
24
+ "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."
25
+ }
26
+ ],
27
+ "default" => "kward"
28
+ }.freeze
29
+ OVERLAY_ALIGNMENTS = %w[left center right].freeze
30
+ OVERLAY_WIDTHS = %w[capped maximum].freeze
31
+
32
+ Skill = Struct.new(:name, :description, :folder, :path, :license, :compatibility, :metadata, :allowed_tools, keyword_init: true)
33
+ PromptTemplate = Struct.new(:command, :description, :argument_hint, :body, :path, keyword_init: true) do
34
+ def expand(arguments)
35
+ body.gsub("$ARGUMENTS", arguments.to_s)
36
+ end
37
+ end
38
+
39
+ @skip_config = false
40
+ @warning_sink = nil
41
+
42
+ module Core
43
+ extend self
44
+
45
+ def skip_config=(value)
46
+ @skip_config = value
47
+ end
48
+
49
+ def warning_sink=(sink)
50
+ @warning_sink = sink
51
+ end
52
+
53
+ def warning_sink
54
+ @warning_sink
55
+ end
56
+
57
+ def skip_config?
58
+ @skip_config == true
59
+ end
60
+
61
+ # Directory that contains Kward's user config and adjacent prompt/skill
62
+ # data. Defaults to `~/.kward`, or the directory of `KWARD_CONFIG_PATH`.
63
+ #
64
+ # @return [String] expanded config directory path
65
+ def config_dir
66
+ config_path = ENV["KWARD_CONFIG_PATH"]
67
+ return File.expand_path(File.dirname(config_path)) if config_path && !config_path.empty?
68
+
69
+ File.expand_path("~/.kward")
70
+ end
71
+
72
+ # @return [String] expanded JSON config file path
73
+ def config_path
74
+ File.expand_path(ENV["KWARD_CONFIG_PATH"] || File.join(config_dir, "config.json"))
75
+ end
76
+
77
+ # @return [String] directory containing reusable local caches
78
+ def cache_dir
79
+ File.join(config_dir, "cache")
80
+ end
81
+
82
+ # @return [String] embedded-shell rc config path
83
+ def kwshrc_path
84
+ File.join(config_dir, "kwshrc")
85
+ end
86
+
87
+ # @return [Array<String>] embedded-shell rc config paths in load order
88
+ def kwshrc_paths
89
+ [kwshrc_path, File.expand_path("~/.kwshrc")].uniq
90
+ end
91
+
92
+ def workspace_hooks_path(root = Dir.pwd)
93
+ File.join(File.expand_path(root), ".kward", "hooks.json")
94
+ end
95
+
96
+ def trusted_workspace_hooks_path
97
+ File.join(config_dir, "trusted_workspace_hooks.json")
98
+ end
99
+
100
+ # Returns a fresh default config suitable for first-run persistence.
101
+ #
102
+ # @return [Hash] deep-mutable config defaults
103
+ def default_config
104
+ {
105
+ "personas" => JSON.parse(JSON.generate(DEFAULT_PERSONAS)),
106
+ "memory" => {
107
+ "enabled" => false,
108
+ "auto_summary" => false
109
+ },
110
+ "composer" => {
111
+ "busy_help" => true,
112
+ "tab_keybindings" => "auto"
113
+ },
114
+ "editor" => {
115
+ "mode" => "modern",
116
+ "agent" => {},
117
+ "runners" => {},
118
+ "auto_indent" => true,
119
+ "auto_close_pairs" => true,
120
+ "soft_wrap" => true,
121
+ "bar_cursor" => true,
122
+ "line_numbers" => "absolute",
123
+ "diff_view" => "auto"
124
+ },
125
+ "overlay" => DEFAULT_OVERLAY_SETTINGS.dup,
126
+ "project_browser" => {
127
+ "icons" => "off"
128
+ },
129
+ "web_search" => {
130
+ "enabled" => true,
131
+ "provider" => "auto",
132
+ "allow_model_providers" => false
133
+ },
134
+ "updates" => {
135
+ "check" => true
136
+ },
137
+ "sessions" => {
138
+ "auto_resume" => false
139
+ },
140
+ "skills" => {
141
+ "trust_project" => false
142
+ },
143
+ "enforce_workspace_agents_file" => false,
144
+ "system_prompt" => {
145
+ "include_principles" => true
146
+ },
147
+ "mcpServers" => {},
148
+ "plugins" => {},
149
+ "transports" => {},
150
+ "tools" => {
151
+ "workspace_guardrails" => true
152
+ },
153
+ "permissions" => {
154
+ "enabled" => false,
155
+ "mode" => "ask"
156
+ },
157
+ "sandbox" => {
158
+ "mode" => "off",
159
+ "network" => "deny",
160
+ "writable_roots" => [],
161
+ "protect_git_metadata" => true
162
+ }
163
+ }
164
+ end
165
+
166
+ # Performs ensure default config for configuration file and path handling.
167
+ def ensure_default_config!(path = config_path)
168
+ path = File.expand_path(path)
169
+ return false if skip_config? && path == config_path
170
+ return false if File.exist?(path)
171
+
172
+ write_config(default_config, path)
173
+ true
174
+ end
175
+
176
+ def code_search_cache_dir
177
+ File.join(cache_dir, "code_search")
178
+ end
179
+
180
+ def openrouter_models_cache_path
181
+ File.join(cache_dir, "openrouter_models.json")
182
+ end
183
+
184
+ def model_catalog_cache_path(provider_id)
185
+ provider = provider_id.to_s.gsub(/[^a-z0-9_-]/i, "_")
186
+ raise ArgumentError, "Provider id must be a non-empty string" if provider.empty?
187
+
188
+ File.join(cache_dir, "models", "#{provider}.json")
189
+ end
190
+
191
+ def update_check_cache_path
192
+ File.join(cache_dir, "update_check.json")
193
+ end
194
+
195
+ def project_browser_state_path
196
+ File.join(cache_dir, "project_browser_state.json")
197
+ end
198
+
199
+ def prompt_history_path(cwd, config_dir: self.config_dir, kind: "prompt")
200
+ key = Digest::SHA256.hexdigest(canonical_workspace_root(cwd))[0, 24]
201
+ return File.join(config_dir, "history", "#{key}.jsonl") if kind.to_s == "prompt"
202
+
203
+ File.join(config_dir, "history", kind.to_s, "#{key}.jsonl")
204
+ end
205
+
206
+ # @return [String] directory containing structured memory files
207
+ def memory_dir
208
+ File.join(config_dir, "memory")
209
+ end
210
+
211
+ def memory_core_path
212
+ File.join(memory_dir, "core.json")
213
+ end
214
+
215
+ def memory_soft_path
216
+ File.join(memory_dir, "soft.jsonl")
217
+ end
218
+
219
+ def memory_events_path
220
+ File.join(memory_dir, "events.jsonl")
221
+ end
222
+
223
+ # Reads the JSON config file.
224
+ #
225
+ # Missing files are treated as an empty config. Invalid JSON raises a
226
+ # user-facing error that includes the file path. This method does not merge
227
+ # defaults; callers should apply feature-specific defaults at the point where
228
+ # behavior is decided.
229
+ #
230
+ # @param path [String] config file path
231
+ # @return [Hash] parsed config object
232
+ def read_config(path = config_path)
233
+ path = File.expand_path(path)
234
+ return {} if skip_config? && path == config_path
235
+ return {} unless File.exist?(path)
236
+
237
+ config = JSON.parse(File.read(path))
238
+ return config if config.is_a?(Hash)
239
+
240
+ raise ConfigError.new(path: path, format: "JSON", detail: "top-level value must be an object")
241
+ rescue JSON::ParserError => e
242
+ raise ConfigError.new(path: path, format: "JSON", detail: e.message)
243
+ end
244
+
245
+ # Writes config JSON using private file permissions.
246
+ #
247
+ # @param config [Hash] config object to persist
248
+ # @param path [String] config file path
249
+ def write_config(config, path = config_path)
250
+ path = File.expand_path(path)
251
+ raise "Cannot write Kward config while --skip-config is active: #{path}" if skip_config? && path == config_path
252
+
253
+ PrivateFile.write_json(path, config)
254
+ end
255
+
256
+ def lifecycle_hooks_config(workspace_root = Dir.pwd)
257
+ config = read_config
258
+ workspace_config = read_trusted_workspace_hooks_config(workspace_root)
259
+ return config if workspace_config.empty?
260
+
261
+ merge_hooks_config(config, workspace_config)
262
+ end
263
+
264
+ def read_trusted_workspace_hooks_config(workspace_root = Dir.pwd)
265
+ path = workspace_hooks_path(workspace_root)
266
+ return {} unless workspace_hooks_trusted?(workspace_root)
267
+
268
+ read_config(path)
269
+ end
270
+
271
+ def workspace_hooks_trusted?(workspace_root = Dir.pwd)
272
+ path = workspace_hooks_path(workspace_root)
273
+ return false unless File.file?(path)
274
+
275
+ trusted = read_trusted_workspace_hooks
276
+ trusted[File.expand_path(path)] == workspace_hooks_digest(workspace_root)
277
+ end
278
+
279
+ def trust_workspace_hooks!(workspace_root = Dir.pwd)
280
+ path = workspace_hooks_path(workspace_root)
281
+ raise "No workspace hook config found: #{path}" unless File.file?(path)
282
+
283
+ trusted = read_trusted_workspace_hooks
284
+ trusted[File.expand_path(path)] = workspace_hooks_digest(workspace_root)
285
+ PrivateFile.write_json(trusted_workspace_hooks_path, trusted)
286
+ end
287
+
288
+ def untrust_workspace_hooks!(workspace_root = Dir.pwd)
289
+ path = File.expand_path(workspace_hooks_path(workspace_root))
290
+ trusted = read_trusted_workspace_hooks
291
+ trusted.delete(path)
292
+ PrivateFile.write_json(trusted_workspace_hooks_path, trusted)
293
+ end
294
+
295
+ def workspace_hooks_digest(workspace_root = Dir.pwd)
296
+ Digest::SHA256.file(workspace_hooks_path(workspace_root)).hexdigest
297
+ end
298
+
299
+ def read_trusted_workspace_hooks
300
+ config = read_config(trusted_workspace_hooks_path)
301
+ config.is_a?(Hash) ? config : {}
302
+ rescue ConfigError
303
+ {}
304
+ end
305
+
306
+ def merge_hooks_config(config, workspace_config)
307
+ merged = JSON.parse(JSON.generate(config || {}))
308
+ merged["hooks"] = merge_hook_maps(merged["hooks"], workspace_config["hooks"] || workspace_config[:hooks])
309
+ merged
310
+ end
311
+
312
+ def merge_hook_maps(left, right)
313
+ left = left.is_a?(Hash) ? left : {}
314
+ right = right.is_a?(Hash) ? right : {}
315
+ merged = JSON.parse(JSON.generate(left))
316
+ right.each do |event, entries|
317
+ merged[event.to_s] = Array(merged[event.to_s]) + Array(entries)
318
+ end
319
+ merged
320
+ end
321
+
322
+ def read_kwsh_config
323
+ config = {
324
+ shell: Kwsh::DEFAULT_SHELL,
325
+ timeout_seconds: Kwsh::DEFAULT_TIMEOUT_SECONDS,
326
+ max_output_bytes: Kwsh::DEFAULT_MAX_OUTPUT_BYTES,
327
+ history_limit: Kwsh::DEFAULT_HISTORY_LIMIT,
328
+ env: {},
329
+ aliases: {}
330
+ }
331
+ rc_config = read_kwshrc_config
332
+ config[:env].merge!(rc_config[:env])
333
+ config[:aliases].merge!(rc_config[:aliases])
334
+ config
335
+ end
336
+
337
+ def read_kwshrc_config(paths = kwshrc_paths, env: ENV.to_h)
338
+ Kwshrc.read(paths, env: env)
339
+ rescue Kwshrc::ParseError => e
340
+ raise "Invalid kwshrc config: #{e.message}"
341
+ end
342
+
343
+ # Merges top-level config values and writes the updated config privately.
344
+ def update_config(values, path = config_path)
345
+ raise "Config values must be an object" unless values.is_a?(Hash)
346
+
347
+ config = read_config(path)
348
+ values.each { |key, value| config[key.to_s] = value }
349
+ write_config(config, path)
350
+ config
351
+ end
352
+
353
+ # Merges values into a one-level nested config section and writes privately.
354
+ def update_nested_config(section, values, path = config_path)
355
+ raise "Config values must be an object" unless values.is_a?(Hash)
356
+
357
+ config = read_config(path)
358
+ current = config[section.to_s].is_a?(Hash) ? config[section.to_s].dup : {}
359
+ config[section.to_s] = current.merge(values.transform_keys(&:to_s))
360
+ write_config(config, path)
361
+ config
362
+ end
363
+
364
+ # Removes a top-level config key when it exists.
365
+ def delete_config_key(key, path = config_path)
366
+ config = read_config(path)
367
+ existed = config.key?(key.to_s)
368
+ config.delete(key.to_s)
369
+ write_config(config, path) if existed
370
+ existed
371
+ end
372
+
373
+ # Returns the first present non-empty string value among several config keys.
374
+ def config_value(config, *keys)
375
+ keys.each do |key|
376
+ text = presence(config[key])
377
+ return text if text
378
+ end
379
+ nil
380
+ end
381
+
382
+ # Returns validated overlay settings with defaults for missing or invalid
383
+ # values.
384
+ #
385
+ # @param config [Hash] parsed config object
386
+ # @return [Hash] overlay settings with `alignment` and `width`
387
+ end
388
+ end
389
+ end
@@ -0,0 +1,96 @@
1
+ # Configuration extensions behavior.
2
+ module Kward
3
+ module ConfigFiles
4
+ module Extensions
5
+ extend self
6
+
7
+ # Lists configured skills discovered under the config directory.
8
+ #
9
+ # @return [Array<Skill>] skill metadata available to the model
10
+ def skills(workspace_root: Dir.pwd, warning_sink: nil, project_skill_paths: nil)
11
+ skills_registry(workspace_root: workspace_root, warning_sink: warning_sink, project_skill_paths: project_skill_paths).skills
12
+ end
13
+
14
+ def project_skill_candidates(workspace_root: Dir.pwd, warning_sink: nil)
15
+ skills_registry(workspace_root: workspace_root, warning_sink: warning_sink).project_skill_candidates
16
+ end
17
+
18
+ # @return [String] trusted user plugin directory
19
+ def plugin_dir
20
+ File.expand_path("~/.kward/plugins")
21
+ end
22
+
23
+ # Finds trusted plugin files and package entrypoints.
24
+ #
25
+ # Plugins are intentionally loaded only from `~/.kward/plugins`, not from a
26
+ # workspace or custom `KWARD_CONFIG_PATH` directory.
27
+ #
28
+ # @return [Array<String>] sorted plugin file paths
29
+ def plugin_paths(warning_sink: nil)
30
+ plugins_root = plugin_dir
31
+ return [] unless Dir.exist?(plugins_root)
32
+
33
+ paths = Dir.glob(File.join(plugins_root, "*.rb"))
34
+ paths.concat(Dir.glob(File.join(plugins_root, "*", "plugin.rb")))
35
+ paths.sort
36
+ rescue StandardError => e
37
+ emit_warning("Warning: skipping Kward plugins in #{plugins_root}: #{e.message}", warning_sink: warning_sink)
38
+ []
39
+ end
40
+
41
+ # Lists prompt templates exposed as slash commands.
42
+ #
43
+ # @param reserved_commands [Array<String>] command names unavailable to templates
44
+ # @return [Array<PromptTemplate>] prompt template metadata and bodies
45
+ def prompt_templates(reserved_commands: [])
46
+ prompt_template_registry.prompt_templates(reserved_commands: reserved_commands)
47
+ end
48
+
49
+ # Reads a skill file by skill name and optional relative path.
50
+ #
51
+ # @param name [String] configured skill name
52
+ # @param relative_path [String, nil] path inside the skill directory
53
+ # @return [String] file contents or an error string
54
+ def read_skill_file(name, relative_path = nil, workspace_root: Dir.pwd)
55
+ skills_registry(workspace_root: workspace_root).read_skill_file(name, relative_path)
56
+ end
57
+
58
+ def skills_registry(workspace_root: Dir.pwd, warning_sink: nil, project_skill_paths: nil)
59
+ Skills::Registry.new(
60
+ config_dir: config_dir,
61
+ workspace_root: workspace_root,
62
+ project_skills_trusted: project_skills_trusted?,
63
+ skill_class: Skill,
64
+ max_file_bytes: MAX_SKILL_FILE_BYTES,
65
+ markdown_parser: ->(path) { Frontmatter.markdown_parts(path, lenient: true) },
66
+ inside_directory: method(:inside_directory?),
67
+ warning_sink: warning_sink || @warning_sink,
68
+ project_skill_paths: project_skill_paths
69
+ )
70
+ end
71
+
72
+ def prompt_template_registry
73
+ ::Kward::Prompts::Templates.new(
74
+ config_dir: config_dir,
75
+ template_class: PromptTemplate,
76
+ markdown_parser: ->(path) { Frontmatter.markdown_parts(path) },
77
+ warning_sink: @warning_sink
78
+ )
79
+ end
80
+
81
+ def emit_warning(message, warning_sink: nil)
82
+ sink = warning_sink || @warning_sink
83
+ sink ? sink.call(message) : warn(message)
84
+ end
85
+
86
+ def inside_directory?(path, base)
87
+ PathGuard.inside?(path, base)
88
+ end
89
+
90
+ def presence(value)
91
+ text = value.to_s
92
+ text.empty? ? nil : text
93
+ end
94
+ end
95
+ end
96
+ end