kward 0.79.0 → 0.80.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 (117) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +6 -0
  3. data/CHANGELOG.md +58 -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/tabs.md +37 -0
  32. data/doc/telegram.md +138 -0
  33. data/doc/transports.md +209 -0
  34. data/doc/usage.md +11 -3
  35. data/doc/web-search.md +3 -3
  36. data/doc/workspace-tools.md +2 -2
  37. data/examples/plugins/stardate_footer.rb +10 -0
  38. data/examples/plugins/telegram/plugin.rb +39 -0
  39. data/examples/plugins/telegram/telegram_api.rb +136 -0
  40. data/examples/plugins/telegram/telegram_transport.rb +304 -0
  41. data/lib/kward/ansi.rb +1 -0
  42. data/lib/kward/auth/api_key_store.rb +99 -0
  43. data/lib/kward/cli/auth_commands.rb +72 -13
  44. data/lib/kward/cli/commands.rb +7 -0
  45. data/lib/kward/cli/git.rb +31 -8
  46. data/lib/kward/cli/plugins.rb +1 -0
  47. data/lib/kward/cli/prompt_interface.rb +7 -0
  48. data/lib/kward/cli/rendering.rb +6 -2
  49. data/lib/kward/cli/runtime_helpers.rb +4 -2
  50. data/lib/kward/cli/sessions.rb +1 -1
  51. data/lib/kward/cli/settings.rb +97 -33
  52. data/lib/kward/cli/tabs.rb +102 -37
  53. data/lib/kward/cli/transports.rb +67 -0
  54. data/lib/kward/cli/worktrees.rb +360 -0
  55. data/lib/kward/cli.rb +20 -0
  56. data/lib/kward/config_files.rb +26 -3
  57. data/lib/kward/conversation.rb +13 -7
  58. data/lib/kward/git_worktree_manager.rb +291 -0
  59. data/lib/kward/model/azure_openai_config.rb +54 -0
  60. data/lib/kward/model/catalog.rb +113 -0
  61. data/lib/kward/model/client.rb +225 -20
  62. data/lib/kward/model/model_info.rb +49 -0
  63. data/lib/kward/model/payloads.rb +63 -4
  64. data/lib/kward/model/provider_catalog.rb +114 -0
  65. data/lib/kward/model/sources.rb +70 -0
  66. data/lib/kward/model/stream_parser.rb +83 -13
  67. data/lib/kward/permissions/policy.rb +18 -4
  68. data/lib/kward/plugin_chat_runtime.rb +374 -0
  69. data/lib/kward/plugin_registry.rb +80 -7
  70. data/lib/kward/private_file.rb +9 -3
  71. data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
  72. data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
  73. data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
  74. data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
  75. data/lib/kward/prompt_interface/editor/controller.rb +7 -7
  76. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  77. data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
  78. data/lib/kward/prompt_interface/editor/state.rb +3 -9
  79. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +184 -9
  80. data/lib/kward/prompt_interface/file_overlay.rb +2 -2
  81. data/lib/kward/prompt_interface/git_prompt.rb +7 -3
  82. data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
  83. data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
  84. data/lib/kward/prompt_interface/interactive/state.rb +2 -2
  85. data/lib/kward/prompt_interface/project_browser.rb +25 -6
  86. data/lib/kward/prompt_interface/runtime_state.rb +22 -6
  87. data/lib/kward/prompt_interface/screen.rb +3 -0
  88. data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
  89. data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
  90. data/lib/kward/prompt_interface.rb +53 -12
  91. data/lib/kward/prompts/commands.rb +1 -1
  92. data/lib/kward/prompts.rb +6 -5
  93. data/lib/kward/rpc/auth_manager.rb +112 -152
  94. data/lib/kward/rpc/config_manager.rb +46 -6
  95. data/lib/kward/rpc/plugin_chat_manager.rb +55 -196
  96. data/lib/kward/rpc/prompt_bridge.rb +8 -4
  97. data/lib/kward/rpc/redactor.rb +1 -1
  98. data/lib/kward/rpc/server.rb +60 -10
  99. data/lib/kward/rpc/session_manager.rb +109 -30
  100. data/lib/kward/session_store.rb +84 -24
  101. data/lib/kward/tab_driver.rb +6 -3
  102. data/lib/kward/tools/git_commit.rb +39 -0
  103. data/lib/kward/tools/registry.rb +7 -2
  104. data/lib/kward/tools/tool_call.rb +1 -0
  105. data/lib/kward/transport/gateway.rb +253 -0
  106. data/lib/kward/transport/host.rb +343 -0
  107. data/lib/kward/transport/manager.rb +179 -0
  108. data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
  109. data/lib/kward/transport/runtime.rb +41 -0
  110. data/lib/kward/transport/store.rb +101 -0
  111. data/lib/kward/transport.rb +204 -0
  112. data/lib/kward/version.rb +1 -1
  113. data/lib/kward/workspace_factory.rb +19 -2
  114. data/templates/default/fulldoc/html/js/kward.js +2 -0
  115. data/templates/default/kward_navigation.rb +7 -2
  116. data/templates/default/layout/html/setup.rb +4 -0
  117. metadata +26 -1
@@ -0,0 +1,67 @@
1
+ require_relative "../transport/runtime"
2
+
3
+ module Kward
4
+ class CLI
5
+ # Foreground transport lifecycle commands.
6
+ module Transports
7
+ private
8
+
9
+ def handle_transport_command(arguments)
10
+ command = arguments.first.to_s
11
+ case command
12
+ when "list"
13
+ raise ArgumentError, command_usage("transport") unless arguments.length == 1
14
+
15
+ with_transport_runtime { |runtime| print_transport_entries(runtime.manager.list) }
16
+ when "status"
17
+ raise ArgumentError, command_usage("transport") unless arguments.length <= 2
18
+
19
+ with_transport_runtime do |runtime|
20
+ entries = arguments.length == 2 ? [runtime.manager.status(arguments[1])] : runtime.manager.status
21
+ print_transport_entries(entries)
22
+ end
23
+ when "run"
24
+ raise ArgumentError, command_usage("transport") unless (2..3).cover?(arguments.length)
25
+
26
+ workspace_root = arguments[2] ? expanded_working_directory(arguments[2]) : @working_directory
27
+ run_transport(arguments[1], workspace_root: workspace_root)
28
+ else
29
+ raise ArgumentError, command_usage("transport")
30
+ end
31
+ end
32
+
33
+ def print_transport_entries(entries)
34
+ entries.each do |entry|
35
+ @prompt.say("#{entry[:id]}\t#{entry[:state] || "stopped"}")
36
+ end
37
+ end
38
+
39
+ def run_transport(name, workspace_root: nil)
40
+ runtime = Transport::Runtime.new(client: ensure_client!)
41
+ runtime.manager.start(name, workspace_root: workspace_root)
42
+ @prompt.say("Transport #{name} is running. Press Ctrl-C to stop.")
43
+ wait_for_transport_shutdown
44
+ ensure
45
+ runtime&.shutdown
46
+ end
47
+
48
+ def with_transport_runtime
49
+ runtime = Transport::Runtime.new(client: ensure_client!)
50
+ yield runtime
51
+ ensure
52
+ runtime&.shutdown
53
+ end
54
+
55
+ def wait_for_transport_shutdown
56
+ stopped = false
57
+ previous_handlers = {}
58
+ %w[INT TERM].each do |signal|
59
+ previous_handlers[signal] = Signal.trap(signal) { stopped = true }
60
+ end
61
+ sleep 0.1 until stopped
62
+ ensure
63
+ previous_handlers&.each { |signal, handler| Signal.trap(signal, handler) }
64
+ end
65
+ end
66
+ end
67
+ end
@@ -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")
@@ -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"
@@ -156,6 +157,7 @@ module Kward
156
157
  "include_principles" => true
157
158
  },
158
159
  "mcpServers" => {},
160
+ "transports" => {},
159
161
  "tools" => {
160
162
  "workspace_guardrails" => true
161
163
  },
@@ -190,6 +192,13 @@ module Kward
190
192
  File.join(cache_dir, "openrouter_models.json")
191
193
  end
192
194
 
195
+ def model_catalog_cache_path(provider_id)
196
+ provider = provider_id.to_s.gsub(/[^a-z0-9_-]/i, "_")
197
+ raise ArgumentError, "Provider id must be a non-empty string" if provider.empty?
198
+
199
+ File.join(cache_dir, "models", "#{provider}.json")
200
+ end
201
+
193
202
  def update_check_cache_path
194
203
  File.join(cache_dir, "update_check.json")
195
204
  end
@@ -236,7 +245,10 @@ module Kward
236
245
  return {} if skip_config? && path == config_path
237
246
  return {} unless File.exist?(path)
238
247
 
239
- JSON.parse(File.read(path))
248
+ config = JSON.parse(File.read(path))
249
+ return config if config.is_a?(Hash)
250
+
251
+ raise ConfigError.new(path: path, format: "JSON", detail: "top-level value must be an object")
240
252
  rescue JSON::ParserError => e
241
253
  raise ConfigError.new(path: path, format: "JSON", detail: e.message)
242
254
  end
@@ -544,6 +556,15 @@ module Kward
544
556
  value.is_a?(Hash) ? value : {}
545
557
  end
546
558
 
559
+ # Returns the private configuration namespace for one transport.
560
+ def transport_config(transport_id, config = read_config)
561
+ transports = config["transports"]
562
+ return {} unless transports.is_a?(Hash)
563
+
564
+ values = transports[transport_id.to_s]
565
+ values.is_a?(Hash) ? DeepCopy.dup(values) : {}
566
+ end
567
+
547
568
  # Returns configured MCP stdio servers, or an empty config when absent.
548
569
  def mcp_servers(config = read_config)
549
570
  value = config["mcpServers"] || config.dig("mcp", "servers")
@@ -896,7 +917,7 @@ module Kward
896
917
  File.expand_path("~/.kward/plugins")
897
918
  end
898
919
 
899
- # Finds trusted top-level plugin files.
920
+ # Finds trusted plugin files and package entrypoints.
900
921
  #
901
922
  # Plugins are intentionally loaded only from `~/.kward/plugins`, not from a
902
923
  # workspace or custom `KWARD_CONFIG_PATH` directory.
@@ -906,7 +927,9 @@ module Kward
906
927
  plugins_root = plugin_dir
907
928
  return [] unless Dir.exist?(plugins_root)
908
929
 
909
- Dir.glob(File.join(plugins_root, "*.rb")).sort
930
+ paths = Dir.glob(File.join(plugins_root, "*.rb"))
931
+ paths.concat(Dir.glob(File.join(plugins_root, "*", "plugin.rb")))
932
+ paths.sort
910
933
  rescue StandardError => e
911
934
  warn "Warning: skipping Kward plugins in #{plugins_root}: #{e.message}"
912
935
  []
@@ -58,32 +58,33 @@ module Kward
58
58
  # @return [PluginRegistry, nil] registry used to collect plugin prompt context
59
59
  attr_accessor :plugin_registry
60
60
  # @return [String, nil] plugin prompt context used in the current system prompt
61
- attr_reader :last_plugin_prompt_context
61
+ attr_reader :last_plugin_prompt_context, :execution_profile_context
62
62
  # @return [Hash] original large tool outputs retained outside model context
63
63
  attr_reader :tool_output_artifacts
64
64
  # @return [ContextBudgetMeter] runtime context savings for this conversation
65
65
  attr_reader :context_budget_meter
66
66
 
67
- def initialize(system_message: DEFAULT_SYSTEM_MESSAGE, messages: [], read_paths: [], on_append: nil, on_compact: nil, on_tool_execution: nil, on_runtime_update: nil, workspace_root: Dir.pwd, compaction_system_message: DEFAULT_SYSTEM_MESSAGE, provider: nil, model: nil, reasoning_effort: nil, memory_context: nil, session_memories: [], last_memory_retrieval: nil, plugin_registry: nil)
67
+ def initialize(system_message: DEFAULT_SYSTEM_MESSAGE, messages: [], read_paths: [], on_append: nil, on_compact: nil, on_tool_execution: nil, on_runtime_update: nil, workspace_root: Dir.pwd, compaction_system_message: DEFAULT_SYSTEM_MESSAGE, provider: nil, model: nil, reasoning_effort: nil, memory_context: nil, session_memories: [], last_memory_retrieval: nil, plugin_registry: nil, execution_profile_context: nil)
68
68
  @workspace_root = ConfigFiles.canonical_workspace_root(workspace_root)
69
69
  @provider = provider
70
70
  @model = model
71
71
  @reasoning_effort = reasoning_effort
72
72
  @plugin_registry = plugin_registry
73
+ @execution_profile_context = execution_profile_context.to_s.empty? ? nil : execution_profile_context.to_s.freeze
73
74
  @messages = []
74
75
  restored_system_message, transcript_messages = split_system_message(messages)
75
76
  if system_message.equal?(DEFAULT_SYSTEM_MESSAGE)
76
- if restored_system_message
77
+ if restored_system_message && @execution_profile_context.to_s.empty?
77
78
  system_message = restored_system_message
78
79
  else
79
80
  @last_plugin_prompt_context = plugin_prompt_context
80
- system_message = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: memory_context, plugin_context: @last_plugin_prompt_context)
81
+ system_message = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: memory_context, plugin_context: @last_plugin_prompt_context, execution_profile_context: @execution_profile_context)
81
82
  end
82
83
  end
83
84
  @system_message = system_message
84
85
  @system_message_enabled = !@system_message.nil?
85
86
  if compaction_system_message.equal?(DEFAULT_SYSTEM_MESSAGE)
86
- compaction_system_message = @system_message_enabled ? Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time) : nil
87
+ compaction_system_message = @system_message_enabled ? Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, execution_profile_context: @execution_profile_context) : nil
87
88
  end
88
89
  @compaction_system_message = compaction_system_message
89
90
  @workspace_agents_mtime = workspace_agents_mtime
@@ -187,15 +188,20 @@ module Kward
187
188
  return nil unless @system_message_enabled
188
189
 
189
190
  @last_plugin_prompt_context = plugin_prompt_context
190
- replacement = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: @memory_context, plugin_context: @last_plugin_prompt_context)
191
+ replacement = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: @memory_context, plugin_context: @last_plugin_prompt_context, execution_profile_context: @execution_profile_context)
191
192
  @system_message = replacement
192
193
  @on_system_message_change&.call(replacement)
193
- @compaction_system_message = Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time)
194
+ @compaction_system_message = Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, execution_profile_context: @execution_profile_context)
194
195
  @workspace_agents_mtime = workspace_agents_mtime
195
196
  @system_prompt_sources_fingerprint = ConfigFiles.system_prompt_sources_fingerprint
196
197
  replacement
197
198
  end
198
199
 
200
+ def update_execution_profile_context!(context)
201
+ @execution_profile_context = context.to_s.empty? ? nil : context.to_s.freeze
202
+ refresh_system_message!
203
+ end
204
+
199
205
  def update_runtime_context!(provider: nil, model:, reasoning_effort:, refresh: true)
200
206
  @provider = provider unless provider.to_s.empty?
201
207
  @model = model