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