kward 0.78.0 → 0.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +13 -2
  3. data/.github/workflows/pages.yml +1 -1
  4. data/CHANGELOG.md +82 -1
  5. data/Gemfile.lock +2 -2
  6. data/README.md +33 -38
  7. data/doc/agent-tools.md +10 -11
  8. data/doc/api.md +3 -3
  9. data/doc/authentication.md +79 -110
  10. data/doc/code-search.md +1 -1
  11. data/doc/composer.md +1 -1
  12. data/doc/configuration.md +136 -27
  13. data/doc/context-budgeting.md +6 -6
  14. data/doc/context-tools.md +2 -2
  15. data/doc/editor.md +7 -7
  16. data/doc/extensibility.md +9 -29
  17. data/doc/files.md +6 -6
  18. data/doc/getting-started.md +3 -3
  19. data/doc/git.md +8 -2
  20. data/doc/lifecycle-hooks.md +5 -4
  21. data/doc/local-models.md +130 -0
  22. data/doc/mcp.md +5 -5
  23. data/doc/permissions.md +15 -5
  24. data/doc/plugins.md +39 -4
  25. data/doc/prompt-templates.md +105 -0
  26. data/doc/providers.md +140 -0
  27. data/doc/releasing.md +11 -3
  28. data/doc/rpc.md +65 -31
  29. data/doc/sandboxing.md +142 -0
  30. data/doc/security.md +31 -10
  31. data/doc/session-management.md +2 -4
  32. data/doc/skills.md +10 -0
  33. data/doc/tabs.md +37 -0
  34. data/doc/telegram.md +138 -0
  35. data/doc/transports.md +209 -0
  36. data/doc/usage.md +11 -3
  37. data/doc/web-search.md +3 -3
  38. data/doc/workspace-tools.md +4 -4
  39. data/examples/plugins/stardate_footer.rb +10 -0
  40. data/examples/plugins/telegram/plugin.rb +39 -0
  41. data/examples/plugins/telegram/telegram_api.rb +136 -0
  42. data/examples/plugins/telegram/telegram_transport.rb +304 -0
  43. data/kward.gemspec +1 -1
  44. data/lib/kward/ansi.rb +1 -0
  45. data/lib/kward/auth/api_key_store.rb +99 -0
  46. data/lib/kward/cli/auth_commands.rb +72 -13
  47. data/lib/kward/cli/commands.rb +7 -0
  48. data/lib/kward/cli/doctor.rb +21 -0
  49. data/lib/kward/cli/git.rb +31 -8
  50. data/lib/kward/cli/plugins.rb +1 -0
  51. data/lib/kward/cli/prompt_interface.rb +7 -0
  52. data/lib/kward/cli/rendering.rb +6 -2
  53. data/lib/kward/cli/runtime_helpers.rb +4 -2
  54. data/lib/kward/cli/sessions.rb +1 -1
  55. data/lib/kward/cli/settings.rb +162 -36
  56. data/lib/kward/cli/slash_commands.rb +109 -1
  57. data/lib/kward/cli/tabs.rb +102 -37
  58. data/lib/kward/cli/transports.rb +67 -0
  59. data/lib/kward/cli/worktrees.rb +360 -0
  60. data/lib/kward/cli.rb +22 -1
  61. data/lib/kward/config_files.rb +106 -4
  62. data/lib/kward/conversation.rb +27 -8
  63. data/lib/kward/git_worktree_manager.rb +291 -0
  64. data/lib/kward/local_command_runner.rb +2 -2
  65. data/lib/kward/model/azure_openai_config.rb +54 -0
  66. data/lib/kward/model/catalog.rb +113 -0
  67. data/lib/kward/model/client.rb +364 -23
  68. data/lib/kward/model/model_info.rb +60 -1
  69. data/lib/kward/model/payloads.rb +64 -4
  70. data/lib/kward/model/provider_catalog.rb +114 -0
  71. data/lib/kward/model/sources.rb +70 -0
  72. data/lib/kward/model/stream_parser.rb +141 -39
  73. data/lib/kward/pan/index.html.erb +50 -0
  74. data/lib/kward/pan/server.rb +49 -2
  75. data/lib/kward/permissions/policy.rb +18 -4
  76. data/lib/kward/plugin_chat_runtime.rb +374 -0
  77. data/lib/kward/plugin_registry.rb +81 -8
  78. data/lib/kward/private_file.rb +9 -3
  79. data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
  80. data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
  81. data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
  82. data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
  83. data/lib/kward/prompt_interface/editor/controller.rb +43 -10
  84. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  85. data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
  86. data/lib/kward/prompt_interface/editor/state.rb +3 -9
  87. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +184 -9
  88. data/lib/kward/prompt_interface/file_overlay.rb +2 -2
  89. data/lib/kward/prompt_interface/git_prompt.rb +7 -3
  90. data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
  91. data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
  92. data/lib/kward/prompt_interface/interactive/state.rb +2 -2
  93. data/lib/kward/prompt_interface/project_browser.rb +25 -6
  94. data/lib/kward/prompt_interface/runtime_state.rb +22 -6
  95. data/lib/kward/prompt_interface/screen.rb +3 -0
  96. data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
  97. data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
  98. data/lib/kward/prompt_interface.rb +71 -12
  99. data/lib/kward/prompts/commands.rb +3 -1
  100. data/lib/kward/prompts.rb +22 -10
  101. data/lib/kward/rpc/auth_manager.rb +112 -152
  102. data/lib/kward/rpc/config_manager.rb +46 -6
  103. data/lib/kward/rpc/plugin_chat_manager.rb +56 -194
  104. data/lib/kward/rpc/prompt_bridge.rb +8 -4
  105. data/lib/kward/rpc/redactor.rb +1 -1
  106. data/lib/kward/rpc/server.rb +102 -14
  107. data/lib/kward/rpc/session_manager.rb +151 -38
  108. data/lib/kward/sandbox/capabilities.rb +39 -0
  109. data/lib/kward/sandbox/command_runner.rb +28 -0
  110. data/lib/kward/sandbox/environment.rb +24 -0
  111. data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
  112. data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
  113. data/lib/kward/sandbox/passthrough_runner.rb +13 -0
  114. data/lib/kward/sandbox/policy.rb +74 -0
  115. data/lib/kward/sandbox/runner_factory.rb +55 -0
  116. data/lib/kward/sandbox/unavailable_runner.rb +21 -0
  117. data/lib/kward/sandbox.rb +9 -0
  118. data/lib/kward/session_store.rb +110 -24
  119. data/lib/kward/skills/capture.rb +144 -0
  120. data/lib/kward/tab_driver.rb +6 -3
  121. data/lib/kward/tools/git_commit.rb +39 -0
  122. data/lib/kward/tools/registry.rb +26 -6
  123. data/lib/kward/tools/tool_call.rb +1 -0
  124. data/lib/kward/transport/gateway.rb +253 -0
  125. data/lib/kward/transport/host.rb +343 -0
  126. data/lib/kward/transport/manager.rb +179 -0
  127. data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
  128. data/lib/kward/transport/runtime.rb +41 -0
  129. data/lib/kward/transport/store.rb +101 -0
  130. data/lib/kward/transport.rb +204 -0
  131. data/lib/kward/version.rb +1 -1
  132. data/lib/kward/workspace.rb +18 -3
  133. data/lib/kward/workspace_factory.rb +34 -0
  134. data/templates/default/fulldoc/html/js/kward.js +3 -0
  135. data/templates/default/kward_navigation.rb +8 -1
  136. data/templates/default/layout/html/setup.rb +5 -0
  137. metadata +41 -2
@@ -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
- refresh_system_message! if @system_message_enabled && workspace_agents_mtime != @workspace_agents_mtime
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