kward 0.78.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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +13 -2
  3. data/.github/workflows/pages.yml +1 -1
  4. data/CHANGELOG.md +82 -1
  5. data/Gemfile.lock +2 -2
  6. data/README.md +33 -38
  7. data/doc/agent-tools.md +10 -11
  8. data/doc/api.md +3 -3
  9. data/doc/authentication.md +79 -110
  10. data/doc/code-search.md +1 -1
  11. data/doc/composer.md +1 -1
  12. data/doc/configuration.md +136 -27
  13. data/doc/context-budgeting.md +6 -6
  14. data/doc/context-tools.md +2 -2
  15. data/doc/editor.md +7 -7
  16. data/doc/extensibility.md +9 -29
  17. data/doc/files.md +6 -6
  18. data/doc/getting-started.md +3 -3
  19. data/doc/git.md +8 -2
  20. data/doc/lifecycle-hooks.md +5 -4
  21. data/doc/local-models.md +130 -0
  22. data/doc/mcp.md +5 -5
  23. data/doc/permissions.md +15 -5
  24. data/doc/plugins.md +39 -4
  25. data/doc/prompt-templates.md +105 -0
  26. data/doc/providers.md +140 -0
  27. data/doc/releasing.md +11 -3
  28. data/doc/rpc.md +65 -31
  29. data/doc/sandboxing.md +142 -0
  30. data/doc/security.md +31 -10
  31. data/doc/session-management.md +2 -4
  32. data/doc/skills.md +10 -0
  33. data/doc/tabs.md +37 -0
  34. data/doc/telegram.md +138 -0
  35. data/doc/transports.md +209 -0
  36. data/doc/usage.md +11 -3
  37. data/doc/web-search.md +3 -3
  38. data/doc/workspace-tools.md +4 -4
  39. data/examples/plugins/stardate_footer.rb +10 -0
  40. data/examples/plugins/telegram/plugin.rb +39 -0
  41. data/examples/plugins/telegram/telegram_api.rb +136 -0
  42. data/examples/plugins/telegram/telegram_transport.rb +304 -0
  43. data/kward.gemspec +1 -1
  44. data/lib/kward/ansi.rb +1 -0
  45. data/lib/kward/auth/api_key_store.rb +99 -0
  46. data/lib/kward/cli/auth_commands.rb +72 -13
  47. data/lib/kward/cli/commands.rb +7 -0
  48. data/lib/kward/cli/doctor.rb +21 -0
  49. data/lib/kward/cli/git.rb +31 -8
  50. data/lib/kward/cli/plugins.rb +1 -0
  51. data/lib/kward/cli/prompt_interface.rb +7 -0
  52. data/lib/kward/cli/rendering.rb +6 -2
  53. data/lib/kward/cli/runtime_helpers.rb +4 -2
  54. data/lib/kward/cli/sessions.rb +1 -1
  55. data/lib/kward/cli/settings.rb +162 -36
  56. data/lib/kward/cli/slash_commands.rb +109 -1
  57. data/lib/kward/cli/tabs.rb +102 -37
  58. data/lib/kward/cli/transports.rb +67 -0
  59. data/lib/kward/cli/worktrees.rb +360 -0
  60. data/lib/kward/cli.rb +22 -1
  61. data/lib/kward/config_files.rb +106 -4
  62. data/lib/kward/conversation.rb +27 -8
  63. data/lib/kward/git_worktree_manager.rb +291 -0
  64. data/lib/kward/local_command_runner.rb +2 -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 +364 -23
  68. data/lib/kward/model/model_info.rb +60 -1
  69. data/lib/kward/model/payloads.rb +64 -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 +141 -39
  73. data/lib/kward/pan/index.html.erb +50 -0
  74. data/lib/kward/pan/server.rb +49 -2
  75. data/lib/kward/permissions/policy.rb +18 -4
  76. data/lib/kward/plugin_chat_runtime.rb +374 -0
  77. data/lib/kward/plugin_registry.rb +81 -8
  78. data/lib/kward/private_file.rb +9 -3
  79. data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
  80. data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
  81. data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
  82. data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
  83. data/lib/kward/prompt_interface/editor/controller.rb +43 -10
  84. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  85. data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
  86. data/lib/kward/prompt_interface/editor/state.rb +3 -9
  87. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +184 -9
  88. data/lib/kward/prompt_interface/file_overlay.rb +2 -2
  89. data/lib/kward/prompt_interface/git_prompt.rb +7 -3
  90. data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
  91. data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
  92. data/lib/kward/prompt_interface/interactive/state.rb +2 -2
  93. data/lib/kward/prompt_interface/project_browser.rb +25 -6
  94. data/lib/kward/prompt_interface/runtime_state.rb +22 -6
  95. data/lib/kward/prompt_interface/screen.rb +3 -0
  96. data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
  97. data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
  98. data/lib/kward/prompt_interface.rb +71 -12
  99. data/lib/kward/prompts/commands.rb +3 -1
  100. data/lib/kward/prompts.rb +22 -10
  101. data/lib/kward/rpc/auth_manager.rb +112 -152
  102. data/lib/kward/rpc/config_manager.rb +46 -6
  103. data/lib/kward/rpc/plugin_chat_manager.rb +56 -194
  104. data/lib/kward/rpc/prompt_bridge.rb +8 -4
  105. data/lib/kward/rpc/redactor.rb +1 -1
  106. data/lib/kward/rpc/server.rb +102 -14
  107. data/lib/kward/rpc/session_manager.rb +151 -38
  108. data/lib/kward/sandbox/capabilities.rb +39 -0
  109. data/lib/kward/sandbox/command_runner.rb +28 -0
  110. data/lib/kward/sandbox/environment.rb +24 -0
  111. data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
  112. data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
  113. data/lib/kward/sandbox/passthrough_runner.rb +13 -0
  114. data/lib/kward/sandbox/policy.rb +74 -0
  115. data/lib/kward/sandbox/runner_factory.rb +55 -0
  116. data/lib/kward/sandbox/unavailable_runner.rb +21 -0
  117. data/lib/kward/sandbox.rb +9 -0
  118. data/lib/kward/session_store.rb +110 -24
  119. data/lib/kward/skills/capture.rb +144 -0
  120. data/lib/kward/tab_driver.rb +6 -3
  121. data/lib/kward/tools/git_commit.rb +39 -0
  122. data/lib/kward/tools/registry.rb +26 -6
  123. data/lib/kward/tools/tool_call.rb +1 -0
  124. data/lib/kward/transport/gateway.rb +253 -0
  125. data/lib/kward/transport/host.rb +343 -0
  126. data/lib/kward/transport/manager.rb +179 -0
  127. data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
  128. data/lib/kward/transport/runtime.rb +41 -0
  129. data/lib/kward/transport/store.rb +101 -0
  130. data/lib/kward/transport.rb +204 -0
  131. data/lib/kward/version.rb +1 -1
  132. data/lib/kward/workspace.rb +18 -3
  133. data/lib/kward/workspace_factory.rb +34 -0
  134. data/templates/default/fulldoc/html/js/kward.js +3 -0
  135. data/templates/default/kward_navigation.rb +8 -1
  136. data/templates/default/layout/html/setup.rb +5 -0
  137. metadata +41 -2
@@ -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"
@@ -38,13 +43,15 @@ require_relative "tab_store"
38
43
  require_relative "tab_driver"
39
44
  require_relative "session_trash"
40
45
  require_relative "session_tree_renderer"
46
+ require_relative "skills/capture"
41
47
  require_relative "starter_pack_installer"
42
48
  require_relative "steering"
43
49
  require_relative "update_check"
44
50
  require_relative "tools/tool_call"
45
51
  require_relative "tools/registry"
46
52
  require_relative "telemetry/stats"
47
- require_relative "workspace"
53
+ require_relative "workspace_factory"
54
+ require_relative "git_worktree_manager"
48
55
  require_relative "cli/commands"
49
56
  require_relative "cli/auth_commands"
50
57
  require_relative "cli/doctor"
@@ -58,6 +65,7 @@ require_relative "cli/hook_commands"
58
65
  require_relative "cli/settings"
59
66
  require_relative "cli/sessions"
60
67
  require_relative "cli/tabs"
68
+ require_relative "cli/worktrees"
61
69
  require_relative "cli/compaction"
62
70
  require_relative "cli/rendering"
63
71
  require_relative "cli/prompt_interface"
@@ -65,6 +73,7 @@ require_relative "cli/plugins"
65
73
  require_relative "cli/git"
66
74
  require_relative "cli/interactive_turn"
67
75
  require_relative "cli/tool_summaries"
76
+ require_relative "cli/transports"
68
77
 
69
78
  # Namespace for the Kward CLI agent runtime.
70
79
  module Kward
@@ -91,6 +100,7 @@ module Kward
91
100
  include CLI::Settings
92
101
  include CLI::Sessions
93
102
  include CLI::Tabs
103
+ include CLI::Worktrees
94
104
  include CLI::CompactionCommands
95
105
  include CLI::Rendering
96
106
  include CLI::PromptInterfaceSupport
@@ -98,6 +108,7 @@ module Kward
98
108
  include CLI::GitCommands
99
109
  include CLI::InteractiveTurn
100
110
  include CLI::ToolSummaries
111
+ include CLI::Transports
101
112
 
102
113
  def initialize(argv: ARGV, stdin: STDIN, prompt: TTY::Prompt.new, client: nil, session_store: nil, context_usage: ContextUsage.new)
103
114
  @argv = argv
@@ -243,6 +254,16 @@ module Kward
243
254
  return
244
255
  end
245
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
+
246
267
  if @argv.first == "rpc"
247
268
  if help_option_arguments?(@argv[1..] || [])
248
269
  print_command_help("rpc")
@@ -3,9 +3,11 @@ 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"
10
+ require_relative "sandbox/policy"
9
11
  require_relative "ekwsh"
10
12
  require_relative "editor_mode"
11
13
  require_relative "diff_view_mode"
@@ -151,13 +153,23 @@ module Kward
151
153
  "trust_project" => false
152
154
  },
153
155
  "enforce_workspace_agents_file" => false,
156
+ "system_prompt" => {
157
+ "include_principles" => true
158
+ },
154
159
  "mcpServers" => {},
160
+ "transports" => {},
155
161
  "tools" => {
156
162
  "workspace_guardrails" => true
157
163
  },
158
164
  "permissions" => {
159
165
  "enabled" => false,
160
166
  "mode" => "ask"
167
+ },
168
+ "sandbox" => {
169
+ "mode" => "off",
170
+ "network" => "deny",
171
+ "writable_roots" => [],
172
+ "protect_git_metadata" => true
161
173
  }
162
174
  }
163
175
  end
@@ -180,6 +192,13 @@ module Kward
180
192
  File.join(cache_dir, "openrouter_models.json")
181
193
  end
182
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
+
183
202
  def update_check_cache_path
184
203
  File.join(cache_dir, "update_check.json")
185
204
  end
@@ -226,7 +245,10 @@ module Kward
226
245
  return {} if skip_config? && path == config_path
227
246
  return {} unless File.exist?(path)
228
247
 
229
- 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")
230
252
  rescue JSON::ParserError => e
231
253
  raise ConfigError.new(path: path, format: "JSON", detail: e.message)
232
254
  end
@@ -498,6 +520,18 @@ module Kward
498
520
  Permissions::Policy.from_config(config)
499
521
  end
500
522
 
523
+ # Builds the user-controlled command sandbox policy for a workspace.
524
+ def sandbox_policy(workspace_root, config = read_config)
525
+ sandbox = config["sandbox"].is_a?(Hash) ? config["sandbox"] : {}
526
+ Sandbox::Policy.new(
527
+ mode: sandbox.fetch("mode", "off"),
528
+ network: sandbox.fetch("network", "deny"),
529
+ workspace_root: workspace_root,
530
+ writable_roots: sandbox.fetch("writable_roots", []),
531
+ protect_git_metadata: sandbox.fetch("protect_git_metadata", true)
532
+ )
533
+ end
534
+
501
535
  # Returns whether project-level Agent Skills should be loaded from the workspace.
502
536
  def project_skills_trusted?(config = read_config)
503
537
  skills = config["skills"].is_a?(Hash) ? config["skills"] : {}
@@ -522,6 +556,15 @@ module Kward
522
556
  value.is_a?(Hash) ? value : {}
523
557
  end
524
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
+
525
568
  # Returns configured MCP stdio servers, or an empty config when absent.
526
569
  def mcp_servers(config = read_config)
527
570
  value = config["mcpServers"] || config.dig("mcp", "servers")
@@ -558,13 +601,61 @@ module Kward
558
601
  # alias for existing installations.
559
602
  #
560
603
  # @return [String, nil] prompt text, or nil when absent/too large
561
- def agents_prompt
604
+ def agents_prompt(config: read_config)
605
+ return nil unless include_config_principles?(config)
606
+
562
607
  path = config_principles_path
563
608
  return read_prompt_file(path, "Kward principles file") if File.exist?(path)
564
609
 
565
610
  read_prompt_file(config_agents_path, "Kward AGENTS.md alias")
566
611
  end
567
612
 
613
+ # Returns whether config-directory principles are included in normal system
614
+ # prompt assembly. Replacement prompt files bypass all assembled sections.
615
+ def include_config_principles?(config = read_config)
616
+ settings = config["system_prompt"]
617
+ return true unless settings.is_a?(Hash)
618
+
619
+ settings["include_principles"] != false
620
+ end
621
+
622
+ # Returns the configured replacement system prompt path, or nil when normal
623
+ # prompt assembly should be used. Relative paths are anchored to the config
624
+ # directory so configuration remains portable with KWARD_CONFIG_PATH.
625
+ def system_prompt_file_path(config = read_config)
626
+ settings = config["system_prompt"]
627
+ value = settings.is_a?(Hash) ? settings["file"].to_s.strip : ""
628
+ return nil if value.empty?
629
+
630
+ File.expand_path(value, config_dir)
631
+ end
632
+
633
+ # Returns the replacement system prompt text when configured. A configured
634
+ # but unavailable file intentionally yields nil: callers must not fall back
635
+ # to Kward's larger assembled prompt in replacement mode.
636
+ def system_prompt_file(config = read_config)
637
+ path = system_prompt_file_path(config)
638
+ return nil unless path
639
+
640
+ read_prompt_file(path, "custom system prompt file")
641
+ end
642
+
643
+ def replacement_system_prompt?(config = read_config)
644
+ !system_prompt_file_path(config).nil?
645
+ end
646
+
647
+ # Returns a lightweight fingerprint for config-owned prompt sources. It is
648
+ # used by active conversations to pick up prompt edits without a restart.
649
+ def system_prompt_sources_fingerprint
650
+ config = read_config
651
+ paths = [config_path, system_prompt_file_path(config)]
652
+ if include_config_principles?(config)
653
+ paths << (File.exist?(config_principles_path) ? config_principles_path : config_agents_path)
654
+ end
655
+
656
+ paths.compact.map { |path| prompt_source_signature(path) }.join("\0")
657
+ end
658
+
568
659
  def config_principles_path
569
660
  File.join(config_dir, "PRINCIPLES.md")
570
661
  end
@@ -686,6 +777,15 @@ module Kward
686
777
  read_prompt_file(workspace_agents_path(workspace_root), "workspace AGENTS.md")
687
778
  end
688
779
 
780
+ def prompt_source_signature(path)
781
+ return "#{path}:missing" unless File.exist?(path)
782
+
783
+ stat = File.stat(path)
784
+ "#{path}:#{stat.size}:#{stat.mtime.to_f}"
785
+ rescue StandardError
786
+ "#{path}:unavailable"
787
+ end
788
+
689
789
  def read_prompt_file(path, label)
690
790
  return nil unless File.exist?(path)
691
791
 
@@ -817,7 +917,7 @@ module Kward
817
917
  File.expand_path("~/.kward/plugins")
818
918
  end
819
919
 
820
- # Finds trusted top-level plugin files.
920
+ # Finds trusted plugin files and package entrypoints.
821
921
  #
822
922
  # Plugins are intentionally loaded only from `~/.kward/plugins`, not from a
823
923
  # workspace or custom `KWARD_CONFIG_PATH` directory.
@@ -827,7 +927,9 @@ module Kward
827
927
  plugins_root = plugin_dir
828
928
  return [] unless Dir.exist?(plugins_root)
829
929
 
830
- 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
831
933
  rescue StandardError => e
832
934
  warn "Warning: skipping Kward plugins in #{plugins_root}: #{e.message}"
833
935
  []