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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
59
|
-
if provider
|
|
60
|
-
|
|
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"
|
|
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"
|
|
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
|
data/lib/kward/cli/commands.rb
CHANGED
|
@@ -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
|
data/lib/kward/cli/doctor.rb
CHANGED
|
@@ -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
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }
|
data/lib/kward/cli/plugins.rb
CHANGED
|
@@ -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)
|
data/lib/kward/cli/rendering.rb
CHANGED
|
@@ -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.
|
|
276
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/kward/cli/sessions.rb
CHANGED
|
@@ -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:
|
|
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)
|
data/lib/kward/cli/settings.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
574
|
-
|
|
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
|
-
|
|
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
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
|
726
|
-
["
|
|
816
|
+
def login_method_choices
|
|
817
|
+
["API key", "Subscription / OAuth"]
|
|
727
818
|
end
|
|
728
819
|
|
|
729
|
-
def
|
|
820
|
+
def selected_login_method(selected)
|
|
730
821
|
case selected.to_s.downcase
|
|
731
|
-
when /\
|
|
732
|
-
|
|
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
|
|
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
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|