kward 0.83.0 → 0.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +102 -15
  3. data/CONTRIBUTING.md +74 -0
  4. data/Gemfile.lock +8 -2
  5. data/README.md +21 -1
  6. data/Rakefile +46 -2
  7. data/SECURITY.md +31 -0
  8. data/doc/agent-tools.md +13 -1
  9. data/doc/api.md +21 -2
  10. data/doc/composer.md +2 -2
  11. data/doc/configuration.md +95 -25
  12. data/doc/editor.md +28 -13
  13. data/doc/extensibility.md +2 -1
  14. data/doc/files.md +8 -4
  15. data/doc/getting-started.md +3 -0
  16. data/doc/git.md +3 -1
  17. data/doc/pan.md +25 -15
  18. data/doc/permissions.md +4 -4
  19. data/doc/platform-support.md +48 -0
  20. data/doc/plugins.md +464 -15
  21. data/doc/rpc.md +154 -16
  22. data/doc/sandboxing.md +11 -5
  23. data/doc/security.md +10 -3
  24. data/doc/session-management.md +5 -4
  25. data/doc/shell.md +62 -45
  26. data/doc/tabs.md +6 -2
  27. data/doc/transports.md +15 -0
  28. data/doc/troubleshooting.md +12 -2
  29. data/doc/usage.md +9 -6
  30. data/doc/workspace-tools.md +9 -0
  31. data/examples/plugins/space_invaders.rb +1 -1
  32. data/examples/plugins/stardate_footer.rb +2 -2
  33. data/examples/plugins/telegram/plugin.rb +1 -1
  34. data/kward.gemspec +5 -4
  35. data/lib/kward/agent.rb +30 -14
  36. data/lib/kward/cli/auth_commands.rb +34 -13
  37. data/lib/kward/cli/commands.rb +83 -62
  38. data/lib/kward/cli/compaction.rb +9 -3
  39. data/lib/kward/cli/doctor.rb +39 -17
  40. data/lib/kward/cli/hook_commands.rb +22 -12
  41. data/lib/kward/cli/interactive_turn.rb +48 -7
  42. data/lib/kward/cli/plugins.rb +81 -12
  43. data/lib/kward/cli/project_skills_commands.rb +8 -4
  44. data/lib/kward/cli/prompt_interface.rb +52 -5
  45. data/lib/kward/cli/rendering.rb +18 -9
  46. data/lib/kward/cli/runtime_helpers.rb +228 -71
  47. data/lib/kward/cli/sessions.rb +9 -5
  48. data/lib/kward/cli/settings/menus.rb +745 -0
  49. data/lib/kward/cli/settings/model.rb +327 -0
  50. data/lib/kward/cli/settings.rb +6 -1055
  51. data/lib/kward/cli/slash_commands.rb +56 -17
  52. data/lib/kward/cli/tabs.rb +244 -33
  53. data/lib/kward/cli/tool_summaries.rb +14 -0
  54. data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +14 -7
  55. data/lib/kward/cli/worktrees.rb +65 -2
  56. data/lib/kward/cli.rb +70 -30
  57. data/lib/kward/compactor.rb +18 -7
  58. data/lib/kward/config/core.rb +389 -0
  59. data/lib/kward/config/extensions.rb +96 -0
  60. data/lib/kward/config/prompts.rb +313 -0
  61. data/lib/kward/config/settings.rb +250 -0
  62. data/lib/kward/config_files.rb +14 -994
  63. data/lib/kward/conversation.rb +31 -2
  64. data/lib/kward/image_attachments.rb +1 -1
  65. data/lib/kward/model/client.rb +36 -24
  66. data/lib/kward/model/copilot_models.rb +2 -2
  67. data/lib/kward/model/model_info.rb +20 -3
  68. data/lib/kward/{openrouter_model_cache.rb → model/openrouter_model_cache.rb} +3 -3
  69. data/lib/kward/model/payloads.rb +12 -3
  70. data/lib/kward/model/provider_catalog.rb +5 -0
  71. data/lib/kward/model/stream_parser.rb +20 -4
  72. data/lib/kward/model/typesafe_client.rb +78 -0
  73. data/lib/kward/pan/index.html.erb +3 -3
  74. data/lib/kward/pan/server.rb +33 -10
  75. data/lib/kward/permissions/policy.rb +6 -2
  76. data/lib/kward/plugin_registry.rb +2 -659
  77. data/lib/kward/plugins/actions.rb +453 -0
  78. data/lib/kward/plugins/chat_contract.rb +121 -0
  79. data/lib/kward/{plugin_chat_runtime.rb → plugins/chat_runtime.rb} +62 -18
  80. data/lib/kward/plugins/host.rb +232 -0
  81. data/lib/kward/plugins/registry.rb +1190 -0
  82. data/lib/kward/plugins/resources.rb +206 -0
  83. data/lib/kward/plugins/turn_request.rb +36 -0
  84. data/lib/kward/plugins/ui.rb +219 -0
  85. data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
  86. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  87. data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
  88. data/lib/kward/prompt_interface/editor/controller.rb +30 -33
  89. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  90. data/lib/kward/prompt_interface/editor/markdown_code_block.rb +136 -0
  91. data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
  92. data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
  93. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  94. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  95. data/lib/kward/prompt_interface/editor/scratchpad_languages.rb +74 -0
  96. data/lib/kward/prompt_interface/editor/scratchpad_runner.rb +182 -0
  97. data/lib/kward/prompt_interface/editor/state.rb +11 -11
  98. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  99. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  100. data/lib/kward/prompt_interface/file_overlay.rb +71 -15
  101. data/lib/kward/prompt_interface/key_handler.rb +67 -0
  102. data/lib/kward/prompt_interface/layout.rb +1 -1
  103. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  104. data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
  105. data/lib/kward/prompt_interface/project_browser.rb +415 -14
  106. data/lib/kward/prompt_interface/runtime_state.rb +56 -2
  107. data/lib/kward/prompt_interface/screen.rb +11 -4
  108. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  109. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  110. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  111. data/lib/kward/prompt_interface.rb +151 -27
  112. data/lib/kward/prompts/commands.rb +3 -2
  113. data/lib/kward/prompts.rb +1 -1
  114. data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
  115. data/lib/kward/pty/detached_run.rb +44 -0
  116. data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +104 -30
  117. data/lib/kward/{local_command_runner.rb → pty/local_command_runner.rb} +1 -1
  118. data/lib/kward/{local_pty_command_runner.rb → pty/local_pty_runner.rb} +3 -9
  119. data/lib/kward/{pty_output_sink.rb → pty/output_sink.rb} +52 -0
  120. data/lib/kward/{pty_transcript_normalizer.rb → pty/transcript_normalizer.rb} +1 -1
  121. data/lib/kward/rpc/plugin_chat_manager.rb +30 -10
  122. data/lib/kward/rpc/prompt_bridge.rb +25 -0
  123. data/lib/kward/rpc/server.rb +91 -12
  124. data/lib/kward/rpc/session_manager.rb +147 -44
  125. data/lib/kward/rpc/session_tree_rows.rb +2 -2
  126. data/lib/kward/rpc/tool_metadata.rb +1 -1
  127. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  128. data/lib/kward/sandbox/command_runner.rb +1 -1
  129. data/lib/kward/{session_catalog.rb → sessions/catalog.rb} +1 -1
  130. data/lib/kward/{session_store.rb → sessions/store.rb} +9 -9
  131. data/lib/kward/{session_tree_nodes.rb → sessions/tree_nodes.rb} +3 -3
  132. data/lib/kward/{session_tree_renderer.rb → sessions/tree_renderer.rb} +4 -4
  133. data/lib/kward/{session_tree_tool_display.rb → sessions/tree_tool_display.rb} +1 -1
  134. data/lib/kward/{ekwsh.rb → shell/kwsh.rb} +38 -19
  135. data/lib/kward/shell/kwshrc.rb +233 -0
  136. data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +121 -28
  137. data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +2 -0
  138. data/lib/kward/{shell_prompt_session.rb → shell/prompt_session.rb} +1 -1
  139. data/lib/kward/skills/trust_store.rb +1 -1
  140. data/lib/kward/tabs/driver.rb +194 -0
  141. data/lib/kward/{tab_store.rb → tabs/store.rb} +2 -2
  142. data/lib/kward/{ansi.rb → terminal/ansi.rb} +110 -10
  143. data/lib/kward/{clipboard.rb → terminal/clipboard.rb} +1 -1
  144. data/lib/kward/{terminal_image_support.rb → terminal/image_support.rb} +1 -1
  145. data/lib/kward/{terminal_keys.rb → terminal/keys.rb} +12 -0
  146. data/lib/kward/terminal/text.rb +121 -0
  147. data/lib/kward/text_matcher.rb +18 -0
  148. data/lib/kward/tools/base.rb +18 -0
  149. data/lib/kward/tools/context_for_task.rb +15 -6
  150. data/lib/kward/tools/edit_file.rb +9 -6
  151. data/lib/kward/tools/git_commit.rb +13 -7
  152. data/lib/kward/tools/list_directory.rb +4 -4
  153. data/lib/kward/tools/open_editor.rb +41 -0
  154. data/lib/kward/tools/plugin_tool.rb +41 -0
  155. data/lib/kward/tools/prepare_shell_command.rb +1 -1
  156. data/lib/kward/tools/read_file.rb +7 -6
  157. data/lib/kward/tools/registry.rb +109 -17
  158. data/lib/kward/tools/run_shell_command.rb +10 -8
  159. data/lib/kward/tools/search/code.rb +1 -1
  160. data/lib/kward/tools/summarize_file_structure.rb +5 -5
  161. data/lib/kward/tools/tool_call.rb +3 -1
  162. data/lib/kward/tools/typesafe_evaluate.rb +81 -0
  163. data/lib/kward/tools/workspace_targets.rb +58 -0
  164. data/lib/kward/tools/write_file.rb +9 -6
  165. data/lib/kward/{export_path.rb → transcripts/export_path.rb} +1 -1
  166. data/lib/kward/{markdown_transcript.rb → transcripts/markdown_transcript.rb} +2 -2
  167. data/lib/kward/transport/contracts.rb +200 -0
  168. data/lib/kward/transport/gateway.rb +79 -35
  169. data/lib/kward/transport/plugin_chat_gateway.rb +3 -2
  170. data/lib/kward/transport.rb +1 -200
  171. data/lib/kward/version.rb +1 -1
  172. data/lib/kward/{workspace_factory.rb → workspace/factory.rb} +2 -2
  173. data/lib/kward/{project_files.rb → workspace/files.rb} +2 -2
  174. data/lib/kward/{git_worktree_manager.rb → workspace/git_worktree_manager.rb} +28 -0
  175. data/lib/kward/{workspace.rb → workspace/workspace.rb} +3 -3
  176. data/templates/default/kward_navigation.rb +1 -0
  177. data/templates/default/layout/html/footer.erb +10 -0
  178. data/templates/default/layout/html/headers.erb +23 -0
  179. data/templates/default/layout/html/layout.erb +2 -2
  180. data/templates/default/layout/html/setup.rb +41 -2
  181. metadata +94 -47
  182. data/lib/kward/scratchpad_runner.rb +0 -56
  183. data/lib/kward/tab_driver.rb +0 -90
  184. /data/lib/kward/{editor_prompt.rb → cli/editor_prompt.rb} +0 -0
  185. /data/lib/kward/{editor_prompt_session.rb → cli/editor_prompt_session.rb} +0 -0
  186. /data/lib/kward/{diff_view_mode.rb → prompt_interface/editor/diff_view_mode.rb} +0 -0
  187. /data/lib/kward/{editor_mode.rb → prompt_interface/editor/editor_mode.rb} +0 -0
  188. /data/lib/kward/{session_diff.rb → sessions/diff.rb} +0 -0
  189. /data/lib/kward/{session_naming.rb → sessions/naming.rb} +0 -0
  190. /data/lib/kward/{session_trash.rb → sessions/trash.rb} +0 -0
  191. /data/lib/kward/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
  192. /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
  193. /data/lib/kward/{path_guard.rb → workspace/path_guard.rb} +0 -0
@@ -0,0 +1,313 @@
1
+ # Configuration prompts behavior.
2
+ module Kward
3
+ module ConfigFiles
4
+ module Prompts
5
+ extend self
6
+
7
+ def agents_prompt(config: read_config)
8
+ return nil unless include_config_principles?(config)
9
+
10
+ path = config_principles_path
11
+ return read_prompt_file(path, "Kward principles file") if File.exist?(path)
12
+
13
+ read_prompt_file(config_agents_path, "Kward AGENTS.md alias")
14
+ end
15
+
16
+ # Returns whether config-directory principles are included in normal system
17
+ # prompt assembly. Replacement prompt files bypass all assembled sections.
18
+ def include_config_principles?(config = read_config)
19
+ settings = config["system_prompt"]
20
+ return true unless settings.is_a?(Hash)
21
+
22
+ settings["include_principles"] != false
23
+ end
24
+
25
+ # Returns the configured replacement system prompt path, or nil when normal
26
+ # prompt assembly should be used. Relative paths are anchored to the config
27
+ # directory so configuration remains portable with KWARD_CONFIG_PATH.
28
+ def system_prompt_file_path(config = read_config)
29
+ settings = config["system_prompt"]
30
+ value = settings.is_a?(Hash) ? settings["file"].to_s.strip : ""
31
+ return nil if value.empty?
32
+
33
+ File.expand_path(value, config_dir)
34
+ end
35
+
36
+ # Returns the replacement system prompt text when configured. A configured
37
+ # but unavailable file intentionally yields nil: callers must not fall back
38
+ # to Kward's larger assembled prompt in replacement mode.
39
+ def system_prompt_file(config = read_config)
40
+ path = system_prompt_file_path(config)
41
+ return nil unless path
42
+
43
+ read_prompt_file(path, "custom system prompt file")
44
+ end
45
+
46
+ def replacement_system_prompt?(config = read_config)
47
+ !system_prompt_file_path(config).nil?
48
+ end
49
+
50
+ # Returns a lightweight fingerprint for config-owned prompt sources. It is
51
+ # used by active conversations to pick up prompt edits without a restart.
52
+ def system_prompt_sources_fingerprint
53
+ config = read_config
54
+ paths = [config_path, system_prompt_file_path(config)]
55
+ if include_config_principles?(config)
56
+ paths << (File.exist?(config_principles_path) ? config_principles_path : config_agents_path)
57
+ end
58
+
59
+ paths.compact.map { |path| prompt_source_signature(path) }.join("\0")
60
+ end
61
+
62
+ def config_principles_path
63
+ File.join(config_dir, "PRINCIPLES.md")
64
+ end
65
+
66
+ def config_agents_path
67
+ File.join(config_dir, "AGENTS.md")
68
+ end
69
+
70
+ # Builds persona prompt text from default, workspace, model, reasoning,
71
+ # time-of-day, weekday, and suffix config entries.
72
+ #
73
+ # Persona resolution is intentionally data-driven so users can edit config
74
+ # without plugin code. Keep new persona selectors additive and deterministic;
75
+ # prompt construction depends on stable ordering.
76
+ #
77
+ # @param workspace_root [String] active workspace root
78
+ # @param model [String, nil] active model name
79
+ # @param reasoning_effort [String, nil] active reasoning effort
80
+ # @param now [Time] local time used for time-based modifiers
81
+ # @param config [Hash] parsed config object
82
+ # @return [String, nil] persona prompt text when entries match
83
+ def persona_prompt(workspace_root, model: nil, reasoning_effort: nil, now: Time.now, config: read_config)
84
+ text = persona_entries(workspace_root: workspace_root, model: model, reasoning_effort: reasoning_effort, now: now, config: config).map do |entry|
85
+ entry[:prompt]
86
+ end.join("\n\n")
87
+ return nil if text.empty?
88
+
89
+ text
90
+ end
91
+
92
+ # Returns the label of the persona selected by default/workspace/model rules.
93
+ def active_persona_label(workspace_root:, model: nil, config: read_config)
94
+ personas = config["personas"]
95
+ return nil unless personas.is_a?(Hash)
96
+
97
+ labels = crew_character_labels(personas)
98
+ active_label = persona_label_for_key(personas["default"], labels) unless personas["default"].nil?
99
+
100
+ workspaces = personas["workspaces"]
101
+ if workspaces.is_a?(Hash)
102
+ root = canonical_workspace_root(workspace_root)
103
+ workspaces.each do |path, key|
104
+ next unless canonical_workspace_root(path) == root
105
+
106
+ active_label = persona_label_for_key(key, labels)
107
+ break
108
+ end
109
+ end
110
+
111
+ models = personas["models"]
112
+ if models.is_a?(Hash) && !model.to_s.empty? && models.key?(model.to_s)
113
+ active_label = persona_label_for_key(models[model.to_s], labels)
114
+ end
115
+
116
+ active_label
117
+ end
118
+
119
+ def persona_entries(workspace_root:, model: nil, reasoning_effort: nil, now: Time.now, config: read_config, include_reasoning: true)
120
+ personas = config["personas"]
121
+ return [] unless personas.is_a?(Hash)
122
+
123
+ characters = crew_characters(personas)
124
+ entries = []
125
+ active_persona = { layer: "default", value: personas["default"], name: nil }
126
+
127
+ workspaces = personas["workspaces"]
128
+ if workspaces.is_a?(Hash)
129
+ root = canonical_workspace_root(workspace_root)
130
+ workspaces.each do |path, key|
131
+ if canonical_workspace_root(path) == root
132
+ active_persona = { layer: "workspace", value: key, name: path }
133
+ break
134
+ end
135
+ end
136
+ end
137
+
138
+ models = personas["models"]
139
+ if models.is_a?(Hash) && !model.to_s.empty? && models.key?(model.to_s)
140
+ active_persona = { layer: "model", value: models[model.to_s], name: model.to_s }
141
+ end
142
+
143
+ add_persona_entry(
144
+ entries,
145
+ active_persona.fetch(:layer),
146
+ resolved_persona_text(active_persona.fetch(:value), characters: characters),
147
+ name: active_persona[:name]
148
+ )
149
+
150
+ modifiers = personas["persona_modifiers"]
151
+ if modifiers.is_a?(Hash)
152
+ if include_reasoning
153
+ reasoning = modifiers["reasoning"]
154
+ add_persona_entry(entries, "reasoning", reasoning[reasoning_effort.to_s]) if reasoning.is_a?(Hash) && !reasoning_effort.to_s.empty?
155
+ end
156
+
157
+ time_of_day = modifiers["time_of_day"]
158
+ bucket = time_of_day_bucket(now)
159
+ add_persona_entry(entries, "time_of_day", time_of_day[bucket], name: bucket) if time_of_day.is_a?(Hash)
160
+
161
+ weekday = modifiers["weekday"]
162
+ day = weekday_name(now)
163
+ add_persona_entry(entries, "weekday", weekday[day], name: day) if weekday.is_a?(Hash)
164
+
165
+ add_persona_entry(entries, "suffix", modifiers["suffix"])
166
+ end
167
+
168
+ entries
169
+ end
170
+
171
+ def workspace_agents_path(workspace_root)
172
+ File.join(canonical_workspace_root(workspace_root), "AGENTS.md")
173
+ end
174
+
175
+ def workspace_agents_file?(workspace_root)
176
+ File.exist?(workspace_agents_path(workspace_root))
177
+ end
178
+
179
+ def workspace_agents_prompt(workspace_root)
180
+ read_prompt_file(workspace_agents_path(workspace_root), "workspace AGENTS.md")
181
+ end
182
+
183
+ def prompt_source_signature(path)
184
+ return "#{path}:missing" unless File.exist?(path)
185
+
186
+ stat = File.stat(path)
187
+ "#{path}:#{stat.size}:#{stat.mtime.to_f}"
188
+ rescue StandardError
189
+ "#{path}:unavailable"
190
+ end
191
+
192
+ def read_prompt_file(path, label)
193
+ return nil unless File.exist?(path)
194
+
195
+ size = File.size(path)
196
+ if size > MAX_PROMPT_FILE_BYTES
197
+ emit_warning "Warning: skipping #{label} #{path}: file too large (#{size} bytes; limit is #{MAX_PROMPT_FILE_BYTES} bytes)"
198
+ return nil
199
+ end
200
+
201
+ File.read(path)
202
+ rescue StandardError => e
203
+ emit_warning "Warning: skipping #{label} #{path}: #{e.message}"
204
+ nil
205
+ end
206
+
207
+ def canonical_workspace_root(path)
208
+ expanded = File.expand_path(path.to_s.empty? ? Dir.pwd : path.to_s)
209
+ File.directory?(expanded) ? File.realpath(expanded) : expanded
210
+ end
211
+
212
+ def add_persona_entry(entries, layer, value, name: nil)
213
+ text = presence(value)
214
+ return unless text
215
+
216
+ entries << { layer: layer.to_s, name: name.to_s, prompt: text }
217
+ end
218
+
219
+ def crew_characters(personas)
220
+ named_character_values(personas) do |_key, definition|
221
+ extract_character_instruction(definition)
222
+ end
223
+ end
224
+
225
+ def crew_character_labels(personas)
226
+ named_character_values(personas) do |_key, definition|
227
+ extract_character_label(definition)
228
+ end
229
+ end
230
+
231
+ def resolved_persona_text(value, characters: {})
232
+ return nil if value.nil?
233
+
234
+ key = value.to_s.strip
235
+ return nil if key.empty?
236
+
237
+ text = characters[key.to_s]
238
+ return text unless text.to_s.empty?
239
+
240
+ value
241
+ end
242
+
243
+ def persona_label_for_key(value, labels)
244
+ key = value.to_s.strip
245
+ return nil if key.empty?
246
+
247
+ presence(labels[key])
248
+ end
249
+
250
+ def named_character_values(personas)
251
+ character_entries(personas["characters"] || personas["crew"]).each_with_object({}) do |(key, definition), mapping|
252
+ value = yield(key, definition)
253
+ next if value.to_s.empty?
254
+
255
+ mapping[key.to_s] = value
256
+ end
257
+ end
258
+
259
+ def character_entries(raw)
260
+ case raw
261
+ when Hash
262
+ raw.map { |key, definition| [key, definition] }
263
+ when Array
264
+ raw.filter_map { |entry| character_entry(entry) }
265
+ else
266
+ []
267
+ end
268
+ end
269
+
270
+ def character_entry(entry)
271
+ return nil unless entry.is_a?(Hash)
272
+
273
+ if entry.length == 1 && entry.keys.first.is_a?(String)
274
+ [entry.keys.first, entry.values.first]
275
+ else
276
+ key = entry["key"] || entry[:key] || entry["id"] || entry[:id] || entry["name"] || entry[:name]
277
+ key.to_s.empty? ? nil : [key, entry]
278
+ end
279
+ end
280
+
281
+ def extract_character_label(definition)
282
+ return nil unless definition.is_a?(Hash)
283
+
284
+ presence(definition["label"] || definition[:label])
285
+ end
286
+
287
+ def extract_character_instruction(definition)
288
+ return nil if definition.nil?
289
+
290
+ if definition.is_a?(Hash)
291
+ value = definition["instruction"] || definition[:instruction]
292
+ return presence(value)
293
+ end
294
+
295
+ presence(definition)
296
+ end
297
+
298
+ def time_of_day_bucket(now)
299
+ hour = now.hour
300
+ return "morning" if hour >= 5 && hour < 11
301
+ return "before_lunch" if hour == 11
302
+ return "late_evening" if hour >= 21 || hour < 5
303
+
304
+ nil
305
+ end
306
+
307
+ def weekday_name(now)
308
+ %w[sunday monday tuesday wednesday thursday friday saturday][now.wday]
309
+ end
310
+
311
+ end
312
+ end
313
+ end
@@ -0,0 +1,250 @@
1
+ # Configuration settings behavior.
2
+ module Kward
3
+ module ConfigFiles
4
+ module Settings
5
+ extend self
6
+
7
+ def overlay_settings(config = read_config)
8
+ overlay = config["overlay"].is_a?(Hash) ? config["overlay"] : {}
9
+ settings = DEFAULT_OVERLAY_SETTINGS.dup
10
+ alignment = overlay["alignment"].to_s
11
+ width = overlay["width"].to_s
12
+ settings["alignment"] = alignment if OVERLAY_ALIGNMENTS.include?(alignment)
13
+ settings["width"] = width if OVERLAY_WIDTHS.include?(width)
14
+ settings
15
+ end
16
+
17
+ # Returns the project browser icon theme, or off when unset or invalid.
18
+ def project_browser_icon_theme(config = read_config)
19
+ browser = config["project_browser"].is_a?(Hash) ? config["project_browser"] : {}
20
+ theme = browser["icons"].to_s
21
+ PROJECT_BROWSER_ICON_THEMES.include?(theme) ? theme : "off"
22
+ end
23
+
24
+ # Returns whether the composer should show busy-state keyboard help.
25
+ def composer_busy_help?(config = read_config)
26
+ composer = config["composer"].is_a?(Hash) ? config["composer"] : {}
27
+ composer["busy_help"] != false
28
+ end
29
+
30
+ # Returns the configured tab keybinding family, or auto when unset/invalid.
31
+ def composer_tab_keybindings(config = read_config)
32
+ composer = config["composer"].is_a?(Hash) ? config["composer"] : {}
33
+ value = composer["tab_keybindings"].to_s.downcase
34
+ %w[auto ctrl alt].include?(value) ? value : "auto"
35
+ end
36
+
37
+ # Returns the built-in TUI editor keymap mode.
38
+ def editor_mode(config = read_config)
39
+ editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
40
+ EditorMode.normalize(editor["mode"])
41
+ end
42
+
43
+ # Returns configured editor runner definitions, or an empty map.
44
+ #
45
+ # Runner defaults are resolved by the editor runner at execution time so
46
+ # the generated config remains portable across machines.
47
+ def editor_runners(config = read_config)
48
+ editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
49
+ runners = editor["runners"]
50
+ runners.is_a?(Hash) ? runners : {}
51
+ end
52
+
53
+ # Returns the optional provider override for editor-agent turns.
54
+ def editor_agent_provider(config = read_config)
55
+ value = ENV["KWARD_EDITOR_PROVIDER"].to_s.strip
56
+ return value unless value.empty?
57
+
58
+ editor_agent_setting(config, "provider")
59
+ end
60
+
61
+ # Returns the optional model override for editor-agent turns.
62
+ def editor_agent_model(config = read_config)
63
+ editor_agent_setting(config, "model")
64
+ end
65
+
66
+ # Returns the optional reasoning-effort override for editor-agent turns.
67
+ def editor_agent_reasoning_effort(config = read_config)
68
+ editor_agent_setting(config, "reasoning_effort")
69
+ end
70
+
71
+ # Returns the shell-agent provider override, preferring the environment.
72
+ def shell_agent_provider(config = read_config)
73
+ value = ENV["KWSH_PROVIDER"].to_s.strip
74
+ return value unless value.empty?
75
+
76
+ shell_agent_setting(config, "provider")
77
+ end
78
+
79
+ # Returns the shell-agent model override, preferring the environment.
80
+ def shell_agent_model(config = read_config)
81
+ value = ENV["KWSH_MODE"].to_s.strip
82
+ return value unless value.empty?
83
+
84
+ shell_agent_setting(config, "model")
85
+ end
86
+
87
+ # Returns the shell-agent reasoning-effort override, preferring the environment.
88
+ def shell_agent_reasoning_effort(config = read_config)
89
+ value = ENV["KWSH_REASONING"].to_s.strip
90
+ return value unless value.empty?
91
+
92
+ shell_agent_setting(config, "reasoning_effort")
93
+ end
94
+
95
+ def editor_agent_setting(config, key)
96
+ editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
97
+ agent = editor["agent"].is_a?(Hash) ? editor["agent"] : {}
98
+ value = agent[key].to_s.strip
99
+ value.empty? ? nil : value
100
+ end
101
+
102
+ def shell_agent_setting(config, key)
103
+ shell = config["shell"].is_a?(Hash) ? config["shell"] : {}
104
+ agent = shell["agent"].is_a?(Hash) ? shell["agent"] : {}
105
+ value = agent[key].to_s.strip
106
+ value.empty? ? nil : value
107
+ end
108
+
109
+ # Returns whether the built-in TUI editor should auto-indent new lines.
110
+ def editor_auto_indent?(config = read_config)
111
+ editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
112
+ editor["auto_indent"] != false
113
+ end
114
+
115
+ # Returns whether the built-in TUI editor should auto-close typed pairs.
116
+ def editor_auto_close_pairs?(config = read_config)
117
+ editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
118
+ editor["auto_close_pairs"] != false
119
+ end
120
+
121
+ # Returns whether the built-in TUI editor should soft-wrap long lines.
122
+ def editor_soft_wrap?(config = read_config)
123
+ editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
124
+ editor["soft_wrap"] != false
125
+ end
126
+
127
+ # Returns whether editable built-in TUI editor buffers should use a bar cursor.
128
+ def editor_bar_cursor?(config = read_config)
129
+ editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
130
+ editor["bar_cursor"] != false
131
+ end
132
+
133
+ # Returns the built-in TUI editor line-number display mode.
134
+ def editor_line_numbers(config = read_config)
135
+ editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
136
+ EditorMode.normalize_line_numbers(editor["line_numbers"])
137
+ end
138
+
139
+ # Returns the integrated diff viewer display mode.
140
+ def diff_view(config = read_config)
141
+ editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
142
+ DiffViewMode.normalize(editor["diff_view"])
143
+ end
144
+
145
+ # Returns whether file tools must stay inside the active workspace root.
146
+ def workspace_guardrails_enabled?(config = read_config)
147
+ tools = config["tools"].is_a?(Hash) ? config["tools"] : {}
148
+ tools["workspace_guardrails"] != false
149
+ end
150
+
151
+ # Builds the opt-in model-tool permission policy from persisted configuration.
152
+ def permission_policy(config = read_config)
153
+ Permissions::Policy.from_config(config)
154
+ end
155
+
156
+ # Builds the user-controlled command sandbox policy for a workspace.
157
+ def sandbox_policy(workspace_root, config = read_config)
158
+ sandbox = config["sandbox"].is_a?(Hash) ? config["sandbox"] : {}
159
+ Sandbox::Policy.new(
160
+ mode: sandbox.fetch("mode", "off"),
161
+ network: sandbox.fetch("network", "deny"),
162
+ workspace_root: workspace_root,
163
+ writable_roots: sandbox.fetch("writable_roots", []),
164
+ protect_git_metadata: sandbox.fetch("protect_git_metadata", true)
165
+ )
166
+ end
167
+
168
+ # Returns whether project-level Agent Skills should be loaded from the workspace.
169
+ def project_skills_trusted?(config = read_config)
170
+ skills = config["skills"].is_a?(Hash) ? config["skills"] : {}
171
+ skills["trust_project"] == true
172
+ end
173
+
174
+ # Returns whether new frontends should resume the last active session automatically.
175
+ def session_auto_resume_enabled?(config = read_config)
176
+ sessions = config["sessions"].is_a?(Hash) ? config["sessions"] : {}
177
+ sessions["auto_resume"] == true
178
+ end
179
+
180
+ # Returns whether workspace AGENTS.md contents should be injected directly
181
+ # instead of a compact read-when-relevant instruction.
182
+ def enforce_workspace_agents_file?(config = read_config)
183
+ config["enforce_workspace_agents_file"] == true
184
+ end
185
+
186
+ # Returns the nested web-search config object, or an empty config when absent.
187
+ def web_search_config(config = read_config)
188
+ value = config["web_search"]
189
+ value.is_a?(Hash) ? value : {}
190
+ end
191
+
192
+ # Returns the private configuration namespace for one identified plugin.
193
+ def plugin_config(plugin_id, config = read_config)
194
+ plugins = config.fetch("plugins", {})
195
+ raise ArgumentError, "Kward plugin config must be an object" unless plugins.is_a?(Hash)
196
+
197
+ values = plugins.fetch(plugin_id.to_s, {})
198
+ raise ArgumentError, "Kward plugin config for #{plugin_id} must be an object" unless values.is_a?(Hash)
199
+
200
+ DeepCopy.dup(values)
201
+ end
202
+
203
+ # Returns the private configuration namespace for one transport.
204
+ def transport_config(transport_id, config = read_config)
205
+ transports = config["transports"]
206
+ return {} unless transports.is_a?(Hash)
207
+
208
+ values = transports[transport_id.to_s]
209
+ values.is_a?(Hash) ? DeepCopy.dup(values) : {}
210
+ end
211
+
212
+ # Returns configured MCP stdio servers, or an empty config when absent.
213
+ def mcp_servers(config = read_config)
214
+ value = config["mcpServers"] || config.dig("mcp", "servers")
215
+ value.is_a?(Hash) ? value : {}
216
+ end
217
+
218
+ # Validates and persists terminal overlay settings.
219
+ def update_overlay_settings(values)
220
+ raise "Overlay settings must be an object" unless values.is_a?(Hash)
221
+
222
+ config = read_config
223
+ overlay = overlay_settings(config)
224
+ values.each do |key, value|
225
+ key = key.to_s
226
+ value = value.to_s
227
+ case key
228
+ when "alignment"
229
+ raise "Overlay alignment must be left, center, or right" unless OVERLAY_ALIGNMENTS.include?(value)
230
+ when "width"
231
+ raise "Overlay width must be capped or maximum" unless OVERLAY_WIDTHS.include?(value)
232
+ else
233
+ raise "Unknown overlay setting: #{key}"
234
+ end
235
+ overlay[key] = value
236
+ end
237
+ config["overlay"] = overlay
238
+ write_config(config)
239
+ overlay_settings(config)
240
+ end
241
+
242
+ # Reads global principle instructions from the config directory.
243
+ #
244
+ # `PRINCIPLES.md` is preferred. `AGENTS.md` remains a backwards-compatible
245
+ # alias for existing installations.
246
+ #
247
+ # @return [String, nil] prompt text, or nil when absent/too large
248
+ end
249
+ end
250
+ end