kward 0.79.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 +6 -0
- data/CHANGELOG.md +58 -1
- data/Gemfile.lock +2 -2
- data/README.md +31 -38
- data/doc/agent-tools.md +9 -10
- 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 +69 -26
- data/doc/context-budgeting.md +6 -6
- data/doc/context-tools.md +2 -2
- data/doc/editor.md +7 -7
- data/doc/extensibility.md +7 -29
- data/doc/files.md +6 -6
- data/doc/getting-started.md +2 -2
- data/doc/git.md +8 -2
- data/doc/lifecycle-hooks.md +5 -4
- data/doc/local-models.md +2 -2
- data/doc/mcp.md +5 -5
- data/doc/permissions.md +13 -4
- data/doc/plugins.md +39 -4
- data/doc/prompt-templates.md +105 -0
- data/doc/providers.md +140 -0
- data/doc/releasing.md +10 -2
- data/doc/rpc.md +54 -31
- data/doc/sandboxing.md +22 -0
- data/doc/security.md +22 -7
- data/doc/session-management.md +2 -4
- 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 +2 -2
- 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/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/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 +97 -33
- 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 +20 -0
- data/lib/kward/config_files.rb +26 -3
- data/lib/kward/conversation.rb +13 -7
- data/lib/kward/git_worktree_manager.rb +291 -0
- data/lib/kward/model/azure_openai_config.rb +54 -0
- data/lib/kward/model/catalog.rb +113 -0
- data/lib/kward/model/client.rb +225 -20
- data/lib/kward/model/model_info.rb +49 -0
- data/lib/kward/model/payloads.rb +63 -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 +83 -13
- data/lib/kward/permissions/policy.rb +18 -4
- data/lib/kward/plugin_chat_runtime.rb +374 -0
- data/lib/kward/plugin_registry.rb +80 -7
- 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 +7 -7
- 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 +53 -12
- data/lib/kward/prompts/commands.rb +1 -1
- data/lib/kward/prompts.rb +6 -5
- 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 +55 -196
- data/lib/kward/rpc/prompt_bridge.rb +8 -4
- data/lib/kward/rpc/redactor.rb +1 -1
- data/lib/kward/rpc/server.rb +60 -10
- data/lib/kward/rpc/session_manager.rb +109 -30
- data/lib/kward/session_store.rb +84 -24
- data/lib/kward/tab_driver.rb +6 -3
- data/lib/kward/tools/git_commit.rb +39 -0
- data/lib/kward/tools/registry.rb +7 -2
- 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_factory.rb +19 -2
- data/templates/default/fulldoc/html/js/kward.js +2 -0
- data/templates/default/kward_navigation.rb +7 -2
- data/templates/default/layout/html/setup.rb +4 -0
- metadata +26 -1
|
@@ -4,10 +4,14 @@ module Kward
|
|
|
4
4
|
class PromptInterface
|
|
5
5
|
# Bounded in-memory transcript buffer used by the prompt interface.
|
|
6
6
|
class TranscriptBuffer
|
|
7
|
+
TRIM_RATIO = 0.9
|
|
8
|
+
TRIM_BOUNDARY_SEARCH = 1_024
|
|
9
|
+
|
|
7
10
|
attr_reader :text
|
|
8
11
|
|
|
9
12
|
def initialize(limit:)
|
|
10
|
-
@limit = limit
|
|
13
|
+
@limit = [limit.to_i, 1].max
|
|
14
|
+
@trim_target = [(@limit * TRIM_RATIO).floor, 1].max
|
|
11
15
|
@text = +""
|
|
12
16
|
@display_rows_cache_width = nil
|
|
13
17
|
@display_rows_cache = nil
|
|
@@ -42,7 +46,7 @@ module Kward
|
|
|
42
46
|
|
|
43
47
|
def append(text)
|
|
44
48
|
@text << ANSI.sanitize_transcript(text)
|
|
45
|
-
|
|
49
|
+
trim_to_limit if @text.length > @limit
|
|
46
50
|
invalidate_display_rows_cache
|
|
47
51
|
@text
|
|
48
52
|
end
|
|
@@ -77,6 +81,24 @@ module Kward
|
|
|
77
81
|
@display_rows_cache_width = nil
|
|
78
82
|
@display_rows_cache = nil
|
|
79
83
|
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def trim_to_limit
|
|
88
|
+
cutoff = @text.length - @trim_target
|
|
89
|
+
newline = @text.index("\n", cutoff)
|
|
90
|
+
cutoff = newline + 1 if newline && newline - cutoff <= TRIM_BOUNDARY_SEARCH
|
|
91
|
+
cutoff = safe_escape_boundary(cutoff)
|
|
92
|
+
@text = @text[cutoff..].to_s
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def safe_escape_boundary(cutoff)
|
|
96
|
+
escape_start = @text.rindex("\e", cutoff)
|
|
97
|
+
return cutoff unless escape_start
|
|
98
|
+
|
|
99
|
+
escape = ANSI.escape_sequence_at(@text, escape_start)
|
|
100
|
+
escape && escape_start + escape.length > cutoff ? escape_start + escape.length : cutoff
|
|
101
|
+
end
|
|
80
102
|
end
|
|
81
103
|
end
|
|
82
104
|
end
|
|
@@ -128,12 +128,13 @@ module Kward
|
|
|
128
128
|
end
|
|
129
129
|
end
|
|
130
130
|
|
|
131
|
-
def initialize(input: $stdin, output: $stdout, slash_commands: [], overlay_settings: nil, project_browser_icon_theme: "off", footer: nil, composer_status: nil, busy_help: true, attachment_badges: nil, attachment_parser: nil, banner_message: nil, tab_keybindings: nil, prompt_history: nil, editor_mode: nil, editor_mode_source: nil, editor_auto_indent: true, editor_auto_indent_source: nil, editor_auto_close_pairs: true, editor_auto_close_pairs_source: nil, editor_soft_wrap: true, editor_soft_wrap_source: nil, editor_bar_cursor: true, editor_bar_cursor_source: nil, editor_line_numbers: "absolute", editor_line_numbers_source: nil, diff_view: "auto", diff_view_source: nil)
|
|
131
|
+
def initialize(input: $stdin, output: $stdout, slash_commands: [], overlay_settings: nil, project_browser_icon_theme: "off", footer: nil, composer_status: nil, busy_help: true, attachment_badges: nil, attachment_parser: nil, banner_message: nil, tab_keybindings: nil, prompt_history: nil, workspace_root: Dir.pwd, editor_mode: nil, editor_mode_source: nil, editor_auto_indent: true, editor_auto_indent_source: nil, editor_auto_close_pairs: true, editor_auto_close_pairs_source: nil, editor_soft_wrap: true, editor_soft_wrap_source: nil, editor_bar_cursor: true, editor_bar_cursor_source: nil, editor_line_numbers: "absolute", editor_line_numbers_source: nil, diff_view: "auto", diff_view_source: nil)
|
|
132
132
|
@input_io = input
|
|
133
133
|
@output_io = output
|
|
134
134
|
@reader = TTY::Reader.new(input: input, output: output, interrupt: :error)
|
|
135
135
|
@mutex = Mutex.new
|
|
136
136
|
@prompt_history = prompt_history
|
|
137
|
+
@workspace_root = File.expand_path(workspace_root.to_s.empty? ? Dir.pwd : workspace_root)
|
|
137
138
|
@composer = ComposerState.new
|
|
138
139
|
load_history(@prompt_history.values) if @prompt_history
|
|
139
140
|
self.composer_input = @composer.input
|
|
@@ -146,7 +147,8 @@ module Kward
|
|
|
146
147
|
@steered_count = 0
|
|
147
148
|
@spinner_frame_index = 0
|
|
148
149
|
@last_spinner_tick = monotonic_now
|
|
149
|
-
@last_footer_refresh =
|
|
150
|
+
@last_footer_refresh = nil
|
|
151
|
+
@cached_footer_text = nil
|
|
150
152
|
@last_composer_status_refresh = 0.0
|
|
151
153
|
@cached_composer_status_text = nil
|
|
152
154
|
@prompt_label = "You>"
|
|
@@ -340,10 +342,23 @@ module Kward
|
|
|
340
342
|
@mutex.synchronize { editor_active? }
|
|
341
343
|
end
|
|
342
344
|
|
|
343
|
-
def
|
|
345
|
+
def update_workspace_root(root, prompt_history: nil)
|
|
346
|
+
@mutex.synchronize do
|
|
347
|
+
expanded_root = File.expand_path(root.to_s.empty? ? Dir.pwd : root)
|
|
348
|
+
return false if expanded_root == @workspace_root
|
|
349
|
+
|
|
350
|
+
@workspace_root = expanded_root
|
|
351
|
+
@prompt_history = prompt_history if prompt_history
|
|
352
|
+
load_history(@prompt_history.values) if @prompt_history
|
|
353
|
+
reset_workspace_file_state_locked
|
|
354
|
+
true
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def edit_file(path, base_dir: nil, allow_new: true)
|
|
344
359
|
start(render: false)
|
|
345
360
|
opened = @mutex.synchronize do
|
|
346
|
-
open_editor(path, allow_new: allow_new, base_dir: base_dir, restrict_to_workspace: false).tap do
|
|
361
|
+
open_editor(path, allow_new: allow_new, base_dir: base_dir || prompt_workspace_root, restrict_to_workspace: false).tap do
|
|
347
362
|
render_prompt_locked
|
|
348
363
|
end
|
|
349
364
|
end
|
|
@@ -610,9 +625,14 @@ module Kward
|
|
|
610
625
|
|
|
611
626
|
def update_tabs(labels:, active_index: 0)
|
|
612
627
|
@mutex.synchronize do
|
|
613
|
-
|
|
614
|
-
|
|
628
|
+
normalized_labels = Array(labels).map { |label| normalize_tab_label(label) }
|
|
629
|
+
normalized_index = active_index.to_i
|
|
630
|
+
return false if @tabs == normalized_labels && @active_tab_index == normalized_index
|
|
631
|
+
|
|
632
|
+
@tabs = normalized_labels
|
|
633
|
+
@active_tab_index = normalized_index
|
|
615
634
|
render_prompt_locked if @started && @asking
|
|
635
|
+
true
|
|
616
636
|
end
|
|
617
637
|
end
|
|
618
638
|
|
|
@@ -625,16 +645,19 @@ module Kward
|
|
|
625
645
|
end
|
|
626
646
|
end
|
|
627
647
|
|
|
628
|
-
def tab_view_snapshot
|
|
648
|
+
def tab_view_snapshot(include_transcript: true)
|
|
629
649
|
@mutex.synchronize do
|
|
630
|
-
{
|
|
650
|
+
snapshot = {
|
|
631
651
|
composer: @composer.dup,
|
|
632
652
|
prompt_label: @prompt_label.dup,
|
|
633
|
-
editor_state: @editor_state&.dup
|
|
634
|
-
transcript_buffer: @transcript_buffer.dup,
|
|
635
|
-
transcript_viewport_rows: @transcript_viewport_rows,
|
|
636
|
-
stream_state: @stream_state.dup
|
|
653
|
+
editor_state: @editor_state&.dup
|
|
637
654
|
}
|
|
655
|
+
if include_transcript
|
|
656
|
+
snapshot[:transcript_buffer] = @transcript_buffer.dup
|
|
657
|
+
snapshot[:transcript_viewport_rows] = @transcript_viewport_rows
|
|
658
|
+
snapshot[:stream_state] = @stream_state.dup
|
|
659
|
+
end
|
|
660
|
+
snapshot
|
|
638
661
|
end
|
|
639
662
|
end
|
|
640
663
|
|
|
@@ -859,6 +882,8 @@ module Kward
|
|
|
859
882
|
|
|
860
883
|
def refresh_composer_status
|
|
861
884
|
@mutex.synchronize do
|
|
885
|
+
@cached_footer_text = nil
|
|
886
|
+
@last_footer_refresh = nil
|
|
862
887
|
@cached_composer_status_text = nil
|
|
863
888
|
@last_composer_status_refresh = 0.0
|
|
864
889
|
render_prompt_locked if @started && @asking
|
|
@@ -879,6 +904,22 @@ module Kward
|
|
|
879
904
|
|
|
880
905
|
private
|
|
881
906
|
|
|
907
|
+
def prompt_workspace_root
|
|
908
|
+
@workspace_root
|
|
909
|
+
end
|
|
910
|
+
|
|
911
|
+
def reset_workspace_file_state_locked
|
|
912
|
+
@file_mention_paths = nil
|
|
913
|
+
@file_mention_path_entries_paths = nil
|
|
914
|
+
@file_mention_path_entries = nil
|
|
915
|
+
@project_browser_state = nil
|
|
916
|
+
@project_browser_tree_paths = nil
|
|
917
|
+
@project_browser_tree = nil
|
|
918
|
+
@file_overlay_dismissed_token = nil
|
|
919
|
+
@file_open_dismissed_token = nil
|
|
920
|
+
@file_editor_open_status = nil
|
|
921
|
+
end
|
|
922
|
+
|
|
882
923
|
def modal_active_locked?
|
|
883
924
|
@question_prompt_active || !@question_state.nil? || !@select_state.nil? || !@git_state.nil?
|
|
884
925
|
end
|
|
@@ -30,7 +30,7 @@ module Kward
|
|
|
30
30
|
{ name: "shell", description: "Open the embedded Kward shell.", argument_hint: "" },
|
|
31
31
|
{ name: "scratchpad", description: "Open an unsaved editor buffer.", argument_hint: "[text|markdown|ruby]" },
|
|
32
32
|
{ name: "pty", description: "Run a command in an interactive PTY passthrough session.", argument_hint: "<command>" },
|
|
33
|
-
{ name: "tab", description: "Manage tabs.", argument_hint: "[1-n|move|close|new|name]" },
|
|
33
|
+
{ name: "tab", description: "Manage tabs.", argument_hint: "[1-n|move|close|new|name|worktree]" },
|
|
34
34
|
{ name: "status", description: "Show the current status message.", argument_hint: "" },
|
|
35
35
|
{ name: "stats", description: "Show telemetry logging stats.", argument_hint: "[range]" },
|
|
36
36
|
{ name: "sandbox", description: "Inspect or configure model command sandboxing.", argument_hint: "[status|off|read_only|workspace_write|network allow|network deny]" },
|
data/lib/kward/prompts.rb
CHANGED
|
@@ -22,25 +22,26 @@ module Kward
|
|
|
22
22
|
# @param plugin_context [String, nil] trusted plugin-provided instructions
|
|
23
23
|
# @return [Hash] role/content system message
|
|
24
24
|
# @api public
|
|
25
|
-
def system_message(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil)
|
|
25
|
+
def system_message(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil, execution_profile_context: nil)
|
|
26
26
|
{
|
|
27
27
|
role: "system",
|
|
28
|
-
content: prompt_parts(workspace_root: workspace_root, include_workspace_personality: include_workspace_personality, model: model, reasoning_effort: reasoning_effort, now: now, memory_context: memory_context, plugin_context: plugin_context).compact.join("\n\n")
|
|
28
|
+
content: prompt_parts(workspace_root: workspace_root, include_workspace_personality: include_workspace_personality, model: model, reasoning_effort: reasoning_effort, now: now, memory_context: memory_context, plugin_context: plugin_context, execution_profile_context: execution_profile_context).compact.join("\n\n")
|
|
29
29
|
}
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
def prompt_parts(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil)
|
|
33
|
-
prompt_sections(workspace_root: workspace_root, include_workspace_personality: include_workspace_personality, model: model, reasoning_effort: reasoning_effort, now: now, memory_context: memory_context, plugin_context: plugin_context).map { |section| section[:content] }
|
|
32
|
+
def prompt_parts(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil, execution_profile_context: nil)
|
|
33
|
+
prompt_sections(workspace_root: workspace_root, include_workspace_personality: include_workspace_personality, model: model, reasoning_effort: reasoning_effort, now: now, memory_context: memory_context, plugin_context: plugin_context, execution_profile_context: execution_profile_context).map { |section| section[:content] }
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
# Returns labeled prompt sections for inspection by CLI and RPC frontends.
|
|
37
37
|
#
|
|
38
38
|
# @return [Array<Hash>] section hashes with label, content, and optional source
|
|
39
39
|
# @api public
|
|
40
|
-
def prompt_sections(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil)
|
|
40
|
+
def prompt_sections(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil, execution_profile_context: nil)
|
|
41
41
|
return replacement_prompt_sections if ConfigFiles.replacement_system_prompt?
|
|
42
42
|
|
|
43
43
|
sections = [prompt_section("Built-in system prompt", base_prompt)]
|
|
44
|
+
sections << prompt_section("Execution profile", execution_profile_context) unless execution_profile_context.to_s.empty?
|
|
44
45
|
sections << prompt_section(config_agents_prompt_label, config_agents_prompt, source: config_agents_prompt_source)
|
|
45
46
|
sections << prompt_section("Memory context", memory_context) unless memory_context.to_s.empty?
|
|
46
47
|
if include_workspace_personality
|
|
@@ -4,6 +4,7 @@ require_relative "../auth/anthropic_oauth"
|
|
|
4
4
|
require_relative "../auth/github_oauth"
|
|
5
5
|
require_relative "../auth/openai_oauth"
|
|
6
6
|
require_relative "../model/client"
|
|
7
|
+
require_relative "../model/provider_catalog"
|
|
7
8
|
require_relative "config_manager"
|
|
8
9
|
|
|
9
10
|
# Namespace for the Kward CLI agent runtime.
|
|
@@ -25,45 +26,44 @@ module Kward
|
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
def status
|
|
28
|
-
|
|
29
|
-
config = stored_config
|
|
29
|
+
provider_status = providers.fetch(:providers)
|
|
30
30
|
{
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
openrouterApiKey:
|
|
31
|
+
providers: provider_status,
|
|
32
|
+
openaiOAuth: oauth_status("openai")[:configured],
|
|
33
|
+
openrouterApiKey: @config_manager.api_key_status("openrouter")[:configured],
|
|
34
34
|
openaiAccessToken: !ENV["OPENAI_ACCESS_TOKEN"].to_s.empty?,
|
|
35
|
-
anthropicOAuth:
|
|
36
|
-
githubOAuth:
|
|
35
|
+
anthropicOAuth: oauth_status("anthropic")[:configured],
|
|
36
|
+
githubOAuth: oauth_status("copilot")[:configured]
|
|
37
37
|
}
|
|
38
38
|
rescue StandardError => e
|
|
39
|
-
{ openaiOAuth: false, error: e.message }
|
|
39
|
+
{ providers: [], openaiOAuth: false, error: e.message }
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
def providers
|
|
43
|
-
{ providers:
|
|
43
|
+
{ providers: ProviderCatalog.all.map { |provider| provider_payload(provider) } }
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
def login_with_api_key(provider_id:, api_key:)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
def login_with_api_key(provider_id:, api_key:, configuration: nil)
|
|
47
|
+
raise ArgumentError, "API key must be a non-empty string" if api_key.to_s.strip.empty?
|
|
48
|
+
|
|
49
|
+
provider = ProviderCatalog.fetch(provider_id)
|
|
50
|
+
@config_manager.configure_azure_openai(configuration || {}) if provider.id == "azure_openai"
|
|
51
|
+
@config_manager.set_api_key(provider.id, api_key)
|
|
52
|
+
{ providerId: provider.id, authMethod: "api_key", configured: true, message: "Saved API key for #{provider.name}." }
|
|
50
53
|
end
|
|
51
54
|
|
|
52
|
-
def logout_provider(provider_id:)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
when "openrouter"
|
|
62
|
-
@config_manager.delete_key("openrouter_api_key")
|
|
63
|
-
{ providerId: provider_id, message: "Logged out of OpenRouter." }
|
|
64
|
-
else
|
|
65
|
-
raise "Unsupported auth provider: #{provider_id}"
|
|
55
|
+
def logout_provider(provider_id:, auth_method: nil)
|
|
56
|
+
provider = ProviderCatalog.fetch(provider_id)
|
|
57
|
+
auth_method = auth_method.to_s unless auth_method.nil?
|
|
58
|
+
removed = false
|
|
59
|
+
if provider.api_key? && (auth_method.nil? || auth_method == "api_key")
|
|
60
|
+
removed = @config_manager.delete_api_key(provider.id) || removed
|
|
61
|
+
end
|
|
62
|
+
if provider.oauth? && (auth_method.nil? || auth_method == "oauth")
|
|
63
|
+
removed = logout_oauth_provider(provider.id) || removed
|
|
66
64
|
end
|
|
65
|
+
|
|
66
|
+
{ providerId: provider.id, authMethod: auth_method, removed: removed, message: "Logged out of #{provider.name}." }.compact
|
|
67
67
|
end
|
|
68
68
|
|
|
69
69
|
def login_with_oauth(provider_id:, timeout_seconds: 120)
|
|
@@ -73,8 +73,12 @@ module Kward
|
|
|
73
73
|
start_oauth_login(provider_id: "openai", oauth: @oauth_factory.call, timeout_seconds: timeout_seconds)
|
|
74
74
|
when "anthropic"
|
|
75
75
|
start_oauth_login(provider_id: "anthropic", oauth: @anthropic_oauth_factory.call, timeout_seconds: timeout_seconds)
|
|
76
|
-
when "github"
|
|
77
|
-
raise "GitHub OAuth is
|
|
76
|
+
when "github", "copilot"
|
|
77
|
+
raise "GitHub Copilot OAuth is unavailable over RPC; use the interactive CLI login."
|
|
78
|
+
when "openrouter"
|
|
79
|
+
raise "OpenRouter OAuth is unavailable because Kward has not implemented OpenRouter's official PKCE flow."
|
|
80
|
+
when "xai"
|
|
81
|
+
raise "xAI OAuth is unavailable because no official stable third-party flow is supported."
|
|
78
82
|
else
|
|
79
83
|
raise "Unsupported OAuth provider: #{provider_id}"
|
|
80
84
|
end
|
|
@@ -121,144 +125,100 @@ module Kward
|
|
|
121
125
|
|
|
122
126
|
private
|
|
123
127
|
|
|
124
|
-
def
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
id: "openai",
|
|
140
|
-
name: "OpenAI",
|
|
141
|
-
authType: "oauth",
|
|
142
|
-
configured: env_configured || stored_configured,
|
|
143
|
-
storedCredentialType: "oauth",
|
|
144
|
-
canLogout: stored_configured,
|
|
145
|
-
usesCallbackServer: true
|
|
146
|
-
}
|
|
147
|
-
provider[:source] = env_configured ? "environment" : "stored" if provider[:configured]
|
|
148
|
-
provider[:label] = provider[:configured] ? "Signed in" : "Not signed in"
|
|
149
|
-
provider
|
|
150
|
-
rescue StandardError
|
|
128
|
+
def provider_payload(provider)
|
|
129
|
+
methods = []
|
|
130
|
+
if provider.api_key?
|
|
131
|
+
status = @config_manager.api_key_status(provider.id)
|
|
132
|
+
methods << {
|
|
133
|
+
id: "api_key",
|
|
134
|
+
supported: true,
|
|
135
|
+
configured: status[:configured],
|
|
136
|
+
source: status[:source],
|
|
137
|
+
canLogout: status[:canLogout]
|
|
138
|
+
}.compact
|
|
139
|
+
end
|
|
140
|
+
methods << oauth_method_payload(provider) if provider.oauth?
|
|
141
|
+
configured_methods = methods.select { |method| method[:configured] }
|
|
142
|
+
preferred = methods.find { |method| method[:id] == "oauth" } || methods.first
|
|
151
143
|
{
|
|
152
|
-
id:
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
canLogout:
|
|
160
|
-
usesCallbackServer:
|
|
144
|
+
id: provider.id,
|
|
145
|
+
runtimeId: ProviderCatalog.runtime(provider.id).id,
|
|
146
|
+
name: provider.name,
|
|
147
|
+
authType: preferred&.fetch(:id, nil),
|
|
148
|
+
authMethods: methods,
|
|
149
|
+
configured: !configured_methods.empty?,
|
|
150
|
+
source: configured_methods.find { |method| method[:source] == "environment" }&.fetch(:source, nil) || configured_methods.first&.fetch(:source, nil),
|
|
151
|
+
canLogout: configured_methods.any? { |method| method[:canLogout] },
|
|
152
|
+
usesCallbackServer: methods.any? { |method| method[:usesCallbackServer] },
|
|
153
|
+
label: configured_methods.empty? ? "Not configured" : "Configured"
|
|
161
154
|
}.compact
|
|
162
155
|
end
|
|
163
156
|
|
|
164
|
-
def
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
provider[:label] = provider[:configured] ? "Signed in" : "Not signed in"
|
|
178
|
-
provider
|
|
179
|
-
rescue StandardError
|
|
157
|
+
def oauth_method_payload(provider)
|
|
158
|
+
status = oauth_status(provider.id)
|
|
159
|
+
supported = ["openai", "anthropic"].include?(provider.id)
|
|
160
|
+
reason = unless supported
|
|
161
|
+
case provider.id
|
|
162
|
+
when "openrouter"
|
|
163
|
+
"Official PKCE is documented, but Kward has not implemented it."
|
|
164
|
+
when "xai"
|
|
165
|
+
"No official stable third-party OAuth flow is available."
|
|
166
|
+
else
|
|
167
|
+
"OAuth login is available only in the interactive CLI."
|
|
168
|
+
end
|
|
169
|
+
end
|
|
180
170
|
{
|
|
181
|
-
id: "
|
|
182
|
-
name:
|
|
183
|
-
|
|
184
|
-
configured:
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
def openrouter_provider
|
|
193
|
-
config = stored_config
|
|
194
|
-
env_configured = !ENV["OPENROUTER_API_KEY"].to_s.empty?
|
|
195
|
-
stored_configured = !config["openrouter_api_key"].to_s.empty?
|
|
196
|
-
provider = {
|
|
197
|
-
id: "openrouter",
|
|
198
|
-
name: "OpenRouter",
|
|
199
|
-
authType: "api_key",
|
|
200
|
-
configured: env_configured || stored_configured,
|
|
201
|
-
canLogout: stored_configured
|
|
202
|
-
}
|
|
203
|
-
provider[:source] = env_configured ? "environment" : "stored" if provider[:configured]
|
|
204
|
-
provider[:storedCredentialType] = "api_key" if stored_configured
|
|
205
|
-
provider[:label] = provider[:configured] ? "API key configured" : "API key not configured"
|
|
206
|
-
provider
|
|
171
|
+
id: "oauth",
|
|
172
|
+
name: provider.oauth_name,
|
|
173
|
+
supported: supported,
|
|
174
|
+
configured: status[:configured],
|
|
175
|
+
source: status[:source],
|
|
176
|
+
canLogout: status[:canLogout],
|
|
177
|
+
usesCallbackServer: supported,
|
|
178
|
+
reason: reason
|
|
179
|
+
}.compact
|
|
207
180
|
end
|
|
208
181
|
|
|
209
|
-
def
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
usesCallbackServer: false,
|
|
221
|
-
supported: false,
|
|
222
|
-
reason: "GitHub OAuth is available in the CLI for Copilot scaffolding; RPC login is not implemented yet."
|
|
223
|
-
}
|
|
224
|
-
provider[:source] = env_configured ? "environment" : "stored" if provider[:configured]
|
|
225
|
-
provider[:label] = provider[:configured] ? "Signed in" : "Not signed in"
|
|
226
|
-
provider
|
|
182
|
+
def oauth_status(provider_id)
|
|
183
|
+
case provider_id
|
|
184
|
+
when "openai"
|
|
185
|
+
oauth_credential_status(@oauth_factory.call, env_name: "OPENAI_ACCESS_TOKEN")
|
|
186
|
+
when "anthropic"
|
|
187
|
+
oauth_credential_status(@anthropic_oauth_factory.call)
|
|
188
|
+
when "copilot"
|
|
189
|
+
oauth_credential_status(@github_oauth_factory.call, env_name: "COPILOT_GITHUB_TOKEN", can_logout: false)
|
|
190
|
+
else
|
|
191
|
+
{ configured: false, canLogout: false }
|
|
192
|
+
end
|
|
227
193
|
rescue StandardError
|
|
228
|
-
{
|
|
229
|
-
id: "github",
|
|
230
|
-
name: "GitHub",
|
|
231
|
-
authType: "oauth",
|
|
232
|
-
configured: false,
|
|
233
|
-
label: "Not signed in",
|
|
234
|
-
storedCredentialType: "oauth",
|
|
235
|
-
canLogout: false,
|
|
236
|
-
usesCallbackServer: false,
|
|
237
|
-
supported: false,
|
|
238
|
-
reason: "GitHub OAuth status unavailable."
|
|
239
|
-
}
|
|
194
|
+
{ configured: false, canLogout: false }
|
|
240
195
|
end
|
|
241
196
|
|
|
242
|
-
def
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
197
|
+
def oauth_credential_status(oauth, env_name: nil, can_logout: true)
|
|
198
|
+
environment = env_name && !ENV[env_name].to_s.empty?
|
|
199
|
+
stored = oauth.logged_in?
|
|
200
|
+
{
|
|
201
|
+
configured: environment || stored,
|
|
202
|
+
source: environment ? "environment" : (stored ? "stored" : nil),
|
|
203
|
+
canLogout: can_logout && stored
|
|
204
|
+
}.compact
|
|
250
205
|
end
|
|
251
206
|
|
|
252
|
-
def
|
|
253
|
-
oauth =
|
|
207
|
+
def logout_oauth_provider(provider_id)
|
|
208
|
+
oauth = case provider_id
|
|
209
|
+
when "openai" then @oauth_factory.call
|
|
210
|
+
when "anthropic" then @anthropic_oauth_factory.call
|
|
211
|
+
else return false
|
|
212
|
+
end
|
|
254
213
|
path = oauth.auth_path if oauth.respond_to?(:auth_path)
|
|
255
|
-
|
|
214
|
+
return false unless path && File.exist?(path)
|
|
215
|
+
|
|
216
|
+
File.delete(path)
|
|
217
|
+
true
|
|
256
218
|
end
|
|
257
219
|
|
|
258
|
-
def
|
|
259
|
-
|
|
260
|
-
path = oauth.auth_path if oauth.respond_to?(:auth_path)
|
|
261
|
-
File.delete(path) if path && File.exist?(path)
|
|
220
|
+
def fetch_login(login_id)
|
|
221
|
+
@mutex.synchronize { @logins[login_id.to_s] } || raise("Unknown login: #{login_id}")
|
|
262
222
|
end
|
|
263
223
|
|
|
264
224
|
def wait_for_callback(login, timeout_seconds:)
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
require_relative "../auth/openai_oauth"
|
|
2
|
+
require_relative "../auth/api_key_store"
|
|
2
3
|
require_relative "../config_files"
|
|
3
4
|
require_relative "../model/model_info"
|
|
5
|
+
require_relative "../model/provider_catalog"
|
|
6
|
+
require_relative "../model/azure_openai_config"
|
|
4
7
|
require_relative "redactor"
|
|
5
8
|
|
|
6
9
|
# Namespace for the Kward CLI agent runtime.
|
|
@@ -9,8 +12,9 @@ module Kward
|
|
|
9
12
|
module RPC
|
|
10
13
|
# RPC configuration manager for reading and updating user config.
|
|
11
14
|
class ConfigManager
|
|
12
|
-
def initialize(config_path: OpenAIOAuth.default_config_path)
|
|
15
|
+
def initialize(config_path: OpenAIOAuth.default_config_path, api_key_store: nil)
|
|
13
16
|
@config_path = File.expand_path(config_path)
|
|
17
|
+
@api_key_store = api_key_store || APIKeyStore.new(path: File.join(File.dirname(@config_path), "api_keys.json"), config_path: @config_path)
|
|
14
18
|
end
|
|
15
19
|
|
|
16
20
|
attr_reader :config_path
|
|
@@ -21,6 +25,7 @@ module Kward
|
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
def update(values)
|
|
28
|
+
reject_provider_api_keys!(values)
|
|
24
29
|
Redactor.redact(ConfigFiles.update_config(values, @config_path))
|
|
25
30
|
end
|
|
26
31
|
|
|
@@ -39,12 +44,35 @@ module Kward
|
|
|
39
44
|
end
|
|
40
45
|
|
|
41
46
|
def set_api_key(provider_id, api_key)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
raise "API key must be a non-empty string" if api_key.empty?
|
|
45
|
-
raise "Unsupported API key provider: #{provider_id}" unless provider_id == "openrouter"
|
|
47
|
+
provider = ProviderCatalog.fetch(provider_id)
|
|
48
|
+
raise "#{provider.name} does not accept an API key" unless provider.api_key?
|
|
46
49
|
|
|
47
|
-
|
|
50
|
+
@api_key_store.store(provider.id, api_key)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def api_key_status(provider_id)
|
|
54
|
+
provider = ProviderCatalog.fetch(provider_id)
|
|
55
|
+
configured = @api_key_store.configured?(provider.id)
|
|
56
|
+
stored = @api_key_store.stored?(provider.id)
|
|
57
|
+
{
|
|
58
|
+
configured: configured,
|
|
59
|
+
stored: stored,
|
|
60
|
+
source: configured ? (environment_api_key?(provider) ? "environment" : "stored") : nil,
|
|
61
|
+
canLogout: stored
|
|
62
|
+
}.compact
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def delete_api_key(provider_id)
|
|
66
|
+
@api_key_store.delete(provider_id)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def configure_azure_openai(values)
|
|
70
|
+
setup = AzureOpenAIConfig.new(
|
|
71
|
+
endpoint: values["endpoint"] || values[:endpoint],
|
|
72
|
+
deployment: values["deployment"] || values[:deployment],
|
|
73
|
+
api_version: values["apiVersion"] || values[:apiVersion] || values["api_version"] || values[:api_version]
|
|
74
|
+
)
|
|
75
|
+
update(setup.to_config)
|
|
48
76
|
end
|
|
49
77
|
|
|
50
78
|
def delete_key(key)
|
|
@@ -61,6 +89,18 @@ module Kward
|
|
|
61
89
|
|
|
62
90
|
private
|
|
63
91
|
|
|
92
|
+
def reject_provider_api_keys!(values)
|
|
93
|
+
keys = values.respond_to?(:keys) ? values.keys.map(&:to_s) : []
|
|
94
|
+
private_keys = ProviderCatalog.api_key_providers.map { |provider| "#{provider.id}_api_key" }
|
|
95
|
+
return if (keys & private_keys).empty?
|
|
96
|
+
|
|
97
|
+
raise ArgumentError, "Provider API keys must be saved with auth/loginWithApiKey"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def environment_api_key?(provider)
|
|
101
|
+
provider.api_key_env.any? { |name| !ENV[name].to_s.strip.empty? }
|
|
102
|
+
end
|
|
103
|
+
|
|
64
104
|
def load_config
|
|
65
105
|
ConfigFiles.read_config(@config_path)
|
|
66
106
|
end
|