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
@@ -22,12 +22,15 @@ module Kward
22
22
 
23
23
  # Writes the auth status output for the terminal CLI flow.
24
24
  def print_auth_status
25
- config = safely_read_config.to_h
25
+ store = api_key_store
26
+ store.migrate_openrouter_config_key!
26
27
  lines = ["#{colored("Auth Status", :green, :bold)}", ""]
27
28
  lines << auth_status_line("OpenAI OAuth", File.exist?(OpenAIOAuth.default_auth_path), OpenAIOAuth.default_auth_path)
28
29
  lines << auth_status_line("Anthropic OAuth", File.exist?(AnthropicOAuth.default_auth_path), AnthropicOAuth.default_auth_path)
29
30
  lines << auth_status_line("GitHub OAuth", File.exist?(GithubOAuth.default_auth_path), GithubOAuth.default_auth_path)
30
- lines << auth_status_line("OpenRouter API key", !config["openrouter_api_key"].to_s.empty? || !ENV["OPENROUTER_API_KEY"].to_s.empty?, ConfigFiles.config_path)
31
+ ProviderCatalog.api_key_providers.each do |provider|
32
+ lines << auth_status_line("#{provider.name} API key", store.configured?(provider.id), store.path)
33
+ end
31
34
  @prompt.say lines.join("\n")
32
35
  end
33
36
 
@@ -45,7 +48,11 @@ module Kward
45
48
  File.delete(path)
46
49
  removed << path
47
50
  end
48
- removed << "OpenRouter API key" if OpenRouterAPIKey.new.logout
51
+ store = api_key_store
52
+ store.migrate_openrouter_config_key!
53
+ ProviderCatalog.api_key_providers.each do |provider|
54
+ removed << "#{provider.name} API key" if store.delete(provider.id)
55
+ end
49
56
 
50
57
  if removed.empty?
51
58
  @prompt.say "No saved credentials found."
@@ -54,29 +61,81 @@ module Kward
54
61
  end
55
62
  end
56
63
 
57
- def login(provider: nil, oauth: nil)
58
- provider = provider.to_s.downcase
59
- if provider == "openrouter"
60
- auth = oauth || OpenRouterAPIKey.new
61
- path = auth.login(prompt: @prompt)
62
- @prompt.say("#{colored("Saved", :green, :bold)} OpenRouter API key to #{path}")
64
+ def login(provider: nil, oauth: nil, auth_method: nil)
65
+ provider = normalized_login_provider(provider)
66
+ if api_key_login?(provider, auth_method)
67
+ login_with_api_key(provider)
63
68
  return
64
69
  end
65
70
 
66
71
  oauth ||= case provider
67
- when "github" then GithubOAuth.new
68
- when "anthropic", "claude" then AnthropicOAuth.new
72
+ when "github", "copilot" then GithubOAuth.new
73
+ when "anthropic" then AnthropicOAuth.new
69
74
  else OpenAIOAuth.new
70
75
  end
71
76
  path = oauth.login(prompt: @prompt)
72
77
  name = case provider
73
- when "github" then "GitHub"
74
- when "anthropic", "claude" then "Anthropic"
78
+ when "github", "copilot" then "GitHub"
79
+ when "anthropic" then "Anthropic"
75
80
  else "OpenAI"
76
81
  end
77
82
  @prompt.say("#{colored("Saved", :green, :bold)} #{name} OAuth login to #{path}")
78
83
  end
79
84
 
85
+ private
86
+
87
+ def api_key_store
88
+ @api_key_store ||= APIKeyStore.new
89
+ end
90
+
91
+ def normalized_login_provider(provider)
92
+ value = provider.to_s.downcase
93
+ return "openai" if value.empty?
94
+ return "anthropic" if ["anthropic", "claude"].include?(value)
95
+ return "copilot" if ["github", "copilot"].include?(value)
96
+
97
+ ProviderCatalog.fetch(value).id
98
+ end
99
+
100
+ def api_key_login?(provider, auth_method)
101
+ return true if auth_method == :api_key || auth_method == "api_key"
102
+
103
+ ProviderCatalog.fetch(provider).api_key? && !["openai", "anthropic", "copilot"].include?(provider)
104
+ end
105
+
106
+ def login_with_api_key(provider_id)
107
+ provider = ProviderCatalog.fetch(provider_id)
108
+ raise "#{provider.name} does not accept an API key" unless provider.api_key?
109
+
110
+ api_key = @prompt.ask("#{provider.name} API key:").to_s.strip
111
+ provider_config = provider_config_after_api_key_login(provider)
112
+ models = refresh_provider_models(provider, api_key)
113
+ path = api_key_store.store(provider.id, api_key)
114
+ ConfigFiles.update_config(provider_config) unless provider_config.empty?
115
+ @prompt.say("#{colored("Saved", :green, :bold)} #{provider.name} API key to #{path}")
116
+ @prompt.say("Loaded #{models.length} #{provider.name} model#{models.length == 1 ? "" : "s"}.") if models
117
+ end
118
+
119
+ def refresh_provider_models(provider, api_key)
120
+ return unless ProviderCatalog.runtime(provider.id).automatic_model_discovery?
121
+
122
+ model_catalog(provider_id: provider.id, api_key: api_key).refresh
123
+ end
124
+
125
+ def model_catalog(provider_id:, api_key:)
126
+ ModelCatalog.new(provider_id: provider_id, api_key: api_key)
127
+ end
128
+
129
+ def provider_config_after_api_key_login(provider)
130
+ return {} unless provider.id == "azure_openai"
131
+
132
+ AzureOpenAIConfig.new(
133
+ endpoint: @prompt.ask("Azure OpenAI endpoint:"),
134
+ deployment: @prompt.ask("Azure OpenAI deployment name:"),
135
+ api_version: @prompt.ask("Azure OpenAI API version:")
136
+ ).to_config
137
+ end
138
+
80
139
  end
81
140
  end
82
141
  end
@@ -59,6 +59,7 @@ module Kward
59
59
  #{command.call("kward openrouter refresh")} Refresh cached OpenRouter models
60
60
  #{command.call("kward pan")} Start Pan mode web UI
61
61
  #{command.call("kward rpc")} Start the JSON-RPC backend
62
+ #{command.call("kward transport")} Manage transport plugins
62
63
 
63
64
  #{heading.call("Commands")}
64
65
  #{command.call("help")} Show this help
@@ -74,6 +75,7 @@ module Kward
74
75
  #{command.call("openrouter refresh|list")} Refresh or list cached OpenRouter models
75
76
  #{command.call("pan")} Start Pan mode web UI
76
77
  #{command.call("rpc")} Run the JSON-RPC backend for UI clients
78
+ #{command.call("transport list|status|run")} Manage transport plugins
77
79
 
78
80
  #{heading.call("Options")}
79
81
  #{option.call("--working-directory=PATH")} Run Kward from PATH
@@ -163,6 +165,11 @@ module Kward
163
165
  usage: "kward rpc",
164
166
  description: "Start the JSON-RPC backend for trusted local UI clients.",
165
167
  examples: ["kward rpc", "kward --working-directory ~/code/project rpc"]
168
+ },
169
+ "transport" => {
170
+ usage: "kward transport list|status [NAME]|run NAME [WORKSPACE]",
171
+ description: "Inspect or run trusted external transport plugins.",
172
+ examples: ["kward transport list", "kward transport status", "kward transport run telegram", "kward transport run telegram ~/code/project"]
166
173
  }
167
174
  }
168
175
  end
@@ -25,6 +25,7 @@ module Kward
25
25
  doctor_directory_check("Session directory", SessionStore.new(cwd: current_workspace_root).session_dir, create: true),
26
26
  doctor_workspace_check,
27
27
  doctor_model_check,
28
+ doctor_local_endpoint_check(config),
28
29
  doctor_auth_check(config),
29
30
  doctor_pan_check(config_result),
30
31
  { status: :ok, label: "Color", message: @color_enabled ? "enabled" : "disabled" }
@@ -89,14 +90,34 @@ module Kward
89
90
  { status: :warning, label: "Model", message: e.message }
90
91
  end
91
92
 
93
+ def doctor_local_endpoint_check(config)
94
+ provider = ENV["KWARD_PROVIDER"].to_s.strip
95
+ provider = config["provider"].to_s.strip if provider.empty?
96
+ return { status: :ok, label: "Local endpoint", message: "not selected" } unless provider.casecmp?("local")
97
+
98
+ backend = ENV["KWARD_LOCAL_BACKEND"].to_s.strip
99
+ backend = config["local_backend"].to_s.strip if backend.empty?
100
+ defaults = Kward::Client::LOCAL_BASE_URLS
101
+ url = ENV["KWARD_LOCAL_BASE_URL"].to_s.strip
102
+ url = config["local_base_url"].to_s.strip if url.empty?
103
+ url = defaults.fetch(backend.empty? ? "ollama" : backend, defaults.fetch("ollama")) if url.empty?
104
+ uri = URI.parse(url)
105
+ loopback = ["127.0.0.1", "::1", "localhost"].include?(uri.host)
106
+ { status: loopback ? :ok : :warning, label: "Local endpoint", message: "#{url}#{loopback ? " (loopback)" : " (non-loopback)"}" }
107
+ rescue URI::InvalidURIError
108
+ { status: :error, label: "Local endpoint", message: "invalid URL: #{url}" }
109
+ end
110
+
92
111
  def doctor_auth_check(config)
93
112
  openai_auth = OpenAIOAuth.default_auth_path
94
113
  github_auth = GithubOAuth.default_auth_path
95
114
  has_openrouter = !config.to_h["openrouter_api_key"].to_s.empty? || !ENV["OPENROUTER_API_KEY"].to_s.empty?
115
+ local_provider = @client.respond_to?(:current_provider) && @client.current_provider == "Local"
96
116
  paths = []
97
117
  paths << "OpenAI OAuth" if File.exist?(openai_auth)
98
118
  paths << "GitHub OAuth" if File.exist?(github_auth)
99
119
  paths << "OpenRouter API key" if has_openrouter
120
+ paths << "Local endpoint (no authentication required)" if local_provider
100
121
  return { status: :ok, label: "Auth", message: paths.join(", ") } if paths.any?
101
122
 
102
123
  { status: :warning, label: "Auth", message: "no saved credentials found; run `kward login`" }
data/lib/kward/cli/git.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require "open3"
2
+ require "pathname"
2
3
 
3
4
  # Namespace for the Kward CLI agent runtime.
4
5
  module Kward
@@ -131,20 +132,40 @@ module Kward
131
132
  { path: path, staged: status[0] != " " && status[0] != "?", untracked: status == "??" }
132
133
  end
133
134
 
134
- def git_commit(root, message)
135
- before = git_lifecycle_hook("git_commit_before", root: root, payload: { message: message.to_s })
136
- return { success: false, output: "Declined: #{before.decision.message || "git commit denied"}" } if before.denied? || before.approval_required?
135
+ def git_commit_for_agent(root, message:, paths: nil)
136
+ git_commit(root, message, paths: paths, stage_all: true, run_hooks: false)
137
+ end
138
+
139
+ def git_commit(root, message, paths: nil, stage_all: false, run_hooks: true)
140
+ if run_hooks
141
+ before = git_lifecycle_hook("git_commit_before", root: root, payload: { message: message.to_s })
142
+ return { success: false, output: "Declined: #{before.decision.message || "git commit denied"}" } if before.denied? || before.approval_required?
143
+ end
137
144
 
138
- return git_commit_staged(root, message) if git_staged_changes?(root)
145
+ return git_commit_staged(root, message, run_hooks: run_hooks) if !stage_all && paths.nil? && git_staged_changes?(root)
139
146
 
140
- add_output, add_status = Open3.capture2e("git", "add", "--all", chdir: root.to_s)
147
+ add_arguments = ["git", "add", "--all"]
148
+ add_arguments.concat(["--", *validated_git_commit_paths(root, paths)]) if paths
149
+ add_output, add_status = Open3.capture2e(*add_arguments, chdir: root.to_s)
141
150
  return { success: false, output: add_output } unless add_status.success?
142
151
 
143
- git_commit_staged(root, message)
152
+ git_commit_staged(root, message, run_hooks: run_hooks)
144
153
  rescue StandardError => e
145
154
  { success: false, output: e.message }
146
155
  end
147
156
 
157
+ def validated_git_commit_paths(root, paths)
158
+ Array(paths).map do |path|
159
+ value = path.to_s
160
+ expanded = File.expand_path(value, root.to_s)
161
+ unless !value.empty? && !Pathname.new(value).absolute? && (expanded == root.to_s || expanded.start_with?("#{root}/"))
162
+ raise ArgumentError, "Git commit path must be workspace-relative: #{value}"
163
+ end
164
+
165
+ value
166
+ end
167
+ end
168
+
148
169
  def git_staged_changes?(root)
149
170
  _output, status = Open3.capture2e("git", "diff", "--cached", "--quiet", chdir: root.to_s)
150
171
  !status.success?
@@ -152,10 +173,12 @@ module Kward
152
173
  false
153
174
  end
154
175
 
155
- def git_commit_staged(root, message)
176
+ def git_commit_staged(root, message, run_hooks: true)
156
177
  commit_output, commit_status = Open3.capture2e("git", "commit", "-m", message.to_s, chdir: root.to_s)
157
178
  result = { success: commit_status.success?, output: commit_output }
158
- git_lifecycle_hook("git_commit_after", root: root, payload: { message: message.to_s, success: result[:success], output: result[:output] })
179
+ if run_hooks
180
+ git_lifecycle_hook("git_commit_after", root: root, payload: { message: message.to_s, success: result[:success], output: result[:output] })
181
+ end
159
182
  result
160
183
  rescue StandardError => e
161
184
  { success: false, output: e.message }
@@ -31,6 +31,7 @@ module Kward
31
31
  end
32
32
 
33
33
  def reload_plugins(conversation)
34
+ @prompt_templates = nil
34
35
  @plugin_registry = PluginRegistry.load(reserved_commands: reserved_slash_command_names)
35
36
  @prompt.update_slash_commands(slash_command_entries) if @prompt.respond_to?(:update_slash_commands)
36
37
  conversation.plugin_registry = @plugin_registry if conversation.respond_to?(:plugin_registry=)
@@ -27,6 +27,7 @@ module Kward
27
27
  banner_message: Kward::PromptInterface::BANNER_MESSAGE,
28
28
  tab_keybindings: ConfigFiles.composer_tab_keybindings,
29
29
  prompt_history: PromptHistory.new(cwd: current_workspace_root),
30
+ workspace_root: current_workspace_root,
30
31
  editor_mode: ConfigFiles.editor_mode,
31
32
  editor_mode_source: -> { ConfigFiles.editor_mode },
32
33
  editor_auto_indent: ConfigFiles.editor_auto_indent?,
@@ -67,6 +68,12 @@ module Kward
67
68
  @prompt.respond_to?(:start_stream_block) && @prompt.respond_to?(:write_delta)
68
69
  end
69
70
 
71
+ def update_prompt_workspace_root(root)
72
+ return unless @prompt.respond_to?(:update_workspace_root)
73
+
74
+ @prompt.update_workspace_root(root, prompt_history: PromptHistory.new(cwd: root))
75
+ end
76
+
70
77
  # Writes the startup info screen output for the terminal CLI flow.
71
78
  def print_visual_banner
72
79
  return unless @prompt.respond_to?(:print_visual_banner)
@@ -272,8 +272,12 @@ module Kward
272
272
  # Writes the block delta output for the terminal CLI flow.
273
273
  def print_block_delta(label, delta)
274
274
  if prompt_interface?
275
- @prompt.start_stream_block(label)
276
- @prompt.write_delta(delta)
275
+ if @prompt.respond_to?(:write_stream_block)
276
+ @prompt.write_stream_block(label, delta, finish: false)
277
+ else
278
+ @prompt.start_stream_block(label)
279
+ @prompt.write_delta(delta)
280
+ end
277
281
  else
278
282
  start_stream_block(label)
279
283
  print delta
@@ -317,8 +317,8 @@ module Kward
317
317
  @pending_inputs&.first.is_a?(Hash) && @pending_inputs.first[:tab_action]
318
318
  end
319
319
 
320
- def configured_workspace(root: current_workspace_root)
321
- Workspace.new(root: root, guardrails: workspace_guardrails_enabled?)
320
+ def configured_workspace(root: current_workspace_root, strict: false)
321
+ WorkspaceFactory.build(root: root, guardrails: workspace_guardrails_enabled?, config: safely_read_config.to_h, strict: strict)
322
322
  end
323
323
 
324
324
  def workspace_guardrails_enabled?
@@ -401,6 +401,8 @@ module Kward
401
401
  end
402
402
 
403
403
  def current_workspace_root
404
+ conversation = active_tab&.agent&.conversation if respond_to?(:active_tab, true)
405
+ return conversation.workspace_root if conversation&.respond_to?(:workspace_root)
404
406
  return @active_session.cwd.to_s unless @active_session&.cwd.to_s.empty?
405
407
  return @working_directory if @working_directory
406
408
 
@@ -103,7 +103,7 @@ module Kward
103
103
  @active_session = track_session(session_store.create)
104
104
  reset_session_diff
105
105
  cleanup_replaced_session(previous_session)
106
- conversation = new_conversation(workspace_root: session_store.cwd)
106
+ conversation = new_conversation(workspace_root: current_workspace_root)
107
107
  @active_session.attach(conversation)
108
108
  run_lifecycle_hook("session_create", conversation: conversation, payload: { action: "create" })
109
109
  update_assistant_prompt(conversation)
@@ -88,7 +88,7 @@ module Kward
88
88
  def configure_model_settings(conversation)
89
89
  initial_index = 0
90
90
  loop do
91
- selected, selected_index = select_settings_menu_item("Model & Reasoning", ["Provider", "Default model", "Reasoning effort", "Back"], initial_index)
91
+ selected, selected_index = select_settings_menu_item("Model & Reasoning", ["Provider", "Default model", "Local server preset", "Reasoning effort", "Back"], initial_index)
92
92
  break unless selected
93
93
 
94
94
  initial_index = selected_index if selected_index
@@ -97,6 +97,8 @@ module Kward
97
97
  configure_provider(conversation)
98
98
  when /\Adefault model/
99
99
  configure_model(conversation)
100
+ when /\Alocal server preset/
101
+ configure_local_server_preset(conversation)
100
102
  when /\Areasoning effort/
101
103
  configure_reasoning(conversation)
102
104
  else
@@ -118,7 +120,10 @@ module Kward
118
120
 
119
121
  def provider_choices
120
122
  current = current_model_provider
121
- ["Codex", "Anthropic", "OpenRouter", "Copilot"].map do |provider|
123
+ configured_api_key_providers = ProviderCatalog.api_key_providers.filter_map do |provider|
124
+ provider.name if api_key_store.configured?(provider.id)
125
+ end
126
+ (["Codex", "Anthropic", "OpenRouter", "Copilot", "Local"] + configured_api_key_providers).uniq.map do |provider|
122
127
  label = provider.dup
123
128
  label += " (current)" if provider == current
124
129
  label
@@ -131,8 +136,37 @@ module Kward
131
136
  return "Anthropic" if text.start_with?("anthropic") || text.start_with?("claude")
132
137
  return "OpenRouter" if text.start_with?("openrouter")
133
138
  return "Copilot" if text.start_with?("copilot")
139
+ return "Local" if text.start_with?("local")
134
140
 
135
- nil
141
+ ProviderCatalog.find_by_name(selected)&.name
142
+ end
143
+
144
+ def configure_local_server_preset(conversation)
145
+ choices = [
146
+ "Ollama (http://127.0.0.1:11434/v1)",
147
+ "LM Studio (http://127.0.0.1:1234/v1)",
148
+ "llama.cpp (http://127.0.0.1:8080/v1)",
149
+ "Custom endpoint (edit config)",
150
+ "Back"
151
+ ]
152
+ selected = @prompt.select("Local server preset", choices, title: "Settings")
153
+ return unless selected
154
+
155
+ backend, url = case selected.to_s
156
+ when /\AOllama/ then ["ollama", "http://127.0.0.1:11434/v1"]
157
+ when /\ALM Studio/ then ["lm_studio", "http://127.0.0.1:1234/v1"]
158
+ when /\Allama\.cpp/ then ["llama_cpp", "http://127.0.0.1:8080/v1"]
159
+ when /\ACustom endpoint/
160
+ runtime_output("Set local_base_url in #{ConfigFiles.config_path}.")
161
+ return
162
+ else
163
+ return
164
+ end
165
+
166
+ ConfigFiles.update_config("local_backend" => backend, "local_base_url" => url)
167
+ reload_client_config
168
+ refresh_conversation_runtime(conversation)
169
+ @prompt.redraw if @prompt.respond_to?(:redraw)
136
170
  end
137
171
 
138
172
  def configure_account_settings
@@ -524,6 +558,10 @@ module Kward
524
558
  case selected.to_s.downcase
525
559
  when /\Adefault persona/
526
560
  configure_default_persona(conversation)
561
+ when /\Aenable global principles/, /\Adisable global principles/
562
+ toggle_global_principles(conversation)
563
+ when /\Aglobal principles \(ignored/
564
+ runtime_output("Global principles are ignored while a replacement system prompt is configured.")
527
565
  when /\Aactive instructions/
528
566
  show_active_instructions_summary(conversation)
529
567
  else
@@ -533,13 +571,31 @@ module Kward
533
571
  end
534
572
 
535
573
  def personalization_setting_choices(conversation)
574
+ config = safely_read_config.to_h
575
+ replacement = ConfigFiles.system_prompt_file_path(config)
576
+ principles = if replacement
577
+ "Global principles (ignored by replacement)"
578
+ elsif ConfigFiles.include_config_principles?(config)
579
+ "Disable global principles"
580
+ else
581
+ "Enable global principles"
582
+ end
536
583
  [
537
584
  "Default persona (#{default_persona_label})",
585
+ principles,
538
586
  "Active instructions summary",
539
587
  "Back"
540
588
  ]
541
589
  end
542
590
 
591
+ def toggle_global_principles(conversation)
592
+ config = safely_read_config.to_h
593
+ settings = config["system_prompt"].is_a?(Hash) ? config["system_prompt"] : {}
594
+ ConfigFiles.update_config("system_prompt" => settings.merge("include_principles" => !ConfigFiles.include_config_principles?(config)))
595
+ conversation&.refresh_system_message!
596
+ @prompt.redraw if @prompt.respond_to?(:redraw)
597
+ end
598
+
543
599
  def default_persona_label
544
600
  personas = safely_read_config.to_h["personas"]
545
601
  value = personas.is_a?(Hash) ? personas["default"] : nil
@@ -569,9 +625,18 @@ module Kward
569
625
 
570
626
  def show_active_instructions_summary(conversation)
571
627
  label = ConfigFiles.active_persona_label(workspace_root: current_workspace_root, model: current_model_id, config: safely_read_config.to_h)
628
+ config = safely_read_config.to_h
629
+ replacement_path = ConfigFiles.system_prompt_file_path(config)
572
630
  lines = ["Active persona: #{label || "none"}"]
573
- lines << "Global PRINCIPLES.md: #{ConfigFiles.agents_prompt ? "present" : "absent"}"
574
- lines << "Workspace AGENTS.md: #{ConfigFiles.workspace_agents_prompt(current_workspace_root) ? "present" : "absent"}"
631
+ if replacement_path
632
+ lines << "System prompt: replacement (#{replacement_path})"
633
+ lines << "Global PRINCIPLES.md: ignored by replacement"
634
+ lines << "Workspace AGENTS.md: ignored by replacement"
635
+ else
636
+ lines << "System prompt: Kward default"
637
+ lines << "Global PRINCIPLES.md: #{ConfigFiles.include_config_principles?(config) ? (ConfigFiles.agents_prompt ? "present" : "absent") : "disabled"}"
638
+ lines << "Workspace AGENTS.md: #{ConfigFiles.workspace_agents_prompt(current_workspace_root) ? "present" : "absent"}"
639
+ end
575
640
  lines << "Messages: #{conversation.messages.length}" if conversation&.respond_to?(:messages)
576
641
  runtime_output(lines.join("\n"))
577
642
  end
@@ -659,12 +724,15 @@ module Kward
659
724
  return
660
725
  end
661
726
 
662
- selected = @prompt.select("OAuth provider", login_provider_choices, title: "Login")
727
+ method = selected_login_method(@prompt.select("How would you like to connect?", login_method_choices, title: "Login"))
728
+ return unless method
729
+
730
+ selected = @prompt.select(login_provider_prompt(method), login_provider_choices(method), title: "Login")
663
731
  provider = selected_login_provider(selected)
664
732
  return unless provider
665
733
 
666
734
  run_busy_local_command_and_requeue(activity: "running") do
667
- login(provider: provider)
735
+ login(provider: provider, auth_method: method)
668
736
  reload_client_config
669
737
  end
670
738
  rescue StandardError => e
@@ -678,17 +746,40 @@ module Kward
678
746
  end
679
747
 
680
748
  models ||= normalized_available_models(conversation)
681
- choices = model_choices(models, conversation)
682
- selected = @prompt.select("Default model", choices, title: "Models", custom: true)
683
- return unless selected
684
-
685
- provider, model = selected_model(selected, models)
686
- raise "Model must be a non-empty string" if model.to_s.strip.empty?
687
-
688
- ConfigFiles.update_config(ModelInfo.config_values_for_selection(provider, model))
689
- reload_client_config
690
- refresh_conversation_runtime(conversation)
691
- @prompt.redraw if @prompt.respond_to?(:redraw)
749
+ provider_filter = nil
750
+ show_all = false
751
+ loop do
752
+ visible_models = provider_filter ? models.select { |model| model[:provider] == provider_filter } : models
753
+ visible_models = visible_models.select { |model| default_selectable_model?(model) } unless show_all
754
+ actions = ["Refresh model list", "Enter model ID manually", "Show all models", "Change provider"]
755
+ selected = @prompt.select("Default model", model_choices(visible_models, conversation) + actions, title: "Models", custom: true)
756
+ return unless selected
757
+
758
+ case selected.to_s
759
+ when "Refresh model list"
760
+ refreshed = @client.refresh_available_models(provider: provider_filter) if @client.respond_to?(:refresh_available_models)
761
+ models = normalized_available_models(conversation, models: refreshed)
762
+ when "Enter model ID manually"
763
+ provider = provider_filter || conversation&.provider || current_model_provider
764
+ model = @prompt.select("Model ID for #{provider}", [], title: "Models", custom: true)
765
+ return unless model
766
+ persist_model_selection(provider, model, conversation)
767
+ return
768
+ when "Show all models"
769
+ provider_filter = nil
770
+ show_all = true
771
+ when "Change provider"
772
+ selected_provider_name = selected_provider(@prompt.select("Provider", provider_choices, title: "Models"))
773
+ if selected_provider_name
774
+ provider_filter = selected_provider_name
775
+ show_all = false
776
+ end
777
+ else
778
+ provider, model = selected_model(selected, models)
779
+ persist_model_selection(provider, model, conversation)
780
+ return
781
+ end
782
+ end
692
783
  rescue StandardError => e
693
784
  runtime_output("Model error: #{e.message}")
694
785
  end
@@ -722,23 +813,38 @@ module Kward
722
813
  @prompt.respond_to?(:select)
723
814
  end
724
815
 
725
- def login_provider_choices
726
- ["OpenAI", "Anthropic", "OpenRouter", "GitHub"]
816
+ def login_method_choices
817
+ ["API key", "Subscription / OAuth"]
727
818
  end
728
819
 
729
- def selected_login_provider(selected)
820
+ def selected_login_method(selected)
730
821
  case selected.to_s.downcase
731
- when /\Aopenai\b/
732
- "openai"
733
- when /\Aanthropic\b/, /\Aclaude\b/
734
- "anthropic"
735
- when /\Aopenrouter\b/
736
- "openrouter"
737
- when /\Agithub\b/
738
- "github"
822
+ when /\Aapi key\z/ then :api_key
823
+ when /\Asubscription \/ oauth\z/ then :oauth
739
824
  end
740
825
  end
741
826
 
827
+ def login_provider_prompt(method)
828
+ method == :api_key ? "Add an API key" : "Sign in with a subscription"
829
+ end
830
+
831
+ def login_provider_choices(method)
832
+ if method == :api_key
833
+ ProviderCatalog.api_key_providers.map(&:name)
834
+ else
835
+ ["Anthropic Claude", "ChatGPT", "GitHub Copilot"]
836
+ end
837
+ end
838
+
839
+ def selected_login_provider(selected)
840
+ value = selected.to_s.downcase
841
+ return "openai" if value == "openai" || value == "chatgpt"
842
+ return "anthropic" if value == "anthropic" || value == "anthropic claude"
843
+ return "copilot" if value == "github copilot"
844
+
845
+ ProviderCatalog.find_by_name(selected)&.id
846
+ end
847
+
742
848
  def model_overlay_available?
743
849
  @prompt.respond_to?(:select)
744
850
  end
@@ -760,11 +866,11 @@ module Kward
760
866
  values.find { |value| choice.to_s.downcase.start_with?(value) }
761
867
  end
762
868
 
763
- def normalized_available_models(conversation = current_footer_conversation)
869
+ def normalized_available_models(conversation = current_footer_conversation, models: nil)
764
870
  current_provider = conversation.provider || (@client.respond_to?(:current_provider) ? @client.current_provider : "Codex")
765
871
  current_model = conversation.model || (@client.respond_to?(:current_model) ? @client.current_model : nil)
766
872
  current_reasoning = conversation.reasoning_effort || (@client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : nil)
767
- models = @client.respond_to?(:available_models) ? @client.available_models : []
873
+ models ||= @client.respond_to?(:available_models) ? @client.available_models : []
768
874
  ModelInfo.normalize_available(
769
875
  models,
770
876
  current_provider: current_provider,
@@ -784,17 +890,37 @@ module Kward
784
890
  choices.empty? ? ["#{current_provider} #{current_model} (current)"] : choices.uniq
785
891
  end
786
892
 
893
+ def default_selectable_model?(model)
894
+ return true if model[:current]
895
+
896
+ capabilities = Array(model[:supportedParameters]).map { |value| value.to_s.downcase }
897
+ return true if capabilities.empty?
898
+
899
+ capabilities.any? do |capability|
900
+ ["tools", "toolchoice", "functioncalling", "generatecontent", "streamgeneratecontent"].include?(capability.delete("_-"))
901
+ end
902
+ end
903
+
787
904
  def selected_model(selected, models)
788
905
  text = selected.to_s.sub(/ \(current\)\z/, "").strip
789
906
  known = models.find { |model| "#{model[:provider]} #{model[:id]}" == text }
790
907
  return [known[:provider], known[:id]] if known
791
908
 
792
909
  provider, model = text.split(/\s+/, 2)
793
- if ["Codex", "Anthropic", "OpenRouter", "Copilot"].include?(provider) && !model.to_s.strip.empty?
794
- [provider, model.strip]
795
- else
796
- [current_model_provider, text]
797
- end
910
+ known_provider = ["Codex", "Anthropic", "OpenRouter", "Copilot", "Local"].include?(provider) || ProviderCatalog.find_by_name(provider)
911
+ return [provider, model.strip] if known_provider && !model.to_s.strip.empty?
912
+
913
+ [current_model_provider, text]
914
+ end
915
+
916
+ def persist_model_selection(provider, model, conversation)
917
+ model = model.to_s.strip
918
+ raise "Model must be a non-empty string" if model.empty?
919
+
920
+ ConfigFiles.update_config(ModelInfo.config_values_for_selection(provider, model))
921
+ reload_client_config
922
+ refresh_conversation_runtime(conversation)
923
+ @prompt.redraw if @prompt.respond_to?(:redraw)
798
924
  end
799
925
 
800
926
  REASONING_CONFIG_DEBOUNCE_SECONDS = 0.5