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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +13 -2
- data/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +82 -1
- data/Gemfile.lock +2 -2
- data/README.md +33 -38
- data/doc/agent-tools.md +10 -11
- 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 +136 -27
- data/doc/context-budgeting.md +6 -6
- data/doc/context-tools.md +2 -2
- data/doc/editor.md +7 -7
- data/doc/extensibility.md +9 -29
- data/doc/files.md +6 -6
- data/doc/getting-started.md +3 -3
- data/doc/git.md +8 -2
- data/doc/lifecycle-hooks.md +5 -4
- data/doc/local-models.md +130 -0
- data/doc/mcp.md +5 -5
- data/doc/permissions.md +15 -5
- data/doc/plugins.md +39 -4
- data/doc/prompt-templates.md +105 -0
- data/doc/providers.md +140 -0
- data/doc/releasing.md +11 -3
- data/doc/rpc.md +65 -31
- data/doc/sandboxing.md +142 -0
- data/doc/security.md +31 -10
- data/doc/session-management.md +2 -4
- data/doc/skills.md +10 -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 +4 -4
- 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/kward.gemspec +1 -1
- 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/doctor.rb +21 -0
- data/lib/kward/cli/git.rb +31 -8
- data/lib/kward/cli/plugins.rb +1 -0
- data/lib/kward/cli/prompt_interface.rb +7 -0
- data/lib/kward/cli/rendering.rb +6 -2
- data/lib/kward/cli/runtime_helpers.rb +4 -2
- data/lib/kward/cli/sessions.rb +1 -1
- data/lib/kward/cli/settings.rb +162 -36
- data/lib/kward/cli/slash_commands.rb +109 -1
- data/lib/kward/cli/tabs.rb +102 -37
- data/lib/kward/cli/transports.rb +67 -0
- data/lib/kward/cli/worktrees.rb +360 -0
- data/lib/kward/cli.rb +22 -1
- data/lib/kward/config_files.rb +106 -4
- data/lib/kward/conversation.rb +27 -8
- data/lib/kward/git_worktree_manager.rb +291 -0
- data/lib/kward/local_command_runner.rb +2 -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 +364 -23
- data/lib/kward/model/model_info.rb +60 -1
- data/lib/kward/model/payloads.rb +64 -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 +141 -39
- data/lib/kward/pan/index.html.erb +50 -0
- data/lib/kward/pan/server.rb +49 -2
- data/lib/kward/permissions/policy.rb +18 -4
- data/lib/kward/plugin_chat_runtime.rb +374 -0
- data/lib/kward/plugin_registry.rb +81 -8
- 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 +43 -10
- 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 +184 -9
- 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 +71 -12
- data/lib/kward/prompts/commands.rb +3 -1
- data/lib/kward/prompts.rb +22 -10
- 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 +56 -194
- data/lib/kward/rpc/prompt_bridge.rb +8 -4
- data/lib/kward/rpc/redactor.rb +1 -1
- data/lib/kward/rpc/server.rb +102 -14
- data/lib/kward/rpc/session_manager.rb +151 -38
- data/lib/kward/sandbox/capabilities.rb +39 -0
- data/lib/kward/sandbox/command_runner.rb +28 -0
- data/lib/kward/sandbox/environment.rb +24 -0
- data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
- data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
- data/lib/kward/sandbox/passthrough_runner.rb +13 -0
- data/lib/kward/sandbox/policy.rb +74 -0
- data/lib/kward/sandbox/runner_factory.rb +55 -0
- data/lib/kward/sandbox/unavailable_runner.rb +21 -0
- data/lib/kward/sandbox.rb +9 -0
- data/lib/kward/session_store.rb +110 -24
- data/lib/kward/skills/capture.rb +144 -0
- data/lib/kward/tab_driver.rb +6 -3
- data/lib/kward/tools/git_commit.rb +39 -0
- data/lib/kward/tools/registry.rb +26 -6
- 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.rb +18 -3
- data/lib/kward/workspace_factory.rb +34 -0
- data/templates/default/fulldoc/html/js/kward.js +3 -0
- data/templates/default/kward_navigation.rb +8 -1
- data/templates/default/layout/html/setup.rb +5 -0
- 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 "
|
|
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")
|
data/lib/kward/config_files.rb
CHANGED
|
@@ -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
|
|
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"))
|
|
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
|
[]
|