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
data/lib/kward/conversation.rb
CHANGED
|
@@ -58,35 +58,37 @@ module Kward
|
|
|
58
58
|
# @return [PluginRegistry, nil] registry used to collect plugin prompt context
|
|
59
59
|
attr_accessor :plugin_registry
|
|
60
60
|
# @return [String, nil] plugin prompt context used in the current system prompt
|
|
61
|
-
attr_reader :last_plugin_prompt_context
|
|
61
|
+
attr_reader :last_plugin_prompt_context, :execution_profile_context
|
|
62
62
|
# @return [Hash] original large tool outputs retained outside model context
|
|
63
63
|
attr_reader :tool_output_artifacts
|
|
64
64
|
# @return [ContextBudgetMeter] runtime context savings for this conversation
|
|
65
65
|
attr_reader :context_budget_meter
|
|
66
66
|
|
|
67
|
-
def initialize(system_message: DEFAULT_SYSTEM_MESSAGE, messages: [], read_paths: [], on_append: nil, on_compact: nil, on_tool_execution: nil, on_runtime_update: nil, workspace_root: Dir.pwd, compaction_system_message: DEFAULT_SYSTEM_MESSAGE, provider: nil, model: nil, reasoning_effort: nil, memory_context: nil, session_memories: [], last_memory_retrieval: nil, plugin_registry: nil)
|
|
67
|
+
def initialize(system_message: DEFAULT_SYSTEM_MESSAGE, messages: [], read_paths: [], on_append: nil, on_compact: nil, on_tool_execution: nil, on_runtime_update: nil, workspace_root: Dir.pwd, compaction_system_message: DEFAULT_SYSTEM_MESSAGE, provider: nil, model: nil, reasoning_effort: nil, memory_context: nil, session_memories: [], last_memory_retrieval: nil, plugin_registry: nil, execution_profile_context: nil)
|
|
68
68
|
@workspace_root = ConfigFiles.canonical_workspace_root(workspace_root)
|
|
69
69
|
@provider = provider
|
|
70
70
|
@model = model
|
|
71
71
|
@reasoning_effort = reasoning_effort
|
|
72
72
|
@plugin_registry = plugin_registry
|
|
73
|
+
@execution_profile_context = execution_profile_context.to_s.empty? ? nil : execution_profile_context.to_s.freeze
|
|
73
74
|
@messages = []
|
|
74
75
|
restored_system_message, transcript_messages = split_system_message(messages)
|
|
75
76
|
if system_message.equal?(DEFAULT_SYSTEM_MESSAGE)
|
|
76
|
-
if restored_system_message
|
|
77
|
+
if restored_system_message && @execution_profile_context.to_s.empty?
|
|
77
78
|
system_message = restored_system_message
|
|
78
79
|
else
|
|
79
80
|
@last_plugin_prompt_context = plugin_prompt_context
|
|
80
|
-
system_message = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: memory_context, plugin_context: @last_plugin_prompt_context)
|
|
81
|
+
system_message = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: memory_context, plugin_context: @last_plugin_prompt_context, execution_profile_context: @execution_profile_context)
|
|
81
82
|
end
|
|
82
83
|
end
|
|
83
84
|
@system_message = system_message
|
|
84
85
|
@system_message_enabled = !@system_message.nil?
|
|
85
86
|
if compaction_system_message.equal?(DEFAULT_SYSTEM_MESSAGE)
|
|
86
|
-
compaction_system_message = @system_message_enabled ? Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time) : nil
|
|
87
|
+
compaction_system_message = @system_message_enabled ? Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, execution_profile_context: @execution_profile_context) : nil
|
|
87
88
|
end
|
|
88
89
|
@compaction_system_message = compaction_system_message
|
|
89
90
|
@workspace_agents_mtime = workspace_agents_mtime
|
|
91
|
+
@system_prompt_sources_fingerprint = ConfigFiles.system_prompt_sources_fingerprint
|
|
90
92
|
@last_entry_compaction = false
|
|
91
93
|
@memory_context = memory_context
|
|
92
94
|
@session_memories = Array(session_memories)
|
|
@@ -186,14 +188,20 @@ module Kward
|
|
|
186
188
|
return nil unless @system_message_enabled
|
|
187
189
|
|
|
188
190
|
@last_plugin_prompt_context = plugin_prompt_context
|
|
189
|
-
replacement = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: @memory_context, plugin_context: @last_plugin_prompt_context)
|
|
191
|
+
replacement = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: @memory_context, plugin_context: @last_plugin_prompt_context, execution_profile_context: @execution_profile_context)
|
|
190
192
|
@system_message = replacement
|
|
191
193
|
@on_system_message_change&.call(replacement)
|
|
192
|
-
@compaction_system_message = Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time)
|
|
194
|
+
@compaction_system_message = Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, execution_profile_context: @execution_profile_context)
|
|
193
195
|
@workspace_agents_mtime = workspace_agents_mtime
|
|
196
|
+
@system_prompt_sources_fingerprint = ConfigFiles.system_prompt_sources_fingerprint
|
|
194
197
|
replacement
|
|
195
198
|
end
|
|
196
199
|
|
|
200
|
+
def update_execution_profile_context!(context)
|
|
201
|
+
@execution_profile_context = context.to_s.empty? ? nil : context.to_s.freeze
|
|
202
|
+
refresh_system_message!
|
|
203
|
+
end
|
|
204
|
+
|
|
197
205
|
def update_runtime_context!(provider: nil, model:, reasoning_effort:, refresh: true)
|
|
198
206
|
@provider = provider unless provider.to_s.empty?
|
|
199
207
|
@model = model
|
|
@@ -206,7 +214,18 @@ module Kward
|
|
|
206
214
|
end
|
|
207
215
|
|
|
208
216
|
def refresh_system_message_if_workspace_agents_changed!
|
|
209
|
-
|
|
217
|
+
refresh_system_message_if_sources_changed!
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Refreshes runtime prompt state after workspace or config-owned prompt
|
|
221
|
+
# sources change. Keep the older public method above as a compatibility
|
|
222
|
+
# alias for existing callers.
|
|
223
|
+
def refresh_system_message_if_sources_changed!
|
|
224
|
+
return unless @system_message_enabled
|
|
225
|
+
|
|
226
|
+
changed = workspace_agents_mtime != @workspace_agents_mtime ||
|
|
227
|
+
ConfigFiles.system_prompt_sources_fingerprint != @system_prompt_sources_fingerprint
|
|
228
|
+
refresh_system_message! if changed
|
|
210
229
|
end
|
|
211
230
|
|
|
212
231
|
def mark_read(path)
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "open3"
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
require_relative "path_guard"
|
|
6
|
+
|
|
7
|
+
# Namespace for the Kward CLI agent runtime.
|
|
8
|
+
module Kward
|
|
9
|
+
# Durable association between a session tab and a linked Git worktree.
|
|
10
|
+
class GitWorktreeBinding
|
|
11
|
+
FIELDS = %i[repository_root origin_root path branch base_revision active owned].freeze
|
|
12
|
+
|
|
13
|
+
attr_accessor :repository_root, :origin_root, :path, :branch, :base_revision, :active, :owned
|
|
14
|
+
|
|
15
|
+
def initialize(repository_root:, origin_root:, path:, branch:, base_revision:, active: true, owned: true)
|
|
16
|
+
@repository_root = repository_root.to_s
|
|
17
|
+
@origin_root = origin_root.to_s
|
|
18
|
+
@path = path.to_s
|
|
19
|
+
@branch = branch.to_s
|
|
20
|
+
@base_revision = base_revision.to_s
|
|
21
|
+
@active = active != false
|
|
22
|
+
@owned = owned != false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def active?
|
|
26
|
+
active == true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def descriptor
|
|
30
|
+
{
|
|
31
|
+
"repository_root" => repository_root,
|
|
32
|
+
"origin_root" => origin_root,
|
|
33
|
+
"path" => path,
|
|
34
|
+
"branch" => branch,
|
|
35
|
+
"base_revision" => base_revision,
|
|
36
|
+
"active" => active?,
|
|
37
|
+
"owned" => owned != false
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.from_descriptor(value)
|
|
42
|
+
data = value.respond_to?(:transform_keys) ? value.transform_keys(&:to_s) : {}
|
|
43
|
+
required = %w[repository_root origin_root path branch base_revision]
|
|
44
|
+
return nil unless required.all? { |key| !data[key].to_s.empty? }
|
|
45
|
+
|
|
46
|
+
new(
|
|
47
|
+
repository_root: data["repository_root"],
|
|
48
|
+
origin_root: data["origin_root"],
|
|
49
|
+
path: data["path"],
|
|
50
|
+
branch: data["branch"],
|
|
51
|
+
base_revision: data["base_revision"],
|
|
52
|
+
active: data.fetch("active", true),
|
|
53
|
+
owned: data.fetch("owned", true)
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Performs safe, structured operations on linked Git worktrees.
|
|
59
|
+
class GitWorktreeManager
|
|
60
|
+
class Error < StandardError; end
|
|
61
|
+
|
|
62
|
+
WorktreeInfo = Struct.new(
|
|
63
|
+
:path,
|
|
64
|
+
:head,
|
|
65
|
+
:branch,
|
|
66
|
+
:detached,
|
|
67
|
+
:locked,
|
|
68
|
+
:prunable,
|
|
69
|
+
keyword_init: true
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
GitStatus = Struct.new(:entries, keyword_init: true) do
|
|
73
|
+
def clean?
|
|
74
|
+
entries.empty?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def dirty?
|
|
78
|
+
!clean?
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
MergeResult = Struct.new(:status, :output, :conflicts, keyword_init: true) do
|
|
83
|
+
def merged?
|
|
84
|
+
status == :merged
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def conflicted?
|
|
88
|
+
status == :conflicted
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def repository_root(path)
|
|
93
|
+
output = run_git(path, "rev-parse", "--show-toplevel")
|
|
94
|
+
canonical_existing_path(output.strip)
|
|
95
|
+
rescue Error
|
|
96
|
+
raise Error, "Not a Git repository: #{path}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def status(path)
|
|
100
|
+
output = run_git(path, "status", "--porcelain=v1", "--untracked-files=all")
|
|
101
|
+
GitStatus.new(entries: output.lines(chomp: true))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def current_revision(path)
|
|
105
|
+
run_git(path, "rev-parse", "HEAD").strip
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def current_branch(path)
|
|
109
|
+
run_git(path, "branch", "--show-current").strip
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def merge(repository_root:, target_path:, source_branch:)
|
|
113
|
+
repository_root = canonical_existing_path(repository_root)
|
|
114
|
+
target_path = canonical_existing_path(target_path)
|
|
115
|
+
source_branch = source_branch.to_s.strip
|
|
116
|
+
validate_merge!(repository_root, target_path, source_branch)
|
|
117
|
+
|
|
118
|
+
output, status = capture_git(target_path, "merge", "--no-edit", source_branch)
|
|
119
|
+
return MergeResult.new(status: :merged, output: output, conflicts: []) if status.success?
|
|
120
|
+
|
|
121
|
+
conflicts = unmerged_paths(target_path)
|
|
122
|
+
return MergeResult.new(status: :conflicted, output: output, conflicts: conflicts) if merge_in_progress?(target_path)
|
|
123
|
+
|
|
124
|
+
raise Error, git_error("merge", output)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def merge_in_progress?(path)
|
|
128
|
+
_output, status = capture_git(path, "rev-parse", "-q", "--verify", "MERGE_HEAD")
|
|
129
|
+
status.success?
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def unmerged_paths(path)
|
|
133
|
+
run_git(path, "diff", "--name-only", "--diff-filter=U").lines(chomp: true)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def abort_merge(path)
|
|
137
|
+
raise Error, "No merge is in progress." unless merge_in_progress?(path)
|
|
138
|
+
|
|
139
|
+
run_git(path, "merge", "--abort")
|
|
140
|
+
true
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def create(repository_root:, origin_root:, path:, branch:, base: "HEAD")
|
|
144
|
+
repository_root = canonical_existing_path(repository_root)
|
|
145
|
+
origin_root = canonical_existing_path(origin_root)
|
|
146
|
+
path = planned_path(path)
|
|
147
|
+
branch = branch.to_s.strip
|
|
148
|
+
base = base.to_s.strip
|
|
149
|
+
validate_creation!(repository_root, origin_root, path, branch, base)
|
|
150
|
+
|
|
151
|
+
run_git(repository_root, "worktree", "add", "-b", branch, path, base)
|
|
152
|
+
GitWorktreeBinding.new(
|
|
153
|
+
repository_root: repository_root,
|
|
154
|
+
origin_root: origin_root,
|
|
155
|
+
path: path,
|
|
156
|
+
branch: branch,
|
|
157
|
+
base_revision: revision_for(repository_root, base),
|
|
158
|
+
active: true,
|
|
159
|
+
owned: true
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def inspect(repository_root:, path:)
|
|
164
|
+
repository_root = canonical_existing_path(repository_root)
|
|
165
|
+
expected_path = planned_path(path)
|
|
166
|
+
worktree = list(repository_root).find { |entry| same_path?(entry.path, expected_path) }
|
|
167
|
+
raise Error, "Git worktree is not registered: #{expected_path}" unless worktree
|
|
168
|
+
|
|
169
|
+
worktree
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def list(repository_root)
|
|
173
|
+
output = run_git(repository_root, "worktree", "list", "--porcelain")
|
|
174
|
+
parse_worktree_list(output)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def remove(repository_root:, path:, force: false)
|
|
178
|
+
repository_root = canonical_existing_path(repository_root)
|
|
179
|
+
expected_path = planned_path(path)
|
|
180
|
+
inspect(repository_root: repository_root, path: expected_path)
|
|
181
|
+
arguments = ["worktree", "remove"]
|
|
182
|
+
arguments << "--force" if force
|
|
183
|
+
arguments << expected_path
|
|
184
|
+
run_git(repository_root, *arguments)
|
|
185
|
+
true
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private
|
|
189
|
+
|
|
190
|
+
def validate_merge!(repository_root, target_path, source_branch)
|
|
191
|
+
raise Error, "Merge target must be inside the repository: #{target_path}" unless PathGuard.inside?(target_path, repository_root)
|
|
192
|
+
raise Error, "Source branch is required" if source_branch.empty?
|
|
193
|
+
raise Error, "Invalid source branch: #{source_branch}" unless git_succeeds?(repository_root, "check-ref-format", "--branch", source_branch)
|
|
194
|
+
raise Error, "Source branch does not exist: #{source_branch}" unless valid_revision?(repository_root, source_branch)
|
|
195
|
+
raise Error, "A merge is already in progress: #{target_path}" if merge_in_progress?(target_path)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def validate_creation!(repository_root, origin_root, path, branch, base)
|
|
199
|
+
raise Error, "Worktree path must be outside the repository: #{path}" if PathGuard.inside?(path, repository_root)
|
|
200
|
+
raise Error, "Worktree origin must be inside the repository: #{origin_root}" unless PathGuard.inside?(origin_root, repository_root)
|
|
201
|
+
raise Error, "Branch name is required" if branch.empty?
|
|
202
|
+
raise Error, "Base revision is required" if base.empty?
|
|
203
|
+
raise Error, "Invalid branch name: #{branch}" unless git_succeeds?(repository_root, "check-ref-format", "--branch", branch)
|
|
204
|
+
raise Error, "Invalid base revision: #{base}" unless valid_revision?(repository_root, base)
|
|
205
|
+
raise Error, "Worktree path is a file: #{path}" if File.file?(path)
|
|
206
|
+
raise Error, "Worktree path is not empty: #{path}" if File.directory?(path) && !Dir.empty?(path)
|
|
207
|
+
raise Error, "Worktree path is already registered: #{path}" if list(repository_root).any? { |entry| same_path?(entry.path, path) }
|
|
208
|
+
|
|
209
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def valid_revision?(repository_root, revision)
|
|
213
|
+
return false if revision.start_with?("-") || revision.match?(/\s/)
|
|
214
|
+
|
|
215
|
+
git_succeeds?(repository_root, "rev-parse", "--verify", "#{revision}^{commit}")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def revision_for(repository_root, revision)
|
|
219
|
+
run_git(repository_root, "rev-parse", "--verify", "#{revision}^{commit}").strip
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def parse_worktree_list(output)
|
|
223
|
+
output.split(/\n\n+/).filter_map do |block|
|
|
224
|
+
fields = block.lines(chomp: true).each_with_object({}) do |line, values|
|
|
225
|
+
key, value = line.split(" ", 2)
|
|
226
|
+
values[key] = value.to_s unless key.nil? || key.empty?
|
|
227
|
+
end
|
|
228
|
+
path = fields["worktree"]
|
|
229
|
+
next if path.to_s.empty?
|
|
230
|
+
|
|
231
|
+
branch = fields["branch"].to_s.delete_prefix("refs/heads/")
|
|
232
|
+
WorktreeInfo.new(
|
|
233
|
+
path: path,
|
|
234
|
+
head: fields["HEAD"].to_s,
|
|
235
|
+
branch: branch.empty? ? nil : branch,
|
|
236
|
+
detached: fields.key?("detached"),
|
|
237
|
+
locked: fields.key?("locked"),
|
|
238
|
+
prunable: fields.key?("prunable")
|
|
239
|
+
)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def run_git(root, *arguments)
|
|
244
|
+
output, status = capture_git(root, *arguments)
|
|
245
|
+
return output if status.success?
|
|
246
|
+
|
|
247
|
+
raise Error, git_error(arguments.join(" "), output)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def capture_git(root, *arguments)
|
|
251
|
+
Open3.capture2e("git", "-C", root.to_s, *arguments)
|
|
252
|
+
rescue Errno::ENOENT => e
|
|
253
|
+
raise Error, e.message
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def git_error(command, output)
|
|
257
|
+
message = output.to_s.strip
|
|
258
|
+
message = "git #{command} failed" if message.empty?
|
|
259
|
+
message
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def git_succeeds?(root, *arguments)
|
|
263
|
+
_output, status = Open3.capture2e("git", "-C", root.to_s, *arguments)
|
|
264
|
+
status.success?
|
|
265
|
+
rescue Errno::ENOENT
|
|
266
|
+
false
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def canonical_existing_path(path)
|
|
270
|
+
File.realpath(path.to_s)
|
|
271
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
272
|
+
raise Error, "Path does not exist: #{path}"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def planned_path(path)
|
|
276
|
+
expanded = Pathname.new(path.to_s).expand_path
|
|
277
|
+
return File.realpath(expanded.to_s) if File.exist?(expanded.to_s)
|
|
278
|
+
|
|
279
|
+
ancestor = expanded
|
|
280
|
+
ancestor = ancestor.parent until File.exist?(ancestor.to_s) || ancestor == ancestor.parent
|
|
281
|
+
canonical_ancestor = Pathname.new(File.realpath(ancestor.to_s))
|
|
282
|
+
canonical_ancestor.join(expanded.relative_path_from(ancestor)).to_s
|
|
283
|
+
rescue ArgumentError, Errno::ENOENT, Errno::ENOTDIR
|
|
284
|
+
raise Error, "Invalid worktree path: #{path}"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def same_path?(left, right)
|
|
288
|
+
planned_path(left) == planned_path(right)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
@@ -18,7 +18,7 @@ module Kward
|
|
|
18
18
|
@terminate_on_output_limit = terminate_on_output_limit
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
def run(*command, env: {}, cwd: Dir.pwd, cancellation: nil, &block)
|
|
21
|
+
def run(*command, env: {}, cwd: Dir.pwd, cancellation: nil, unsetenv_others: false, &block)
|
|
22
22
|
cancellation&.raise_if_cancelled!
|
|
23
23
|
stdout_buffer = +""
|
|
24
24
|
stderr_buffer = +""
|
|
@@ -27,7 +27,7 @@ module Kward
|
|
|
27
27
|
timed_out = false
|
|
28
28
|
queue = Queue.new
|
|
29
29
|
|
|
30
|
-
Open3.popen3(env.to_h, *command, chdir: cwd.to_s, pgroup: true) do |stdin, stdout, stderr, wait_thread|
|
|
30
|
+
Open3.popen3(env.to_h, *command, chdir: cwd.to_s, pgroup: true, unsetenv_others: unsetenv_others) do |stdin, stdout, stderr, wait_thread|
|
|
31
31
|
stdin.close
|
|
32
32
|
readers = [
|
|
33
33
|
read_stream(stdout, :stdout, queue),
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
# Namespace for Kward model-provider support.
|
|
4
|
+
module Kward
|
|
5
|
+
# Validated Azure OpenAI endpoint and deployment settings.
|
|
6
|
+
class AzureOpenAIConfig
|
|
7
|
+
DEPLOYMENT_PATTERN = /\A[a-zA-Z0-9._-]+\z/
|
|
8
|
+
API_VERSION_PATTERN = /\A[a-zA-Z0-9._-]+\z/
|
|
9
|
+
|
|
10
|
+
def initialize(endpoint:, deployment:, api_version:)
|
|
11
|
+
@endpoint = normalize_endpoint(endpoint)
|
|
12
|
+
@deployment = validate_value(deployment, "deployment name", DEPLOYMENT_PATTERN)
|
|
13
|
+
@api_version = validate_value(api_version, "API version", API_VERSION_PATTERN)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
attr_reader :endpoint, :deployment, :api_version
|
|
17
|
+
|
|
18
|
+
def chat_completions_url
|
|
19
|
+
URI("#{endpoint}/openai/deployments/#{deployment}/chat/completions?api-version=#{api_version}")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def to_config
|
|
23
|
+
{
|
|
24
|
+
"azure_openai_endpoint" => endpoint,
|
|
25
|
+
"azure_openai_model" => deployment,
|
|
26
|
+
"azure_openai_api_version" => api_version
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def normalize_endpoint(value)
|
|
33
|
+
text = value.to_s.strip
|
|
34
|
+
raise ArgumentError, "Azure OpenAI endpoint must be a non-empty HTTPS URL" if text.empty?
|
|
35
|
+
|
|
36
|
+
uri = URI.parse(text)
|
|
37
|
+
unless uri.is_a?(URI::HTTPS) && !uri.host.to_s.empty? && uri.user.nil? && uri.password.nil? && uri.query.nil? && uri.fragment.nil?
|
|
38
|
+
raise ArgumentError, "Azure OpenAI endpoint must be an HTTPS URL without credentials, query, or fragment"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
uri.to_s.sub(%r{/+\z}, "")
|
|
42
|
+
rescue URI::InvalidURIError
|
|
43
|
+
raise ArgumentError, "Azure OpenAI endpoint must be a valid HTTPS URL"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate_value(value, label, pattern)
|
|
47
|
+
text = value.to_s.strip
|
|
48
|
+
raise ArgumentError, "Azure OpenAI #{label} must be a non-empty string" if text.empty?
|
|
49
|
+
raise ArgumentError, "Azure OpenAI #{label} contains invalid characters" unless text.match?(pattern)
|
|
50
|
+
|
|
51
|
+
text
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "time"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "../config_files"
|
|
6
|
+
require_relative "../http"
|
|
7
|
+
require_relative "../private_file"
|
|
8
|
+
require_relative "provider_catalog"
|
|
9
|
+
|
|
10
|
+
# Namespace for Kward model catalogs.
|
|
11
|
+
module Kward
|
|
12
|
+
# Fetches, normalizes, and caches provider model lists without storing secrets.
|
|
13
|
+
class ModelCatalog
|
|
14
|
+
VERSION = 1
|
|
15
|
+
attr_reader :path
|
|
16
|
+
|
|
17
|
+
def initialize(provider_id:, api_key:, path: nil, requester: nil)
|
|
18
|
+
@provider = ProviderCatalog.fetch(provider_id)
|
|
19
|
+
@api_key = api_key.to_s
|
|
20
|
+
@path = File.expand_path(path || ConfigFiles.model_catalog_cache_path(@provider.id))
|
|
21
|
+
@requester = requester
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def refresh
|
|
25
|
+
raise "No #{@provider.name} API key found" if @api_key.empty?
|
|
26
|
+
raise "#{@provider.name} does not support automatic model discovery" unless runtime.automatic_model_discovery?
|
|
27
|
+
|
|
28
|
+
data = cache_data(normalize(response_body))
|
|
29
|
+
PrivateFile.write_json(path, data)
|
|
30
|
+
data.fetch("models")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def models
|
|
34
|
+
Array(read&.fetch("models", []))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def read
|
|
38
|
+
return nil unless File.exist?(path)
|
|
39
|
+
|
|
40
|
+
data = JSON.parse(File.read(path))
|
|
41
|
+
return nil unless data.is_a?(Hash) && data["version"] == VERSION && data["provider"] == @provider.id
|
|
42
|
+
|
|
43
|
+
data
|
|
44
|
+
rescue JSON::ParserError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def response_body
|
|
51
|
+
return @requester.call(url, headers) if @requester
|
|
52
|
+
|
|
53
|
+
response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
|
|
54
|
+
http.request(Http.apply_user_agent(Net::HTTP::Get.new(url)).tap do |request|
|
|
55
|
+
headers.each { |name, value| request[name] = value }
|
|
56
|
+
end)
|
|
57
|
+
end
|
|
58
|
+
raise "#{@provider.name} model refresh failed: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
59
|
+
|
|
60
|
+
response.body
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def url
|
|
64
|
+
base = runtime.model_list_url
|
|
65
|
+
return URI("#{base}?key=#{URI.encode_www_form_component(@api_key)}") if @provider.id == "gemini"
|
|
66
|
+
|
|
67
|
+
URI(base)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def runtime
|
|
71
|
+
ProviderCatalog.runtime(@provider.id)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def headers
|
|
75
|
+
return { "x-api-key" => @api_key, "anthropic-version" => "2023-06-01" } if @provider.id == "anthropic"
|
|
76
|
+
return {} if @provider.id == "gemini"
|
|
77
|
+
|
|
78
|
+
{ "Authorization" => "Bearer #{@api_key}", "Accept" => "application/json" }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def normalize(body)
|
|
82
|
+
payload = JSON.parse(body.to_s)
|
|
83
|
+
entries = payload["data"] || payload["models"] || []
|
|
84
|
+
Array(entries).filter_map { |entry| normalize_entry(entry) }.sort_by { |entry| entry["id"] }
|
|
85
|
+
rescue JSON::ParserError
|
|
86
|
+
raise "#{@provider.name} model refresh returned invalid JSON"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def normalize_entry(entry)
|
|
90
|
+
return unless entry.is_a?(Hash)
|
|
91
|
+
|
|
92
|
+
id = (entry["id"] || entry["name"]).to_s.delete_prefix("models/")
|
|
93
|
+
return if id.empty?
|
|
94
|
+
|
|
95
|
+
{
|
|
96
|
+
"provider" => @provider.id,
|
|
97
|
+
"id" => id,
|
|
98
|
+
"name" => entry["display_name"] || entry["displayName"] || entry["name"] || id,
|
|
99
|
+
"contextWindow" => entry["context_window"] || entry["context_length"] || entry["inputTokenLimit"],
|
|
100
|
+
"supportedParameters" => Array(entry["supported_parameters"] || entry["supportedGenerationMethods"])
|
|
101
|
+
}.compact
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def cache_data(models)
|
|
105
|
+
{
|
|
106
|
+
"version" => VERSION,
|
|
107
|
+
"provider" => @provider.id,
|
|
108
|
+
"refreshed_at" => Time.now.utc.iso8601,
|
|
109
|
+
"models" => models
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|