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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +6 -0
- data/CHANGELOG.md +65 -1
- data/Gemfile.lock +2 -2
- data/README.md +31 -38
- data/doc/agent-tools.md +9 -10
- data/doc/api.md +3 -3
- data/doc/authentication.md +79 -110
- data/doc/code-search.md +1 -1
- data/doc/composer.md +1 -1
- data/doc/configuration.md +69 -26
- data/doc/context-budgeting.md +6 -6
- data/doc/context-tools.md +2 -2
- data/doc/editor.md +7 -7
- data/doc/extensibility.md +7 -29
- data/doc/files.md +6 -6
- data/doc/getting-started.md +2 -2
- data/doc/git.md +8 -2
- data/doc/lifecycle-hooks.md +5 -4
- data/doc/local-models.md +2 -2
- data/doc/mcp.md +5 -5
- data/doc/permissions.md +13 -4
- data/doc/plugins.md +39 -4
- data/doc/prompt-templates.md +105 -0
- data/doc/providers.md +140 -0
- data/doc/releasing.md +10 -2
- data/doc/rpc.md +54 -31
- data/doc/sandboxing.md +22 -0
- data/doc/security.md +22 -7
- data/doc/session-management.md +2 -4
- data/doc/skills.md +2 -0
- data/doc/tabs.md +37 -0
- data/doc/telegram.md +138 -0
- data/doc/transports.md +209 -0
- data/doc/usage.md +11 -3
- data/doc/web-search.md +3 -3
- data/doc/workspace-tools.md +2 -2
- data/examples/plugins/stardate_footer.rb +10 -0
- data/examples/plugins/telegram/plugin.rb +39 -0
- data/examples/plugins/telegram/telegram_api.rb +136 -0
- data/examples/plugins/telegram/telegram_transport.rb +304 -0
- data/lib/kward/agent.rb +11 -6
- data/lib/kward/ansi.rb +1 -0
- data/lib/kward/auth/api_key_store.rb +99 -0
- data/lib/kward/cli/auth_commands.rb +72 -13
- data/lib/kward/cli/commands.rb +7 -0
- data/lib/kward/cli/git.rb +31 -8
- data/lib/kward/cli/memory_commands.rb +2 -2
- data/lib/kward/cli/plugins.rb +1 -0
- data/lib/kward/cli/prompt_interface.rb +44 -1
- data/lib/kward/cli/rendering.rb +6 -2
- data/lib/kward/cli/runtime_helpers.rb +5 -2
- data/lib/kward/cli/sessions.rb +1 -1
- data/lib/kward/cli/settings.rb +97 -33
- data/lib/kward/cli/slash_commands.rb +1 -1
- data/lib/kward/cli/tabs.rb +103 -37
- data/lib/kward/cli/transports.rb +67 -0
- data/lib/kward/cli/worktrees.rb +360 -0
- data/lib/kward/cli.rb +22 -0
- data/lib/kward/compactor.rb +7 -2
- data/lib/kward/config_files.rb +51 -12
- data/lib/kward/conversation.rb +13 -7
- data/lib/kward/git_worktree_manager.rb +291 -0
- data/lib/kward/hooks/audit_log.rb +5 -2
- data/lib/kward/model/azure_openai_config.rb +54 -0
- data/lib/kward/model/catalog.rb +113 -0
- data/lib/kward/model/client.rb +225 -20
- data/lib/kward/model/model_info.rb +49 -0
- data/lib/kward/model/payloads.rb +63 -4
- data/lib/kward/model/provider_catalog.rb +114 -0
- data/lib/kward/model/sources.rb +70 -0
- data/lib/kward/model/stream_parser.rb +83 -13
- data/lib/kward/permissions/policy.rb +18 -4
- data/lib/kward/plugin_chat_runtime.rb +374 -0
- data/lib/kward/plugin_registry.rb +99 -19
- data/lib/kward/private_file.rb +9 -3
- data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
- data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
- data/lib/kward/prompt_interface/editor/controller.rb +7 -7
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
- data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
- data/lib/kward/prompt_interface/editor/state.rb +3 -9
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +219 -19
- data/lib/kward/prompt_interface/file_overlay.rb +2 -2
- data/lib/kward/prompt_interface/git_prompt.rb +7 -3
- data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
- data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
- data/lib/kward/prompt_interface/interactive/state.rb +2 -2
- data/lib/kward/prompt_interface/project_browser.rb +25 -6
- data/lib/kward/prompt_interface/runtime_state.rb +22 -6
- data/lib/kward/prompt_interface/screen.rb +3 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
- data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
- data/lib/kward/prompt_interface.rb +53 -12
- data/lib/kward/prompts/commands.rb +1 -1
- data/lib/kward/prompts/templates.rb +11 -6
- data/lib/kward/prompts.rb +6 -5
- data/lib/kward/rpc/auth_manager.rb +112 -152
- data/lib/kward/rpc/config_manager.rb +46 -6
- data/lib/kward/rpc/plugin_chat_manager.rb +55 -196
- data/lib/kward/rpc/prompt_bridge.rb +8 -4
- data/lib/kward/rpc/redactor.rb +1 -1
- data/lib/kward/rpc/server.rb +60 -10
- data/lib/kward/rpc/session_manager.rb +109 -30
- data/lib/kward/session_store.rb +84 -24
- data/lib/kward/skills/registry.rb +16 -11
- data/lib/kward/tab_driver.rb +6 -3
- data/lib/kward/telemetry/logger.rb +5 -2
- data/lib/kward/tools/git_commit.rb +39 -0
- data/lib/kward/tools/registry.rb +7 -2
- data/lib/kward/tools/tool_call.rb +1 -0
- data/lib/kward/transport/gateway.rb +253 -0
- data/lib/kward/transport/host.rb +343 -0
- data/lib/kward/transport/manager.rb +179 -0
- data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
- data/lib/kward/transport/runtime.rb +41 -0
- data/lib/kward/transport/store.rb +101 -0
- data/lib/kward/transport.rb +204 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace_factory.rb +19 -2
- data/templates/default/fulldoc/html/js/kward.js +2 -0
- data/templates/default/kward_navigation.rb +7 -2
- data/templates/default/layout/html/setup.rb +4 -0
- 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
|
data/lib/kward/compactor.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/kward/config_files.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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"))
|
|
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
|
-
|
|
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
|