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
@@ -1,19 +1,24 @@
1
1
  require "digest"
2
2
  require "fileutils"
3
3
  require "json"
4
- require "yaml"
5
4
  require_relative "frontmatter"
6
5
  require_relative "deep_copy"
7
6
  require_relative "private_file"
8
- require_relative "path_guard"
7
+ require_relative "workspace/path_guard"
9
8
  require_relative "permissions/policy"
10
9
  require_relative "sandbox/policy"
11
- require_relative "ekwsh"
12
- require_relative "editor_mode"
13
- require_relative "diff_view_mode"
10
+ require_relative "shell/kwsh"
11
+ require_relative "shell/kwshrc"
12
+ require_relative "prompt_interface/editor/editor_mode"
13
+ require_relative "prompt_interface/editor/diff_view_mode"
14
14
  require_relative "prompts/templates"
15
15
  require_relative "skills/registry"
16
16
 
17
+ require_relative "config/core"
18
+ require_relative "config/settings"
19
+ require_relative "config/prompts"
20
+ require_relative "config/extensions"
21
+
17
22
  # Namespace for the Kward CLI agent runtime.
18
23
  module Kward
19
24
  # Resolves Kward configuration, cache, memory, prompt, skill, and plugin
@@ -29,994 +34,9 @@ module Kward
29
34
  # reconstructing `~/.kward` paths independently.
30
35
  # @api public
31
36
  module ConfigFiles
32
- # Raised when a user configuration file cannot be parsed.
33
- class ConfigError < StandardError
34
- attr_reader :path, :format, :detail
35
-
36
- def initialize(path:, format:, detail:)
37
- @path = path
38
- @format = format
39
- @detail = detail
40
- super("Invalid Kward config #{format}: #{path}: #{detail}")
41
- end
42
- end
43
-
44
- MAX_SKILL_FILE_BYTES = 100_000
45
- MAX_PROMPT_FILE_BYTES = 32 * 1024
46
- DEFAULT_OVERLAY_SETTINGS = { "alignment" => "center", "width" => "maximum" }.freeze
47
- PROJECT_BROWSER_ICON_THEMES = %w[off nerd-font].freeze
48
- DEFAULT_PERSONAS = {
49
- "characters" => [
50
- {
51
- "key" => "kward",
52
- "label" => "Kward",
53
- "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."
54
- }
55
- ],
56
- "default" => "kward"
57
- }.freeze
58
- OVERLAY_ALIGNMENTS = %w[left center right].freeze
59
- OVERLAY_WIDTHS = %w[capped maximum].freeze
60
-
61
- Skill = Struct.new(:name, :description, :folder, :path, :license, :compatibility, :metadata, :allowed_tools, keyword_init: true)
62
- PromptTemplate = Struct.new(:command, :description, :argument_hint, :body, :path, keyword_init: true) do
63
- def expand(arguments)
64
- body.gsub("$ARGUMENTS", arguments.to_s)
65
- end
66
- end
67
-
68
- @skip_config = false
69
- @warning_sink = nil
70
-
71
- module_function
72
-
73
- def skip_config=(value)
74
- @skip_config = value
75
- end
76
-
77
- def warning_sink=(sink)
78
- @warning_sink = sink
79
- end
80
-
81
- def warning_sink
82
- @warning_sink
83
- end
84
-
85
- def skip_config?
86
- @skip_config == true
87
- end
88
-
89
- # Directory that contains Kward's user config and adjacent prompt/skill
90
- # data. Defaults to `~/.kward`, or the directory of `KWARD_CONFIG_PATH`.
91
- #
92
- # @return [String] expanded config directory path
93
- def config_dir
94
- config_path = ENV["KWARD_CONFIG_PATH"]
95
- return File.expand_path(File.dirname(config_path)) if config_path && !config_path.empty?
96
-
97
- File.expand_path("~/.kward")
98
- end
99
-
100
- # @return [String] expanded JSON config file path
101
- def config_path
102
- File.expand_path(ENV["KWARD_CONFIG_PATH"] || File.join(config_dir, "config.json"))
103
- end
104
-
105
- # @return [String] directory containing reusable local caches
106
- def cache_dir
107
- File.join(config_dir, "cache")
108
- end
109
-
110
- # @return [String] embedded-shell YAML config path
111
- def ekwsh_config_path
112
- File.join(config_dir, "ekwsh.yml")
113
- end
114
-
115
- def workspace_hooks_path(root = Dir.pwd)
116
- File.join(File.expand_path(root), ".kward", "hooks.json")
117
- end
118
-
119
- def trusted_workspace_hooks_path
120
- File.join(config_dir, "trusted_workspace_hooks.json")
121
- end
122
-
123
- # Returns a fresh default config suitable for first-run persistence.
124
- #
125
- # @return [Hash] deep-mutable config defaults
126
- def default_config
127
- {
128
- "personas" => JSON.parse(JSON.generate(DEFAULT_PERSONAS)),
129
- "memory" => {
130
- "enabled" => false,
131
- "auto_summary" => false
132
- },
133
- "composer" => {
134
- "busy_help" => true,
135
- "tab_keybindings" => "auto"
136
- },
137
- "editor" => {
138
- "mode" => "modern",
139
- "agent" => {},
140
- "auto_indent" => true,
141
- "auto_close_pairs" => true,
142
- "soft_wrap" => true,
143
- "bar_cursor" => true,
144
- "line_numbers" => "absolute",
145
- "diff_view" => "auto"
146
- },
147
- "overlay" => DEFAULT_OVERLAY_SETTINGS.dup,
148
- "project_browser" => {
149
- "icons" => "off"
150
- },
151
- "web_search" => {
152
- "enabled" => true,
153
- "provider" => "auto",
154
- "allow_model_providers" => false
155
- },
156
- "updates" => {
157
- "check" => true
158
- },
159
- "sessions" => {
160
- "auto_resume" => false
161
- },
162
- "skills" => {
163
- "trust_project" => false
164
- },
165
- "enforce_workspace_agents_file" => false,
166
- "system_prompt" => {
167
- "include_principles" => true
168
- },
169
- "mcpServers" => {},
170
- "transports" => {},
171
- "tools" => {
172
- "workspace_guardrails" => true
173
- },
174
- "permissions" => {
175
- "enabled" => false,
176
- "mode" => "ask"
177
- },
178
- "sandbox" => {
179
- "mode" => "off",
180
- "network" => "deny",
181
- "writable_roots" => [],
182
- "protect_git_metadata" => true
183
- }
184
- }
185
- end
186
-
187
- # Performs ensure default config for configuration file and path handling.
188
- def ensure_default_config!(path = config_path)
189
- path = File.expand_path(path)
190
- return false if skip_config? && path == config_path
191
- return false if File.exist?(path)
192
-
193
- write_config(default_config, path)
194
- true
195
- end
196
-
197
- def code_search_cache_dir
198
- File.join(cache_dir, "code_search")
199
- end
200
-
201
- def openrouter_models_cache_path
202
- File.join(cache_dir, "openrouter_models.json")
203
- end
204
-
205
- def model_catalog_cache_path(provider_id)
206
- provider = provider_id.to_s.gsub(/[^a-z0-9_-]/i, "_")
207
- raise ArgumentError, "Provider id must be a non-empty string" if provider.empty?
208
-
209
- File.join(cache_dir, "models", "#{provider}.json")
210
- end
211
-
212
- def update_check_cache_path
213
- File.join(cache_dir, "update_check.json")
214
- end
215
-
216
- def project_browser_state_path
217
- File.join(cache_dir, "project_browser_state.json")
218
- end
219
-
220
- def prompt_history_path(cwd, config_dir: self.config_dir, kind: "prompt")
221
- key = Digest::SHA256.hexdigest(canonical_workspace_root(cwd))[0, 24]
222
- return File.join(config_dir, "history", "#{key}.jsonl") if kind.to_s == "prompt"
223
-
224
- File.join(config_dir, "history", kind.to_s, "#{key}.jsonl")
225
- end
226
-
227
- # @return [String] directory containing structured memory files
228
- def memory_dir
229
- File.join(config_dir, "memory")
230
- end
231
-
232
- def memory_core_path
233
- File.join(memory_dir, "core.json")
234
- end
235
-
236
- def memory_soft_path
237
- File.join(memory_dir, "soft.jsonl")
238
- end
239
-
240
- def memory_events_path
241
- File.join(memory_dir, "events.jsonl")
242
- end
243
-
244
- # Reads the JSON config file.
245
- #
246
- # Missing files are treated as an empty config. Invalid JSON raises a
247
- # user-facing error that includes the file path. This method does not merge
248
- # defaults; callers should apply feature-specific defaults at the point where
249
- # behavior is decided.
250
- #
251
- # @param path [String] config file path
252
- # @return [Hash] parsed config object
253
- def read_config(path = config_path)
254
- path = File.expand_path(path)
255
- return {} if skip_config? && path == config_path
256
- return {} unless File.exist?(path)
257
-
258
- config = JSON.parse(File.read(path))
259
- return config if config.is_a?(Hash)
260
-
261
- raise ConfigError.new(path: path, format: "JSON", detail: "top-level value must be an object")
262
- rescue JSON::ParserError => e
263
- raise ConfigError.new(path: path, format: "JSON", detail: e.message)
264
- end
265
-
266
- # Writes config JSON using private file permissions.
267
- #
268
- # @param config [Hash] config object to persist
269
- # @param path [String] config file path
270
- def write_config(config, path = config_path)
271
- path = File.expand_path(path)
272
- raise "Cannot write Kward config while --skip-config is active: #{path}" if skip_config? && path == config_path
273
-
274
- PrivateFile.write_json(path, config)
275
- end
276
-
277
- def lifecycle_hooks_config(workspace_root = Dir.pwd)
278
- config = read_config
279
- workspace_config = read_trusted_workspace_hooks_config(workspace_root)
280
- return config if workspace_config.empty?
281
-
282
- merge_hooks_config(config, workspace_config)
283
- end
284
-
285
- def read_trusted_workspace_hooks_config(workspace_root = Dir.pwd)
286
- path = workspace_hooks_path(workspace_root)
287
- return {} unless workspace_hooks_trusted?(workspace_root)
288
-
289
- read_config(path)
290
- end
291
-
292
- def workspace_hooks_trusted?(workspace_root = Dir.pwd)
293
- path = workspace_hooks_path(workspace_root)
294
- return false unless File.file?(path)
295
-
296
- trusted = read_trusted_workspace_hooks
297
- trusted[File.expand_path(path)] == workspace_hooks_digest(workspace_root)
298
- end
299
-
300
- def trust_workspace_hooks!(workspace_root = Dir.pwd)
301
- path = workspace_hooks_path(workspace_root)
302
- raise "No workspace hook config found: #{path}" unless File.file?(path)
303
-
304
- trusted = read_trusted_workspace_hooks
305
- trusted[File.expand_path(path)] = workspace_hooks_digest(workspace_root)
306
- PrivateFile.write_json(trusted_workspace_hooks_path, trusted)
307
- end
308
-
309
- def untrust_workspace_hooks!(workspace_root = Dir.pwd)
310
- path = File.expand_path(workspace_hooks_path(workspace_root))
311
- trusted = read_trusted_workspace_hooks
312
- trusted.delete(path)
313
- PrivateFile.write_json(trusted_workspace_hooks_path, trusted)
314
- end
315
-
316
- def workspace_hooks_digest(workspace_root = Dir.pwd)
317
- Digest::SHA256.file(workspace_hooks_path(workspace_root)).hexdigest
318
- end
319
-
320
- def read_trusted_workspace_hooks
321
- config = read_config(trusted_workspace_hooks_path)
322
- config.is_a?(Hash) ? config : {}
323
- rescue ConfigError
324
- {}
325
- end
326
-
327
- def merge_hooks_config(config, workspace_config)
328
- merged = JSON.parse(JSON.generate(config || {}))
329
- merged["hooks"] = merge_hook_maps(merged["hooks"], workspace_config["hooks"] || workspace_config[:hooks])
330
- merged
331
- end
332
-
333
- def merge_hook_maps(left, right)
334
- left = left.is_a?(Hash) ? left : {}
335
- right = right.is_a?(Hash) ? right : {}
336
- merged = JSON.parse(JSON.generate(left))
337
- right.each do |event, entries|
338
- merged[event.to_s] = Array(merged[event.to_s]) + Array(entries)
339
- end
340
- merged
341
- end
342
-
343
- def read_ekwsh_config(path = ekwsh_config_path)
344
- path = File.expand_path(path)
345
- return normalize_ekwsh_config(nil) unless File.exist?(path)
346
-
347
- data = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
348
- normalize_ekwsh_config(data)
349
- rescue Psych::SyntaxError => e
350
- raise "Invalid ekwsh YAML config: #{path}: #{e.message}"
351
- end
352
-
353
- def normalize_ekwsh_config(data)
354
- data = data.transform_keys(&:to_s) if data.is_a?(Hash)
355
- settings = data.is_a?(Hash) ? data : {}
356
- {
357
- shell: normalize_ekwsh_shell(settings["shell"]),
358
- timeout_seconds: normalize_positive_integer(settings["timeout_seconds"], Ekwsh::DEFAULT_TIMEOUT_SECONDS),
359
- max_output_bytes: normalize_positive_integer(settings["max_output_bytes"], Ekwsh::DEFAULT_MAX_OUTPUT_BYTES),
360
- history_limit: normalize_positive_integer(settings["history_limit"], Ekwsh::DEFAULT_HISTORY_LIMIT),
361
- env: normalize_ekwsh_env(settings["env"]),
362
- aliases: normalize_ekwsh_aliases(settings["aliases"])
363
- }
364
- end
365
-
366
- def normalize_ekwsh_shell(value)
367
- shell = value.to_s.strip
368
- return Ekwsh::DEFAULT_SHELL if shell.empty?
369
- return shell if shell.start_with?("/") && File.executable?(shell)
370
-
371
- Ekwsh::DEFAULT_SHELL
372
- end
373
-
374
- def normalize_positive_integer(value, default)
375
- integer = Integer(value)
376
- integer.positive? ? integer : default
377
- rescue ArgumentError, TypeError
378
- default
379
- end
380
-
381
- def normalize_ekwsh_env(values)
382
- return {} unless values.is_a?(Hash)
383
-
384
- values.each_with_object({}) do |(key, value), result|
385
- key = key.to_s
386
- next unless key.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
387
- next if value.nil?
388
-
389
- result[key] = value.to_s
390
- end
391
- end
392
-
393
- def normalize_ekwsh_aliases(values)
394
- return {} unless values.is_a?(Hash)
395
-
396
- values.each_with_object({}) do |(name, command), result|
397
- name = name.to_s
398
- command = command.to_s.strip
399
- next unless Ekwsh.valid_alias_name?(name)
400
- next if command.empty?
401
-
402
- result[name] = command
403
- end
404
- end
405
-
406
- # Merges top-level config values and writes the updated config privately.
407
- def update_config(values, path = config_path)
408
- raise "Config values must be an object" unless values.is_a?(Hash)
409
-
410
- config = read_config(path)
411
- values.each { |key, value| config[key.to_s] = value }
412
- write_config(config, path)
413
- config
414
- end
415
-
416
- # Merges values into a one-level nested config section and writes privately.
417
- def update_nested_config(section, values, path = config_path)
418
- raise "Config values must be an object" unless values.is_a?(Hash)
419
-
420
- config = read_config(path)
421
- current = config[section.to_s].is_a?(Hash) ? config[section.to_s].dup : {}
422
- config[section.to_s] = current.merge(values.transform_keys(&:to_s))
423
- write_config(config, path)
424
- config
425
- end
426
-
427
- # Removes a top-level config key when it exists.
428
- def delete_config_key(key, path = config_path)
429
- config = read_config(path)
430
- existed = config.key?(key.to_s)
431
- config.delete(key.to_s)
432
- write_config(config, path) if existed
433
- existed
434
- end
435
-
436
- # Returns the first present non-empty string value among several config keys.
437
- def config_value(config, *keys)
438
- keys.each do |key|
439
- text = presence(config[key])
440
- return text if text
441
- end
442
- nil
443
- end
444
-
445
- # Returns validated overlay settings with defaults for missing or invalid
446
- # values.
447
- #
448
- # @param config [Hash] parsed config object
449
- # @return [Hash] overlay settings with `alignment` and `width`
450
- def overlay_settings(config = read_config)
451
- overlay = config["overlay"].is_a?(Hash) ? config["overlay"] : {}
452
- settings = DEFAULT_OVERLAY_SETTINGS.dup
453
- alignment = overlay["alignment"].to_s
454
- width = overlay["width"].to_s
455
- settings["alignment"] = alignment if OVERLAY_ALIGNMENTS.include?(alignment)
456
- settings["width"] = width if OVERLAY_WIDTHS.include?(width)
457
- settings
458
- end
459
-
460
- # Returns the project browser icon theme, or off when unset or invalid.
461
- def project_browser_icon_theme(config = read_config)
462
- browser = config["project_browser"].is_a?(Hash) ? config["project_browser"] : {}
463
- theme = browser["icons"].to_s
464
- PROJECT_BROWSER_ICON_THEMES.include?(theme) ? theme : "off"
465
- end
466
-
467
- # Returns whether the composer should show busy-state keyboard help.
468
- def composer_busy_help?(config = read_config)
469
- composer = config["composer"].is_a?(Hash) ? config["composer"] : {}
470
- composer["busy_help"] != false
471
- end
472
-
473
- # Returns the configured tab keybinding family, or auto when unset/invalid.
474
- def composer_tab_keybindings(config = read_config)
475
- composer = config["composer"].is_a?(Hash) ? config["composer"] : {}
476
- value = composer["tab_keybindings"].to_s.downcase
477
- %w[auto ctrl alt].include?(value) ? value : "auto"
478
- end
479
-
480
- # Returns the built-in TUI editor keymap mode.
481
- def editor_mode(config = read_config)
482
- editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
483
- EditorMode.normalize(editor["mode"])
484
- end
485
-
486
- # Returns the optional model override for editor-agent turns.
487
- def editor_agent_model(config = read_config)
488
- editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
489
- agent = editor["agent"].is_a?(Hash) ? editor["agent"] : {}
490
- value = agent["model"].to_s.strip
491
- value.empty? ? nil : value
492
- end
493
-
494
- # Returns the optional reasoning-effort override for editor-agent turns.
495
- def editor_agent_reasoning_effort(config = read_config)
496
- editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
497
- agent = editor["agent"].is_a?(Hash) ? editor["agent"] : {}
498
- value = agent["reasoning_effort"].to_s.strip
499
- value.empty? ? nil : value
500
- end
501
-
502
- # Returns whether the built-in TUI editor should auto-indent new lines.
503
- def editor_auto_indent?(config = read_config)
504
- editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
505
- editor["auto_indent"] != false
506
- end
507
-
508
- # Returns whether the built-in TUI editor should auto-close typed pairs.
509
- def editor_auto_close_pairs?(config = read_config)
510
- editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
511
- editor["auto_close_pairs"] != false
512
- end
513
-
514
- # Returns whether the built-in TUI editor should soft-wrap long lines.
515
- def editor_soft_wrap?(config = read_config)
516
- editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
517
- editor["soft_wrap"] != false
518
- end
519
-
520
- # Returns whether editable built-in TUI editor buffers should use a bar cursor.
521
- def editor_bar_cursor?(config = read_config)
522
- editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
523
- editor["bar_cursor"] != false
524
- end
525
-
526
- # Returns the built-in TUI editor line-number display mode.
527
- def editor_line_numbers(config = read_config)
528
- editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
529
- EditorMode.normalize_line_numbers(editor["line_numbers"])
530
- end
531
-
532
- # Returns the integrated diff viewer display mode.
533
- def diff_view(config = read_config)
534
- editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
535
- DiffViewMode.normalize(editor["diff_view"])
536
- end
537
-
538
- # Returns whether file tools must stay inside the active workspace root.
539
- def workspace_guardrails_enabled?(config = read_config)
540
- tools = config["tools"].is_a?(Hash) ? config["tools"] : {}
541
- tools["workspace_guardrails"] != false
542
- end
543
-
544
- # Builds the opt-in model-tool permission policy from persisted configuration.
545
- def permission_policy(config = read_config)
546
- Permissions::Policy.from_config(config)
547
- end
548
-
549
- # Builds the user-controlled command sandbox policy for a workspace.
550
- def sandbox_policy(workspace_root, config = read_config)
551
- sandbox = config["sandbox"].is_a?(Hash) ? config["sandbox"] : {}
552
- Sandbox::Policy.new(
553
- mode: sandbox.fetch("mode", "off"),
554
- network: sandbox.fetch("network", "deny"),
555
- workspace_root: workspace_root,
556
- writable_roots: sandbox.fetch("writable_roots", []),
557
- protect_git_metadata: sandbox.fetch("protect_git_metadata", true)
558
- )
559
- end
560
-
561
- # Returns whether project-level Agent Skills should be loaded from the workspace.
562
- def project_skills_trusted?(config = read_config)
563
- skills = config["skills"].is_a?(Hash) ? config["skills"] : {}
564
- skills["trust_project"] == true
565
- end
566
-
567
- # Returns whether new frontends should resume the last active session automatically.
568
- def session_auto_resume_enabled?(config = read_config)
569
- sessions = config["sessions"].is_a?(Hash) ? config["sessions"] : {}
570
- sessions["auto_resume"] == true
571
- end
572
-
573
- # Returns whether workspace AGENTS.md contents should be injected directly
574
- # instead of a compact read-when-relevant instruction.
575
- def enforce_workspace_agents_file?(config = read_config)
576
- config["enforce_workspace_agents_file"] == true
577
- end
578
-
579
- # Returns the nested web-search config object, or an empty config when absent.
580
- def web_search_config(config = read_config)
581
- value = config["web_search"]
582
- value.is_a?(Hash) ? value : {}
583
- end
584
-
585
- # Returns the private configuration namespace for one transport.
586
- def transport_config(transport_id, config = read_config)
587
- transports = config["transports"]
588
- return {} unless transports.is_a?(Hash)
589
-
590
- values = transports[transport_id.to_s]
591
- values.is_a?(Hash) ? DeepCopy.dup(values) : {}
592
- end
593
-
594
- # Returns configured MCP stdio servers, or an empty config when absent.
595
- def mcp_servers(config = read_config)
596
- value = config["mcpServers"] || config.dig("mcp", "servers")
597
- value.is_a?(Hash) ? value : {}
598
- end
599
-
600
- # Validates and persists terminal overlay settings.
601
- def update_overlay_settings(values)
602
- raise "Overlay settings must be an object" unless values.is_a?(Hash)
603
-
604
- config = read_config
605
- overlay = overlay_settings(config)
606
- values.each do |key, value|
607
- key = key.to_s
608
- value = value.to_s
609
- case key
610
- when "alignment"
611
- raise "Overlay alignment must be left, center, or right" unless OVERLAY_ALIGNMENTS.include?(value)
612
- when "width"
613
- raise "Overlay width must be capped or maximum" unless OVERLAY_WIDTHS.include?(value)
614
- else
615
- raise "Unknown overlay setting: #{key}"
616
- end
617
- overlay[key] = value
618
- end
619
- config["overlay"] = overlay
620
- write_config(config)
621
- overlay_settings(config)
622
- end
623
-
624
- # Reads global principle instructions from the config directory.
625
- #
626
- # `PRINCIPLES.md` is preferred. `AGENTS.md` remains a backwards-compatible
627
- # alias for existing installations.
628
- #
629
- # @return [String, nil] prompt text, or nil when absent/too large
630
- def agents_prompt(config: read_config)
631
- return nil unless include_config_principles?(config)
632
-
633
- path = config_principles_path
634
- return read_prompt_file(path, "Kward principles file") if File.exist?(path)
635
-
636
- read_prompt_file(config_agents_path, "Kward AGENTS.md alias")
637
- end
638
-
639
- # Returns whether config-directory principles are included in normal system
640
- # prompt assembly. Replacement prompt files bypass all assembled sections.
641
- def include_config_principles?(config = read_config)
642
- settings = config["system_prompt"]
643
- return true unless settings.is_a?(Hash)
644
-
645
- settings["include_principles"] != false
646
- end
647
-
648
- # Returns the configured replacement system prompt path, or nil when normal
649
- # prompt assembly should be used. Relative paths are anchored to the config
650
- # directory so configuration remains portable with KWARD_CONFIG_PATH.
651
- def system_prompt_file_path(config = read_config)
652
- settings = config["system_prompt"]
653
- value = settings.is_a?(Hash) ? settings["file"].to_s.strip : ""
654
- return nil if value.empty?
655
-
656
- File.expand_path(value, config_dir)
657
- end
658
-
659
- # Returns the replacement system prompt text when configured. A configured
660
- # but unavailable file intentionally yields nil: callers must not fall back
661
- # to Kward's larger assembled prompt in replacement mode.
662
- def system_prompt_file(config = read_config)
663
- path = system_prompt_file_path(config)
664
- return nil unless path
665
-
666
- read_prompt_file(path, "custom system prompt file")
667
- end
668
-
669
- def replacement_system_prompt?(config = read_config)
670
- !system_prompt_file_path(config).nil?
671
- end
672
-
673
- # Returns a lightweight fingerprint for config-owned prompt sources. It is
674
- # used by active conversations to pick up prompt edits without a restart.
675
- def system_prompt_sources_fingerprint
676
- config = read_config
677
- paths = [config_path, system_prompt_file_path(config)]
678
- if include_config_principles?(config)
679
- paths << (File.exist?(config_principles_path) ? config_principles_path : config_agents_path)
680
- end
681
-
682
- paths.compact.map { |path| prompt_source_signature(path) }.join("\0")
683
- end
684
-
685
- def config_principles_path
686
- File.join(config_dir, "PRINCIPLES.md")
687
- end
688
-
689
- def config_agents_path
690
- File.join(config_dir, "AGENTS.md")
691
- end
692
-
693
- # Builds persona prompt text from default, workspace, model, reasoning,
694
- # time-of-day, weekday, and suffix config entries.
695
- #
696
- # Persona resolution is intentionally data-driven so users can edit config
697
- # without plugin code. Keep new persona selectors additive and deterministic;
698
- # prompt construction depends on stable ordering.
699
- #
700
- # @param workspace_root [String] active workspace root
701
- # @param model [String, nil] active model name
702
- # @param reasoning_effort [String, nil] active reasoning effort
703
- # @param now [Time] local time used for time-based modifiers
704
- # @param config [Hash] parsed config object
705
- # @return [String, nil] persona prompt text when entries match
706
- def persona_prompt(workspace_root, model: nil, reasoning_effort: nil, now: Time.now, config: read_config)
707
- text = persona_entries(workspace_root: workspace_root, model: model, reasoning_effort: reasoning_effort, now: now, config: config).map do |entry|
708
- entry[:prompt]
709
- end.join("\n\n")
710
- return nil if text.empty?
711
-
712
- text
713
- end
714
-
715
- # Returns the label of the persona selected by default/workspace/model rules.
716
- def active_persona_label(workspace_root:, model: nil, config: read_config)
717
- personas = config["personas"]
718
- return nil unless personas.is_a?(Hash)
719
-
720
- labels = crew_character_labels(personas)
721
- active_label = persona_label_for_key(personas["default"], labels) unless personas["default"].nil?
722
-
723
- workspaces = personas["workspaces"]
724
- if workspaces.is_a?(Hash)
725
- root = canonical_workspace_root(workspace_root)
726
- workspaces.each do |path, key|
727
- next unless canonical_workspace_root(path) == root
728
-
729
- active_label = persona_label_for_key(key, labels)
730
- break
731
- end
732
- end
733
-
734
- models = personas["models"]
735
- if models.is_a?(Hash) && !model.to_s.empty? && models.key?(model.to_s)
736
- active_label = persona_label_for_key(models[model.to_s], labels)
737
- end
738
-
739
- active_label
740
- end
741
-
742
- def persona_entries(workspace_root:, model: nil, reasoning_effort: nil, now: Time.now, config: read_config, include_reasoning: true)
743
- personas = config["personas"]
744
- return [] unless personas.is_a?(Hash)
745
-
746
- characters = crew_characters(personas)
747
- entries = []
748
- active_persona = { layer: "default", value: personas["default"], name: nil }
749
-
750
- workspaces = personas["workspaces"]
751
- if workspaces.is_a?(Hash)
752
- root = canonical_workspace_root(workspace_root)
753
- workspaces.each do |path, key|
754
- if canonical_workspace_root(path) == root
755
- active_persona = { layer: "workspace", value: key, name: path }
756
- break
757
- end
758
- end
759
- end
760
-
761
- models = personas["models"]
762
- if models.is_a?(Hash) && !model.to_s.empty? && models.key?(model.to_s)
763
- active_persona = { layer: "model", value: models[model.to_s], name: model.to_s }
764
- end
765
-
766
- add_persona_entry(
767
- entries,
768
- active_persona.fetch(:layer),
769
- resolved_persona_text(active_persona.fetch(:value), characters: characters),
770
- name: active_persona[:name]
771
- )
772
-
773
- modifiers = personas["persona_modifiers"]
774
- if modifiers.is_a?(Hash)
775
- if include_reasoning
776
- reasoning = modifiers["reasoning"]
777
- add_persona_entry(entries, "reasoning", reasoning[reasoning_effort.to_s]) if reasoning.is_a?(Hash) && !reasoning_effort.to_s.empty?
778
- end
779
-
780
- time_of_day = modifiers["time_of_day"]
781
- bucket = time_of_day_bucket(now)
782
- add_persona_entry(entries, "time_of_day", time_of_day[bucket], name: bucket) if time_of_day.is_a?(Hash)
783
-
784
- weekday = modifiers["weekday"]
785
- day = weekday_name(now)
786
- add_persona_entry(entries, "weekday", weekday[day], name: day) if weekday.is_a?(Hash)
787
-
788
- add_persona_entry(entries, "suffix", modifiers["suffix"])
789
- end
790
-
791
- entries
792
- end
793
-
794
- def workspace_agents_path(workspace_root)
795
- File.join(canonical_workspace_root(workspace_root), "AGENTS.md")
796
- end
797
-
798
- def workspace_agents_file?(workspace_root)
799
- File.exist?(workspace_agents_path(workspace_root))
800
- end
801
-
802
- def workspace_agents_prompt(workspace_root)
803
- read_prompt_file(workspace_agents_path(workspace_root), "workspace AGENTS.md")
804
- end
805
-
806
- def prompt_source_signature(path)
807
- return "#{path}:missing" unless File.exist?(path)
808
-
809
- stat = File.stat(path)
810
- "#{path}:#{stat.size}:#{stat.mtime.to_f}"
811
- rescue StandardError
812
- "#{path}:unavailable"
813
- end
814
-
815
- def read_prompt_file(path, label)
816
- return nil unless File.exist?(path)
817
-
818
- size = File.size(path)
819
- if size > MAX_PROMPT_FILE_BYTES
820
- emit_warning "Warning: skipping #{label} #{path}: file too large (#{size} bytes; limit is #{MAX_PROMPT_FILE_BYTES} bytes)"
821
- return nil
822
- end
823
-
824
- File.read(path)
825
- rescue StandardError => e
826
- emit_warning "Warning: skipping #{label} #{path}: #{e.message}"
827
- nil
828
- end
829
-
830
- def canonical_workspace_root(path)
831
- expanded = File.expand_path(path.to_s.empty? ? Dir.pwd : path.to_s)
832
- File.directory?(expanded) ? File.realpath(expanded) : expanded
833
- end
834
-
835
- def add_persona_entry(entries, layer, value, name: nil)
836
- text = presence(value)
837
- return unless text
838
-
839
- entries << { layer: layer.to_s, name: name.to_s, prompt: text }
840
- end
841
-
842
- def crew_characters(personas)
843
- named_character_values(personas) do |_key, definition|
844
- extract_character_instruction(definition)
845
- end
846
- end
847
-
848
- def crew_character_labels(personas)
849
- named_character_values(personas) do |_key, definition|
850
- extract_character_label(definition)
851
- end
852
- end
853
-
854
- def resolved_persona_text(value, characters: {})
855
- return nil if value.nil?
856
-
857
- key = value.to_s.strip
858
- return nil if key.empty?
859
-
860
- text = characters[key.to_s]
861
- return text unless text.to_s.empty?
862
-
863
- value
864
- end
865
-
866
- def persona_label_for_key(value, labels)
867
- key = value.to_s.strip
868
- return nil if key.empty?
869
-
870
- presence(labels[key])
871
- end
872
-
873
- def named_character_values(personas)
874
- character_entries(personas["characters"] || personas["crew"]).each_with_object({}) do |(key, definition), mapping|
875
- value = yield(key, definition)
876
- next if value.to_s.empty?
877
-
878
- mapping[key.to_s] = value
879
- end
880
- end
881
-
882
- def character_entries(raw)
883
- case raw
884
- when Hash
885
- raw.map { |key, definition| [key, definition] }
886
- when Array
887
- raw.filter_map { |entry| character_entry(entry) }
888
- else
889
- []
890
- end
891
- end
892
-
893
- def character_entry(entry)
894
- return nil unless entry.is_a?(Hash)
895
-
896
- if entry.length == 1 && entry.keys.first.is_a?(String)
897
- [entry.keys.first, entry.values.first]
898
- else
899
- key = entry["key"] || entry[:key] || entry["id"] || entry[:id] || entry["name"] || entry[:name]
900
- key.to_s.empty? ? nil : [key, entry]
901
- end
902
- end
903
-
904
- def extract_character_label(definition)
905
- return nil unless definition.is_a?(Hash)
906
-
907
- presence(definition["label"] || definition[:label])
908
- end
909
-
910
- def extract_character_instruction(definition)
911
- return nil if definition.nil?
912
-
913
- if definition.is_a?(Hash)
914
- value = definition["instruction"] || definition[:instruction]
915
- return presence(value)
916
- end
917
-
918
- presence(definition)
919
- end
920
-
921
- def time_of_day_bucket(now)
922
- hour = now.hour
923
- return "morning" if hour >= 5 && hour < 11
924
- return "before_lunch" if hour == 11
925
- return "late_evening" if hour >= 21 || hour < 5
926
-
927
- nil
928
- end
929
-
930
- def weekday_name(now)
931
- %w[sunday monday tuesday wednesday thursday friday saturday][now.wday]
932
- end
933
-
934
- # Lists configured skills discovered under the config directory.
935
- #
936
- # @return [Array<Skill>] skill metadata available to the model
937
- def skills(workspace_root: Dir.pwd, warning_sink: nil, project_skill_paths: nil)
938
- skills_registry(workspace_root: workspace_root, warning_sink: warning_sink, project_skill_paths: project_skill_paths).skills
939
- end
940
-
941
- def project_skill_candidates(workspace_root: Dir.pwd, warning_sink: nil)
942
- skills_registry(workspace_root: workspace_root, warning_sink: warning_sink).project_skill_candidates
943
- end
944
-
945
- # @return [String] trusted user plugin directory
946
- def plugin_dir
947
- File.expand_path("~/.kward/plugins")
948
- end
949
-
950
- # Finds trusted plugin files and package entrypoints.
951
- #
952
- # Plugins are intentionally loaded only from `~/.kward/plugins`, not from a
953
- # workspace or custom `KWARD_CONFIG_PATH` directory.
954
- #
955
- # @return [Array<String>] sorted plugin file paths
956
- def plugin_paths(warning_sink: nil)
957
- plugins_root = plugin_dir
958
- return [] unless Dir.exist?(plugins_root)
959
-
960
- paths = Dir.glob(File.join(plugins_root, "*.rb"))
961
- paths.concat(Dir.glob(File.join(plugins_root, "*", "plugin.rb")))
962
- paths.sort
963
- rescue StandardError => e
964
- emit_warning("Warning: skipping Kward plugins in #{plugins_root}: #{e.message}", warning_sink: warning_sink)
965
- []
966
- end
967
-
968
- # Lists prompt templates exposed as slash commands.
969
- #
970
- # @param reserved_commands [Array<String>] command names unavailable to templates
971
- # @return [Array<PromptTemplate>] prompt template metadata and bodies
972
- def prompt_templates(reserved_commands: [])
973
- prompt_template_registry.prompt_templates(reserved_commands: reserved_commands)
974
- end
975
-
976
- # Reads a skill file by skill name and optional relative path.
977
- #
978
- # @param name [String] configured skill name
979
- # @param relative_path [String, nil] path inside the skill directory
980
- # @return [String] file contents or an error string
981
- def read_skill_file(name, relative_path = nil, workspace_root: Dir.pwd)
982
- skills_registry(workspace_root: workspace_root).read_skill_file(name, relative_path)
983
- end
984
-
985
- def skills_registry(workspace_root: Dir.pwd, warning_sink: nil, project_skill_paths: nil)
986
- Skills::Registry.new(
987
- config_dir: config_dir,
988
- workspace_root: workspace_root,
989
- project_skills_trusted: project_skills_trusted?,
990
- skill_class: Skill,
991
- max_file_bytes: MAX_SKILL_FILE_BYTES,
992
- markdown_parser: ->(path) { Frontmatter.markdown_parts(path, lenient: true) },
993
- inside_directory: method(:inside_directory?),
994
- warning_sink: warning_sink || @warning_sink,
995
- project_skill_paths: project_skill_paths
996
- )
997
- end
998
-
999
- def prompt_template_registry
1000
- Prompts::Templates.new(
1001
- config_dir: config_dir,
1002
- template_class: PromptTemplate,
1003
- markdown_parser: ->(path) { Frontmatter.markdown_parts(path) },
1004
- warning_sink: @warning_sink
1005
- )
1006
- end
1007
-
1008
- def emit_warning(message, warning_sink: nil)
1009
- sink = warning_sink || @warning_sink
1010
- sink ? sink.call(message) : warn(message)
1011
- end
1012
-
1013
- def inside_directory?(path, base)
1014
- PathGuard.inside?(path, base)
1015
- end
1016
-
1017
- def presence(value)
1018
- text = value.to_s
1019
- text.empty? ? nil : text
1020
- end
37
+ extend Core
38
+ extend Settings
39
+ extend Prompts
40
+ extend Extensions
1021
41
  end
1022
42
  end