kward 0.79.0 → 0.80.1

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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +6 -0
  3. data/CHANGELOG.md +65 -1
  4. data/Gemfile.lock +2 -2
  5. data/README.md +31 -38
  6. data/doc/agent-tools.md +9 -10
  7. data/doc/api.md +3 -3
  8. data/doc/authentication.md +79 -110
  9. data/doc/code-search.md +1 -1
  10. data/doc/composer.md +1 -1
  11. data/doc/configuration.md +69 -26
  12. data/doc/context-budgeting.md +6 -6
  13. data/doc/context-tools.md +2 -2
  14. data/doc/editor.md +7 -7
  15. data/doc/extensibility.md +7 -29
  16. data/doc/files.md +6 -6
  17. data/doc/getting-started.md +2 -2
  18. data/doc/git.md +8 -2
  19. data/doc/lifecycle-hooks.md +5 -4
  20. data/doc/local-models.md +2 -2
  21. data/doc/mcp.md +5 -5
  22. data/doc/permissions.md +13 -4
  23. data/doc/plugins.md +39 -4
  24. data/doc/prompt-templates.md +105 -0
  25. data/doc/providers.md +140 -0
  26. data/doc/releasing.md +10 -2
  27. data/doc/rpc.md +54 -31
  28. data/doc/sandboxing.md +22 -0
  29. data/doc/security.md +22 -7
  30. data/doc/session-management.md +2 -4
  31. data/doc/skills.md +2 -0
  32. data/doc/tabs.md +37 -0
  33. data/doc/telegram.md +138 -0
  34. data/doc/transports.md +209 -0
  35. data/doc/usage.md +11 -3
  36. data/doc/web-search.md +3 -3
  37. data/doc/workspace-tools.md +2 -2
  38. data/examples/plugins/stardate_footer.rb +10 -0
  39. data/examples/plugins/telegram/plugin.rb +39 -0
  40. data/examples/plugins/telegram/telegram_api.rb +136 -0
  41. data/examples/plugins/telegram/telegram_transport.rb +304 -0
  42. data/lib/kward/agent.rb +11 -6
  43. data/lib/kward/ansi.rb +1 -0
  44. data/lib/kward/auth/api_key_store.rb +99 -0
  45. data/lib/kward/cli/auth_commands.rb +72 -13
  46. data/lib/kward/cli/commands.rb +7 -0
  47. data/lib/kward/cli/git.rb +31 -8
  48. data/lib/kward/cli/memory_commands.rb +2 -2
  49. data/lib/kward/cli/plugins.rb +1 -0
  50. data/lib/kward/cli/prompt_interface.rb +44 -1
  51. data/lib/kward/cli/rendering.rb +6 -2
  52. data/lib/kward/cli/runtime_helpers.rb +5 -2
  53. data/lib/kward/cli/sessions.rb +1 -1
  54. data/lib/kward/cli/settings.rb +97 -33
  55. data/lib/kward/cli/slash_commands.rb +1 -1
  56. data/lib/kward/cli/tabs.rb +103 -37
  57. data/lib/kward/cli/transports.rb +67 -0
  58. data/lib/kward/cli/worktrees.rb +360 -0
  59. data/lib/kward/cli.rb +22 -0
  60. data/lib/kward/compactor.rb +7 -2
  61. data/lib/kward/config_files.rb +51 -12
  62. data/lib/kward/conversation.rb +13 -7
  63. data/lib/kward/git_worktree_manager.rb +291 -0
  64. data/lib/kward/hooks/audit_log.rb +5 -2
  65. data/lib/kward/model/azure_openai_config.rb +54 -0
  66. data/lib/kward/model/catalog.rb +113 -0
  67. data/lib/kward/model/client.rb +225 -20
  68. data/lib/kward/model/model_info.rb +49 -0
  69. data/lib/kward/model/payloads.rb +63 -4
  70. data/lib/kward/model/provider_catalog.rb +114 -0
  71. data/lib/kward/model/sources.rb +70 -0
  72. data/lib/kward/model/stream_parser.rb +83 -13
  73. data/lib/kward/permissions/policy.rb +18 -4
  74. data/lib/kward/plugin_chat_runtime.rb +374 -0
  75. data/lib/kward/plugin_registry.rb +99 -19
  76. data/lib/kward/private_file.rb +9 -3
  77. data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
  78. data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
  79. data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
  80. data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
  81. data/lib/kward/prompt_interface/editor/controller.rb +7 -7
  82. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  83. data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
  84. data/lib/kward/prompt_interface/editor/state.rb +3 -9
  85. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +219 -19
  86. data/lib/kward/prompt_interface/file_overlay.rb +2 -2
  87. data/lib/kward/prompt_interface/git_prompt.rb +7 -3
  88. data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
  89. data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
  90. data/lib/kward/prompt_interface/interactive/state.rb +2 -2
  91. data/lib/kward/prompt_interface/project_browser.rb +25 -6
  92. data/lib/kward/prompt_interface/runtime_state.rb +22 -6
  93. data/lib/kward/prompt_interface/screen.rb +3 -0
  94. data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
  95. data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
  96. data/lib/kward/prompt_interface.rb +53 -12
  97. data/lib/kward/prompts/commands.rb +1 -1
  98. data/lib/kward/prompts/templates.rb +11 -6
  99. data/lib/kward/prompts.rb +6 -5
  100. data/lib/kward/rpc/auth_manager.rb +112 -152
  101. data/lib/kward/rpc/config_manager.rb +46 -6
  102. data/lib/kward/rpc/plugin_chat_manager.rb +55 -196
  103. data/lib/kward/rpc/prompt_bridge.rb +8 -4
  104. data/lib/kward/rpc/redactor.rb +1 -1
  105. data/lib/kward/rpc/server.rb +60 -10
  106. data/lib/kward/rpc/session_manager.rb +109 -30
  107. data/lib/kward/session_store.rb +84 -24
  108. data/lib/kward/skills/registry.rb +16 -11
  109. data/lib/kward/tab_driver.rb +6 -3
  110. data/lib/kward/telemetry/logger.rb +5 -2
  111. data/lib/kward/tools/git_commit.rb +39 -0
  112. data/lib/kward/tools/registry.rb +7 -2
  113. data/lib/kward/tools/tool_call.rb +1 -0
  114. data/lib/kward/transport/gateway.rb +253 -0
  115. data/lib/kward/transport/host.rb +343 -0
  116. data/lib/kward/transport/manager.rb +179 -0
  117. data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
  118. data/lib/kward/transport/runtime.rb +41 -0
  119. data/lib/kward/transport/store.rb +101 -0
  120. data/lib/kward/transport.rb +204 -0
  121. data/lib/kward/version.rb +1 -1
  122. data/lib/kward/workspace_factory.rb +19 -2
  123. data/templates/default/fulldoc/html/js/kward.js +2 -0
  124. data/templates/default/kward_navigation.rb +7 -2
  125. data/templates/default/layout/html/setup.rb +4 -0
  126. metadata +26 -1
@@ -0,0 +1,360 @@
1
+ require "securerandom"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Coordinates Git worktree bindings with session-backed tab runtimes.
6
+ class CLI
7
+ module Worktrees
8
+ private
9
+
10
+ def git_worktree_manager
11
+ @git_worktree_manager ||= GitWorktreeManager.new
12
+ end
13
+
14
+ def worktree_binding_for(tab)
15
+ driver = tab&.driver
16
+ driver.worktree if driver&.respond_to?(:worktree)
17
+ end
18
+
19
+ def worktree_root_for(binding, fallback: current_workspace_root)
20
+ return fallback unless binding
21
+
22
+ binding.active? ? binding.path : binding.origin_root
23
+ end
24
+
25
+ def reconcile_worktree_binding(tab, conversation)
26
+ binding = worktree_binding_for(tab)
27
+ return nil unless binding
28
+ return binding unless binding.active?
29
+
30
+ binding.active = false unless File.realpath(conversation.workspace_root) == File.realpath(binding.path)
31
+ binding
32
+ rescue Errno::ENOENT, Errno::ENOTDIR
33
+ binding.active = false
34
+ binding
35
+ end
36
+
37
+ def validate_worktree_binding!(binding)
38
+ return unless binding
39
+
40
+ unless File.directory?(binding.origin_root)
41
+ raise GitWorktreeManager::Error, "Original workspace is unavailable: #{binding.origin_root}"
42
+ end
43
+
44
+ return unless binding.active?
45
+
46
+ info = git_worktree_manager.inspect(repository_root: binding.repository_root, path: binding.path)
47
+ return if info.branch == binding.branch
48
+
49
+ raise GitWorktreeManager::Error, "Worktree branch changed: expected #{binding.branch}, found #{info.branch || "detached HEAD"}"
50
+ end
51
+
52
+ def rebind_session_tab(tab, root:, worktree:)
53
+ raise GitWorktreeManager::Error, "A session tab is required." unless tab&.session
54
+ raise GitWorktreeManager::Error, "The session store is unavailable." unless @session_store
55
+
56
+ session, conversation = @session_store.load(
57
+ tab.session.path,
58
+ workspace: configured_workspace(root: root, strict: worktree&.active? == true),
59
+ provider: current_model_provider,
60
+ model: current_model_id,
61
+ reasoning_effort: current_reasoning_effort
62
+ )
63
+ agent = build_tab_agent(conversation, session, worktree: worktree)
64
+ tab.session = track_session(session)
65
+ tab.agent = agent
66
+ tab.driver = SessionTabDriver.new(session: tab.session, agent: agent, worktree: worktree)
67
+ tab.diff = SessionDiff.from_session_file(tab.session.path)
68
+ tab.status = "idle"
69
+ tab.thread = nil
70
+ tab.cancellation = nil
71
+ tab.steering = nil
72
+ tab.error = nil
73
+ tab.answer = nil
74
+ tab.pending_question = nil
75
+ tab.unread = false
76
+ tab.error_reported = false
77
+ tab.event_history.clear
78
+ tab.seen_events = 0
79
+ tab.queued_inputs.clear
80
+ tab.shell = nil
81
+ tab.stream_state = new_tab_stream_state(tab.driver)
82
+ tab.markdown_chunks.clear
83
+ assign_tab_question_prompt(tab.agent, tab)
84
+ if tab == active_tab
85
+ @active_session = tab.session
86
+ @session_diff = tab.diff
87
+ update_assistant_prompt(tab.agent.conversation)
88
+ end
89
+ tab
90
+ end
91
+
92
+ def handle_worktree_command(argument)
93
+ case argument.to_s.strip
94
+ when "", "activate"
95
+ activate_active_tab_worktree
96
+ when "detach"
97
+ detach_current_worktree
98
+ when "status"
99
+ print_active_worktree_status
100
+ when "remove"
101
+ remove_active_worktree
102
+ when "merge"
103
+ merge_active_worktree
104
+ when "merge abort"
105
+ abort_active_worktree_merge
106
+ else
107
+ runtime_output("Usage: /tab worktree [activate|detach|status|merge|merge abort|remove]")
108
+ end
109
+ end
110
+
111
+ def activate_active_tab_worktree
112
+ tab = active_tab
113
+ return runtime_output("There is no active tab.") unless tab
114
+ return runtime_output("Worktrees are available only for normal session tabs.") if plugin_tab?(tab)
115
+ return runtime_output("Tab #{active_tab_number} is running and cannot change workspaces yet.") if tab&.running? || tab&.local_busy? || tab&.shell
116
+
117
+ binding = worktree_binding_for(tab)
118
+ return runtime_output("Tab #{active_tab_number} is already using #{binding.branch} at #{binding.path}.") if binding&.active?
119
+ return activate_existing_worktree(tab, binding) if binding
120
+
121
+ attach_new_worktree(tab)
122
+ rescue GitWorktreeManager::Error, Sandbox::UnavailableError => e
123
+ runtime_output("Worktree error: #{e.message}")
124
+ end
125
+
126
+ def detach_current_worktree
127
+ tab = active_tab
128
+ binding = worktree_binding_for(tab)
129
+ return runtime_output("Tab #{active_tab_number} has no worktree binding.") unless binding
130
+ return runtime_output("Tab #{active_tab_number} is not using its linked worktree.") unless binding.active?
131
+ return runtime_output("Tab #{active_tab_number} is running and cannot change workspaces yet.") if tab.running? || tab.local_busy? || tab.shell
132
+
133
+ detach_active_worktree(tab, binding)
134
+ rescue GitWorktreeManager::Error, Sandbox::UnavailableError => e
135
+ runtime_output("Worktree error: #{e.message}")
136
+ end
137
+
138
+ def attach_new_worktree(tab)
139
+ origin_root = tab.agent.conversation.workspace_root
140
+ repository_root = git_worktree_manager.repository_root(origin_root)
141
+ status = git_worktree_manager.status(origin_root)
142
+ if status.dirty? && !confirm_worktree_action(<<~MESSAGE)
143
+ #{origin_root} has #{status.entries.length} local change(s).
144
+
145
+ The new worktree will be created from HEAD. Existing changes will remain in the original workspace and will not be copied.
146
+
147
+ Create the worktree anyway?
148
+ MESSAGE
149
+ return
150
+ end
151
+
152
+ branch = generated_worktree_branch(tab)
153
+ path = default_worktree_path(repository_root, branch)
154
+ binding = git_worktree_manager.create(
155
+ repository_root: repository_root,
156
+ origin_root: origin_root,
157
+ path: path,
158
+ branch: branch
159
+ )
160
+ begin
161
+ rebind_active_tab(tab, root: binding.path, worktree: binding)
162
+ rescue StandardError
163
+ git_worktree_manager.remove(repository_root: binding.repository_root, path: binding.path)
164
+ raise
165
+ end
166
+ runtime_output("Tab #{active_tab_number} is now using #{binding.branch} at #{binding.path}.")
167
+ end
168
+
169
+ def activate_existing_worktree(tab, binding)
170
+ binding.active = true
171
+ validate_worktree_binding!(binding)
172
+ rebind_active_tab(tab, root: binding.path, worktree: binding)
173
+ runtime_output("Tab #{active_tab_number} is now using #{binding.branch} at #{binding.path}.")
174
+ rescue StandardError
175
+ binding.active = false
176
+ raise
177
+ end
178
+
179
+ def detach_active_worktree(tab, binding)
180
+ validate_worktree_binding!(binding)
181
+ status = git_worktree_manager.status(binding.path)
182
+ if status.dirty? && !confirm_worktree_action(<<~MESSAGE)
183
+ #{binding.path} has #{status.entries.length} local change(s).
184
+
185
+ They will remain in the worktree after this tab switches back to #{binding.origin_root}.
186
+
187
+ Detach and keep the worktree?
188
+ MESSAGE
189
+ return
190
+ end
191
+
192
+ binding.active = false
193
+ rebind_active_tab(tab, root: binding.origin_root, worktree: binding)
194
+ runtime_output("Tab #{active_tab_number} returned to #{binding.origin_root}; worktree kept at #{binding.path}.")
195
+ rescue StandardError
196
+ binding.active = true
197
+ raise
198
+ end
199
+
200
+ def rebind_active_tab(tab, root:, worktree:)
201
+ save_active_tab_state
202
+ stop_tab_live_view
203
+ rebind_session_tab(tab, root: root, worktree: worktree)
204
+ activate_tab(@active_tab_index)
205
+ persist_tabs
206
+ end
207
+
208
+ def print_active_worktree_status
209
+ tab = active_tab
210
+ binding = worktree_binding_for(tab)
211
+ return runtime_output("Tab #{active_tab_number} has no worktree binding.") unless binding
212
+
213
+ lines = [
214
+ "Worktree: #{binding.active? ? "active" : "detached"}",
215
+ "Origin: #{binding.origin_root}",
216
+ "Path: #{binding.path}",
217
+ "Branch: #{binding.branch}",
218
+ "Base: #{binding.base_revision}"
219
+ ]
220
+ begin
221
+ info = git_worktree_manager.inspect(repository_root: binding.repository_root, path: binding.path)
222
+ raise GitWorktreeManager::Error, "Worktree branch changed: expected #{binding.branch}, found #{info.branch || "detached HEAD"}" unless info.branch == binding.branch
223
+ status = git_worktree_manager.status(binding.path)
224
+ lines << "Changes: #{status.clean? ? "clean" : "#{status.entries.length} local change(s)"}"
225
+ rescue GitWorktreeManager::Error => e
226
+ lines << "State: unavailable (#{e.message})"
227
+ end
228
+ runtime_output(lines.join("\n"))
229
+ end
230
+
231
+ def merge_active_worktree
232
+ tab = active_tab
233
+ binding = worktree_binding_for(tab)
234
+ return runtime_output("Tab #{active_tab_number} has no worktree binding.") unless binding
235
+ return runtime_output("Tab #{active_tab_number} is running and cannot merge its worktree yet.") if tab.running? || tab.local_busy? || tab.shell
236
+ return runtime_output("Activate the worktree before merging it.") unless binding.active?
237
+
238
+ validate_worktree_binding!(binding)
239
+ source_status = git_worktree_manager.status(binding.path)
240
+ return runtime_output("Worktree has local changes; commit or clean it before merging.") if source_status.dirty?
241
+
242
+ target_status = git_worktree_manager.status(binding.origin_root)
243
+ return runtime_output("Original workspace has local changes; clean it before merging.") if target_status.dirty?
244
+
245
+ target_branch = git_worktree_manager.current_branch(binding.origin_root)
246
+ return runtime_output("Original workspace is in detached HEAD state; select a target branch before merging.") if target_branch.empty?
247
+ return runtime_output("Worktree already uses the target branch #{target_branch}.") if target_branch == binding.branch
248
+
249
+ target_revision = git_worktree_manager.current_revision(binding.origin_root)
250
+ source_revision = git_worktree_manager.current_revision(binding.path)
251
+ return unless confirm_worktree_action(<<~MESSAGE)
252
+ Merge #{binding.branch} into #{target_branch}?
253
+
254
+ Source: #{binding.branch} (#{source_revision})
255
+ Target: #{target_branch} (#{target_revision})
256
+ Original workspace: #{binding.origin_root}
257
+
258
+ The merge runs directly in the original workspace.
259
+ MESSAGE
260
+
261
+ unless git_worktree_manager.current_revision(binding.origin_root) == target_revision && git_worktree_manager.status(binding.origin_root).clean?
262
+ return runtime_output("Original workspace changed before the merge could begin; review it and try again.")
263
+ end
264
+
265
+ result = git_worktree_manager.merge(
266
+ repository_root: binding.repository_root,
267
+ target_path: binding.origin_root,
268
+ source_branch: binding.branch
269
+ )
270
+ if result.merged?
271
+ runtime_output("Merged #{binding.branch} into #{target_branch}.")
272
+ else
273
+ runtime_output("Merge conflicts in #{result.conflicts.join(", ")}. Resolve them in #{binding.origin_root} or run /tab worktree merge abort.")
274
+ end
275
+ rescue GitWorktreeManager::Error => e
276
+ runtime_output("Worktree error: #{e.message}")
277
+ end
278
+
279
+ def abort_active_worktree_merge
280
+ tab = active_tab
281
+ binding = worktree_binding_for(tab)
282
+ return runtime_output("Tab #{active_tab_number} has no worktree binding.") unless binding
283
+ return runtime_output("Tab #{active_tab_number} is running and cannot abort its merge yet.") if tab.running? || tab.local_busy? || tab.shell
284
+ return runtime_output("No merge is in progress in the original workspace.") unless git_worktree_manager.merge_in_progress?(binding.origin_root)
285
+ return unless confirm_worktree_action("Abort the merge in #{binding.origin_root}?")
286
+
287
+ git_worktree_manager.abort_merge(binding.origin_root)
288
+ runtime_output("Aborted the merge in #{binding.origin_root}.")
289
+ rescue GitWorktreeManager::Error => e
290
+ runtime_output("Worktree error: #{e.message}")
291
+ end
292
+
293
+ def remove_active_worktree
294
+ tab = active_tab
295
+ binding = worktree_binding_for(tab)
296
+ return runtime_output("Tab #{active_tab_number} has no worktree binding.") unless binding
297
+ return runtime_output("Tab #{active_tab_number} is running and cannot remove its worktree yet.") if tab.running? || tab.local_busy? || tab.shell
298
+
299
+ git_worktree_manager.inspect(repository_root: binding.repository_root, path: binding.path)
300
+ status = git_worktree_manager.status(binding.path)
301
+ return runtime_output("Worktree has local changes; detach it or clean it before removing it.") if status.dirty?
302
+
303
+ was_active = binding.active?
304
+ detached = false
305
+ if was_active
306
+ binding.active = false
307
+ rebind_active_tab(tab, root: binding.origin_root, worktree: binding)
308
+ detached = true
309
+ end
310
+ git_worktree_manager.remove(repository_root: binding.repository_root, path: binding.path)
311
+ tab.driver = SessionTabDriver.new(session: tab.session, agent: tab.agent)
312
+ tab.stream_state = new_tab_stream_state(tab.driver)
313
+ persist_tabs
314
+ runtime_output("Removed worktree #{binding.path}. Branch #{binding.branch} was kept.")
315
+ rescue GitWorktreeManager::Error => e
316
+ binding.active = true if was_active && !detached
317
+ runtime_output("Worktree error: #{e.message}")
318
+ end
319
+
320
+ def confirm_worktree_action(message)
321
+ prompt = message.to_s.strip
322
+ if @prompt.respond_to?(:ask_user_question)
323
+ answer = @prompt.ask_user_question([{
324
+ header: "Confirm worktree action",
325
+ question: prompt,
326
+ options: [
327
+ { label: "Continue", description: "Proceed with this Git operation." },
328
+ { label: "Cancel", description: "Leave the workspace unchanged." }
329
+ ]
330
+ }])
331
+ return answer&.first&.fetch(:answer, nil) == "Continue"
332
+ end
333
+
334
+ return false unless @prompt.respond_to?(:yes?)
335
+
336
+ runtime_output(prompt) if prompt.include?("\n")
337
+ @prompt.yes?(prompt.include?("\n") ? "Continue?" : prompt, default: false)
338
+ end
339
+
340
+ def generated_worktree_branch(tab)
341
+ label = tab.label.to_s.downcase.gsub(/[^a-z0-9]+/, "-").sub(/\A-+|-+\z/, "")
342
+ label = "tab-#{active_tab_number}" if label.empty?
343
+ "kward/#{label}-#{SecureRandom.hex(3)}"
344
+ end
345
+
346
+ def default_worktree_path(repository_root, branch)
347
+ slug = branch.to_s.delete_prefix("kward/").gsub(/[^a-zA-Z0-9._-]+/, "-")
348
+ File.join(File.dirname(repository_root), ".kward-worktrees", File.basename(repository_root), slug)
349
+ end
350
+
351
+ def unavailable_worktree_tab(descriptor, error, label: nil)
352
+ driver = UnavailableTabDriver.new(
353
+ descriptor: descriptor,
354
+ message: "Worktree unavailable: #{error.message}"
355
+ )
356
+ build_tab(nil, nil, driver: driver, label: label || descriptor["label"] || "Worktree")
357
+ end
358
+ end
359
+ end
360
+ end
data/lib/kward/cli.rb CHANGED
@@ -17,6 +17,7 @@ require_relative "export_path"
17
17
  require_relative "ekwsh"
18
18
  require_relative "interactive_pty_runner"
19
19
  require_relative "auth/anthropic_oauth"
20
+ require_relative "auth/api_key_store"
20
21
  require_relative "auth/github_oauth"
21
22
  require_relative "auth/openrouter_api_key"
22
23
  require_relative "image_attachments"
@@ -25,12 +26,16 @@ require_relative "memory/turn_context"
25
26
  require_relative "transcript_export"
26
27
  require_relative "message_access"
27
28
  require_relative "model/model_info"
29
+ require_relative "model/azure_openai_config"
30
+ require_relative "model/provider_catalog"
31
+ require_relative "model/catalog"
28
32
  require_relative "auth/openai_oauth"
29
33
  require_relative "pan/server"
30
34
  require_relative "plugin_registry"
31
35
  require_relative "prompts/commands"
32
36
  require_relative "model/retry_message"
33
37
  require_relative "rpc/server"
38
+ require_relative "transport/runtime"
34
39
  require_relative "session_diff"
35
40
  require_relative "session_store"
36
41
  require_relative "session_naming"
@@ -46,6 +51,7 @@ require_relative "tools/tool_call"
46
51
  require_relative "tools/registry"
47
52
  require_relative "telemetry/stats"
48
53
  require_relative "workspace_factory"
54
+ require_relative "git_worktree_manager"
49
55
  require_relative "cli/commands"
50
56
  require_relative "cli/auth_commands"
51
57
  require_relative "cli/doctor"
@@ -59,6 +65,7 @@ require_relative "cli/hook_commands"
59
65
  require_relative "cli/settings"
60
66
  require_relative "cli/sessions"
61
67
  require_relative "cli/tabs"
68
+ require_relative "cli/worktrees"
62
69
  require_relative "cli/compaction"
63
70
  require_relative "cli/rendering"
64
71
  require_relative "cli/prompt_interface"
@@ -66,6 +73,7 @@ require_relative "cli/plugins"
66
73
  require_relative "cli/git"
67
74
  require_relative "cli/interactive_turn"
68
75
  require_relative "cli/tool_summaries"
76
+ require_relative "cli/transports"
69
77
 
70
78
  # Namespace for the Kward CLI agent runtime.
71
79
  module Kward
@@ -92,6 +100,7 @@ module Kward
92
100
  include CLI::Settings
93
101
  include CLI::Sessions
94
102
  include CLI::Tabs
103
+ include CLI::Worktrees
95
104
  include CLI::CompactionCommands
96
105
  include CLI::Rendering
97
106
  include CLI::PromptInterfaceSupport
@@ -99,6 +108,7 @@ module Kward
99
108
  include CLI::GitCommands
100
109
  include CLI::InteractiveTurn
101
110
  include CLI::ToolSummaries
111
+ include CLI::Transports
102
112
 
103
113
  def initialize(argv: ARGV, stdin: STDIN, prompt: TTY::Prompt.new, client: nil, session_store: nil, context_usage: ContextUsage.new)
104
114
  @argv = argv
@@ -244,6 +254,16 @@ module Kward
244
254
  return
245
255
  end
246
256
 
257
+ if @argv.first == "transport"
258
+ if help_option_arguments?(@argv[1..] || [])
259
+ print_command_help("transport")
260
+ return
261
+ end
262
+
263
+ handle_transport_command(@argv[1..] || [])
264
+ return
265
+ end
266
+
247
267
  if @argv.first == "rpc"
248
268
  if help_option_arguments?(@argv[1..] || [])
249
269
  print_command_help("rpc")
@@ -313,6 +333,7 @@ module Kward
313
333
  @prompt.edit_file(path, base_dir: Dir.pwd, allow_new: true)
314
334
  ensure
315
335
  @prompt.close if @prompt.respond_to?(:close) && prompt_interface?
336
+ clear_interactive_warning_sink if respond_to?(:clear_interactive_warning_sink, true)
316
337
  end
317
338
 
318
339
  def run_prompt_or_interactive
@@ -526,6 +547,7 @@ module Kward
526
547
  stop_tabs if respond_to?(:stop_tabs, true)
527
548
  @prompt.close if prompt_interface?
528
549
  ensure
550
+ clear_interactive_warning_sink if respond_to?(:clear_interactive_warning_sink, true)
529
551
  cleanup_unused_sessions
530
552
  remember_active_session(session_store)
531
553
  end
@@ -734,9 +734,10 @@ module Kward
734
734
  AUTO_COMPACTION_EXTRA_GUARD_FLOOR = 12_000
735
735
 
736
736
  # Creates an object for conversation compaction.
737
- def initialize(conversation:, client:, tool_result_summarizer: nil, settings: nil, summarizer: nil)
737
+ def initialize(conversation:, client:, tool_result_summarizer: nil, settings: nil, summarizer: nil, warning_sink: nil)
738
738
  @conversation = conversation
739
739
  @client = client
740
+ @warning_sink = warning_sink
740
741
  @settings = settings || Compaction::Settings.from_config
741
742
  @prompt_builder = Compaction::PromptBuilder.new(
742
743
  serializer: Compaction::ConversationSerializer.new(tool_result_summarizer: tool_result_summarizer)
@@ -791,7 +792,7 @@ module Kward
791
792
  rescue Compaction::NothingToCompact, Compaction::AlreadyCompacted
792
793
  nil
793
794
  rescue StandardError => e
794
- warn "Auto-compaction failed: #{e.message}"
795
+ emit_warning "Auto-compaction failed: #{e.message}"
795
796
  nil
796
797
  end
797
798
 
@@ -814,6 +815,10 @@ module Kward
814
815
 
815
816
  private
816
817
 
818
+ def emit_warning(message)
819
+ @warning_sink ? @warning_sink.call(message) : warn(message)
820
+ end
821
+
817
822
  def prepare
818
823
  @conversation.refresh_system_message_if_workspace_agents_changed!
819
824
  Compaction::Preparation.new(conversation: @conversation, settings: @settings).call
@@ -3,6 +3,7 @@ require "fileutils"
3
3
  require "json"
4
4
  require "yaml"
5
5
  require_relative "frontmatter"
6
+ require_relative "deep_copy"
6
7
  require_relative "private_file"
7
8
  require_relative "path_guard"
8
9
  require_relative "permissions/policy"
@@ -65,6 +66,7 @@ module Kward
65
66
  end
66
67
 
67
68
  @skip_config = false
69
+ @warning_sink = nil
68
70
 
69
71
  module_function
70
72
 
@@ -72,6 +74,14 @@ module Kward
72
74
  @skip_config = value
73
75
  end
74
76
 
77
+ def warning_sink=(sink)
78
+ @warning_sink = sink
79
+ end
80
+
81
+ def warning_sink
82
+ @warning_sink
83
+ end
84
+
75
85
  def skip_config?
76
86
  @skip_config == true
77
87
  end
@@ -156,6 +166,7 @@ module Kward
156
166
  "include_principles" => true
157
167
  },
158
168
  "mcpServers" => {},
169
+ "transports" => {},
159
170
  "tools" => {
160
171
  "workspace_guardrails" => true
161
172
  },
@@ -190,6 +201,13 @@ module Kward
190
201
  File.join(cache_dir, "openrouter_models.json")
191
202
  end
192
203
 
204
+ def model_catalog_cache_path(provider_id)
205
+ provider = provider_id.to_s.gsub(/[^a-z0-9_-]/i, "_")
206
+ raise ArgumentError, "Provider id must be a non-empty string" if provider.empty?
207
+
208
+ File.join(cache_dir, "models", "#{provider}.json")
209
+ end
210
+
193
211
  def update_check_cache_path
194
212
  File.join(cache_dir, "update_check.json")
195
213
  end
@@ -236,7 +254,10 @@ module Kward
236
254
  return {} if skip_config? && path == config_path
237
255
  return {} unless File.exist?(path)
238
256
 
239
- JSON.parse(File.read(path))
257
+ config = JSON.parse(File.read(path))
258
+ return config if config.is_a?(Hash)
259
+
260
+ raise ConfigError.new(path: path, format: "JSON", detail: "top-level value must be an object")
240
261
  rescue JSON::ParserError => e
241
262
  raise ConfigError.new(path: path, format: "JSON", detail: e.message)
242
263
  end
@@ -544,6 +565,15 @@ module Kward
544
565
  value.is_a?(Hash) ? value : {}
545
566
  end
546
567
 
568
+ # Returns the private configuration namespace for one transport.
569
+ def transport_config(transport_id, config = read_config)
570
+ transports = config["transports"]
571
+ return {} unless transports.is_a?(Hash)
572
+
573
+ values = transports[transport_id.to_s]
574
+ values.is_a?(Hash) ? DeepCopy.dup(values) : {}
575
+ end
576
+
547
577
  # Returns configured MCP stdio servers, or an empty config when absent.
548
578
  def mcp_servers(config = read_config)
549
579
  value = config["mcpServers"] || config.dig("mcp", "servers")
@@ -770,13 +800,13 @@ module Kward
770
800
 
771
801
  size = File.size(path)
772
802
  if size > MAX_PROMPT_FILE_BYTES
773
- warn "Warning: skipping #{label} #{path}: file too large (#{size} bytes; limit is #{MAX_PROMPT_FILE_BYTES} bytes)"
803
+ emit_warning "Warning: skipping #{label} #{path}: file too large (#{size} bytes; limit is #{MAX_PROMPT_FILE_BYTES} bytes)"
774
804
  return nil
775
805
  end
776
806
 
777
807
  File.read(path)
778
808
  rescue StandardError => e
779
- warn "Warning: skipping #{label} #{path}: #{e.message}"
809
+ emit_warning "Warning: skipping #{label} #{path}: #{e.message}"
780
810
  nil
781
811
  end
782
812
 
@@ -887,8 +917,8 @@ module Kward
887
917
  # Lists configured skills discovered under the config directory.
888
918
  #
889
919
  # @return [Array<Skill>] skill metadata available to the model
890
- def skills(workspace_root: Dir.pwd)
891
- skills_registry(workspace_root: workspace_root).skills
920
+ def skills(workspace_root: Dir.pwd, warning_sink: nil)
921
+ skills_registry(workspace_root: workspace_root, warning_sink: warning_sink).skills
892
922
  end
893
923
 
894
924
  # @return [String] trusted user plugin directory
@@ -896,19 +926,21 @@ module Kward
896
926
  File.expand_path("~/.kward/plugins")
897
927
  end
898
928
 
899
- # Finds trusted top-level plugin files.
929
+ # Finds trusted plugin files and package entrypoints.
900
930
  #
901
931
  # Plugins are intentionally loaded only from `~/.kward/plugins`, not from a
902
932
  # workspace or custom `KWARD_CONFIG_PATH` directory.
903
933
  #
904
934
  # @return [Array<String>] sorted plugin file paths
905
- def plugin_paths
935
+ def plugin_paths(warning_sink: nil)
906
936
  plugins_root = plugin_dir
907
937
  return [] unless Dir.exist?(plugins_root)
908
938
 
909
- Dir.glob(File.join(plugins_root, "*.rb")).sort
939
+ paths = Dir.glob(File.join(plugins_root, "*.rb"))
940
+ paths.concat(Dir.glob(File.join(plugins_root, "*", "plugin.rb")))
941
+ paths.sort
910
942
  rescue StandardError => e
911
- warn "Warning: skipping Kward plugins in #{plugins_root}: #{e.message}"
943
+ emit_warning("Warning: skipping Kward plugins in #{plugins_root}: #{e.message}", warning_sink: warning_sink)
912
944
  []
913
945
  end
914
946
 
@@ -929,7 +961,7 @@ module Kward
929
961
  skills_registry(workspace_root: workspace_root).read_skill_file(name, relative_path)
930
962
  end
931
963
 
932
- def skills_registry(workspace_root: Dir.pwd)
964
+ def skills_registry(workspace_root: Dir.pwd, warning_sink: nil)
933
965
  Skills::Registry.new(
934
966
  config_dir: config_dir,
935
967
  workspace_root: workspace_root,
@@ -937,7 +969,8 @@ module Kward
937
969
  skill_class: Skill,
938
970
  max_file_bytes: MAX_SKILL_FILE_BYTES,
939
971
  markdown_parser: ->(path) { Frontmatter.markdown_parts(path, lenient: true) },
940
- inside_directory: method(:inside_directory?)
972
+ inside_directory: method(:inside_directory?),
973
+ warning_sink: warning_sink || @warning_sink
941
974
  )
942
975
  end
943
976
 
@@ -945,10 +978,16 @@ module Kward
945
978
  Prompts::Templates.new(
946
979
  config_dir: config_dir,
947
980
  template_class: PromptTemplate,
948
- markdown_parser: ->(path) { Frontmatter.markdown_parts(path) }
981
+ markdown_parser: ->(path) { Frontmatter.markdown_parts(path) },
982
+ warning_sink: @warning_sink
949
983
  )
950
984
  end
951
985
 
986
+ def emit_warning(message, warning_sink: nil)
987
+ sink = warning_sink || @warning_sink
988
+ sink ? sink.call(message) : warn(message)
989
+ end
990
+
952
991
  def inside_directory?(path, base)
953
992
  PathGuard.inside?(path, base)
954
993
  end