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.
Files changed (117) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +6 -0
  3. data/CHANGELOG.md +58 -1
  4. data/Gemfile.lock +2 -2
  5. data/README.md +31 -38
  6. data/doc/agent-tools.md +9 -10
  7. data/doc/api.md +3 -3
  8. data/doc/authentication.md +79 -110
  9. data/doc/code-search.md +1 -1
  10. data/doc/composer.md +1 -1
  11. data/doc/configuration.md +69 -26
  12. data/doc/context-budgeting.md +6 -6
  13. data/doc/context-tools.md +2 -2
  14. data/doc/editor.md +7 -7
  15. data/doc/extensibility.md +7 -29
  16. data/doc/files.md +6 -6
  17. data/doc/getting-started.md +2 -2
  18. data/doc/git.md +8 -2
  19. data/doc/lifecycle-hooks.md +5 -4
  20. data/doc/local-models.md +2 -2
  21. data/doc/mcp.md +5 -5
  22. data/doc/permissions.md +13 -4
  23. data/doc/plugins.md +39 -4
  24. data/doc/prompt-templates.md +105 -0
  25. data/doc/providers.md +140 -0
  26. data/doc/releasing.md +10 -2
  27. data/doc/rpc.md +54 -31
  28. data/doc/sandboxing.md +22 -0
  29. data/doc/security.md +22 -7
  30. data/doc/session-management.md +2 -4
  31. data/doc/tabs.md +37 -0
  32. data/doc/telegram.md +138 -0
  33. data/doc/transports.md +209 -0
  34. data/doc/usage.md +11 -3
  35. data/doc/web-search.md +3 -3
  36. data/doc/workspace-tools.md +2 -2
  37. data/examples/plugins/stardate_footer.rb +10 -0
  38. data/examples/plugins/telegram/plugin.rb +39 -0
  39. data/examples/plugins/telegram/telegram_api.rb +136 -0
  40. data/examples/plugins/telegram/telegram_transport.rb +304 -0
  41. data/lib/kward/ansi.rb +1 -0
  42. data/lib/kward/auth/api_key_store.rb +99 -0
  43. data/lib/kward/cli/auth_commands.rb +72 -13
  44. data/lib/kward/cli/commands.rb +7 -0
  45. data/lib/kward/cli/git.rb +31 -8
  46. data/lib/kward/cli/plugins.rb +1 -0
  47. data/lib/kward/cli/prompt_interface.rb +7 -0
  48. data/lib/kward/cli/rendering.rb +6 -2
  49. data/lib/kward/cli/runtime_helpers.rb +4 -2
  50. data/lib/kward/cli/sessions.rb +1 -1
  51. data/lib/kward/cli/settings.rb +97 -33
  52. data/lib/kward/cli/tabs.rb +102 -37
  53. data/lib/kward/cli/transports.rb +67 -0
  54. data/lib/kward/cli/worktrees.rb +360 -0
  55. data/lib/kward/cli.rb +20 -0
  56. data/lib/kward/config_files.rb +26 -3
  57. data/lib/kward/conversation.rb +13 -7
  58. data/lib/kward/git_worktree_manager.rb +291 -0
  59. data/lib/kward/model/azure_openai_config.rb +54 -0
  60. data/lib/kward/model/catalog.rb +113 -0
  61. data/lib/kward/model/client.rb +225 -20
  62. data/lib/kward/model/model_info.rb +49 -0
  63. data/lib/kward/model/payloads.rb +63 -4
  64. data/lib/kward/model/provider_catalog.rb +114 -0
  65. data/lib/kward/model/sources.rb +70 -0
  66. data/lib/kward/model/stream_parser.rb +83 -13
  67. data/lib/kward/permissions/policy.rb +18 -4
  68. data/lib/kward/plugin_chat_runtime.rb +374 -0
  69. data/lib/kward/plugin_registry.rb +80 -7
  70. data/lib/kward/private_file.rb +9 -3
  71. data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
  72. data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
  73. data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
  74. data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
  75. data/lib/kward/prompt_interface/editor/controller.rb +7 -7
  76. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  77. data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
  78. data/lib/kward/prompt_interface/editor/state.rb +3 -9
  79. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +184 -9
  80. data/lib/kward/prompt_interface/file_overlay.rb +2 -2
  81. data/lib/kward/prompt_interface/git_prompt.rb +7 -3
  82. data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
  83. data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
  84. data/lib/kward/prompt_interface/interactive/state.rb +2 -2
  85. data/lib/kward/prompt_interface/project_browser.rb +25 -6
  86. data/lib/kward/prompt_interface/runtime_state.rb +22 -6
  87. data/lib/kward/prompt_interface/screen.rb +3 -0
  88. data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
  89. data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
  90. data/lib/kward/prompt_interface.rb +53 -12
  91. data/lib/kward/prompts/commands.rb +1 -1
  92. data/lib/kward/prompts.rb +6 -5
  93. data/lib/kward/rpc/auth_manager.rb +112 -152
  94. data/lib/kward/rpc/config_manager.rb +46 -6
  95. data/lib/kward/rpc/plugin_chat_manager.rb +55 -196
  96. data/lib/kward/rpc/prompt_bridge.rb +8 -4
  97. data/lib/kward/rpc/redactor.rb +1 -1
  98. data/lib/kward/rpc/server.rb +60 -10
  99. data/lib/kward/rpc/session_manager.rb +109 -30
  100. data/lib/kward/session_store.rb +84 -24
  101. data/lib/kward/tab_driver.rb +6 -3
  102. data/lib/kward/tools/git_commit.rb +39 -0
  103. data/lib/kward/tools/registry.rb +7 -2
  104. data/lib/kward/tools/tool_call.rb +1 -0
  105. data/lib/kward/transport/gateway.rb +253 -0
  106. data/lib/kward/transport/host.rb +343 -0
  107. data/lib/kward/transport/manager.rb +179 -0
  108. data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
  109. data/lib/kward/transport/runtime.rb +41 -0
  110. data/lib/kward/transport/store.rb +101 -0
  111. data/lib/kward/transport.rb +204 -0
  112. data/lib/kward/version.rb +1 -1
  113. data/lib/kward/workspace_factory.rb +19 -2
  114. data/templates/default/fulldoc/html/js/kward.js +2 -0
  115. data/templates/default/kward_navigation.rb +7 -2
  116. data/templates/default/layout/html/setup.rb +4 -0
  117. 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
- @text = @text[-@limit, @limit] if @text.length > @limit
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 = monotonic_now
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 edit_file(path, base_dir: Dir.pwd, allow_new: true)
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
- @tabs = Array(labels).map { |label| normalize_tab_label(label) }
614
- @active_tab_index = active_index.to_i
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
- oauth = @oauth_factory.call
29
- config = stored_config
29
+ provider_status = providers.fetch(:providers)
30
30
  {
31
- openaiOAuth: oauth.logged_in?,
32
- openaiAccountId: oauth.respond_to?(:account_id) ? oauth.account_id : nil,
33
- openrouterApiKey: !ENV["OPENROUTER_API_KEY"].to_s.empty? || !config["openrouter_api_key"].to_s.empty?,
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: @anthropic_oauth_factory.call.logged_in?,
36
- githubOAuth: @github_oauth_factory.call.logged_in?
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: [openai_provider, anthropic_provider, openrouter_provider, github_provider] }
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
- provider_id = provider_id.to_s
48
- @config_manager.set_api_key(provider_id, api_key)
49
- { providerId: provider_id, message: "Saved API key for #{provider_name(provider_id)}." }
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
- provider_id = provider_id.to_s
54
- case provider_id
55
- when "openai"
56
- logout_openai
57
- { providerId: provider_id, message: "Logged out of OpenAI." }
58
- when "anthropic"
59
- logout_anthropic
60
- { providerId: provider_id, message: "Logged out of Anthropic." }
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 supported in the CLI with `ruby lib/main.rb login github`, but RPC browser login is not implemented yet."
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 fetch_login(login_id)
125
- @mutex.synchronize { @logins[login_id.to_s] } || raise("Unknown login: #{login_id}")
126
- end
127
-
128
- def stored_config
129
- @config_manager.read(redacted: false)
130
- rescue StandardError
131
- {}
132
- end
133
-
134
- def openai_provider
135
- oauth = @oauth_factory.call
136
- env_configured = !ENV["OPENAI_ACCESS_TOKEN"].to_s.empty?
137
- stored_configured = oauth.logged_in?
138
- provider = {
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: "openai",
153
- name: "OpenAI",
154
- authType: "oauth",
155
- configured: !ENV["OPENAI_ACCESS_TOKEN"].to_s.empty?,
156
- source: (!ENV["OPENAI_ACCESS_TOKEN"].to_s.empty? ? "environment" : nil),
157
- label: (!ENV["OPENAI_ACCESS_TOKEN"].to_s.empty? ? "Signed in" : "Not signed in"),
158
- storedCredentialType: "oauth",
159
- canLogout: false,
160
- usesCallbackServer: true
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 anthropic_provider
165
- oauth = @anthropic_oauth_factory.call
166
- stored_configured = oauth.logged_in?
167
- provider = {
168
- id: "anthropic",
169
- name: "Anthropic",
170
- authType: "oauth",
171
- configured: stored_configured,
172
- storedCredentialType: "oauth",
173
- canLogout: stored_configured,
174
- usesCallbackServer: true
175
- }
176
- provider[:source] = "stored" if provider[:configured]
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: "anthropic",
182
- name: "Anthropic",
183
- authType: "oauth",
184
- configured: false,
185
- label: "Not signed in",
186
- storedCredentialType: "oauth",
187
- canLogout: false,
188
- usesCallbackServer: true
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 github_provider
210
- oauth = @github_oauth_factory.call
211
- env_configured = !ENV["COPILOT_GITHUB_TOKEN"].to_s.empty?
212
- stored_configured = oauth.logged_in? && !env_configured
213
- provider = {
214
- id: "github",
215
- name: "GitHub",
216
- authType: "oauth",
217
- configured: env_configured || stored_configured,
218
- storedCredentialType: "oauth",
219
- canLogout: false,
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 provider_name(provider_id)
243
- case provider_id
244
- when "openrouter" then "OpenRouter"
245
- when "openai" then "OpenAI"
246
- when "anthropic" then "Anthropic"
247
- when "github" then "GitHub"
248
- else provider_id
249
- end
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 logout_openai
253
- oauth = @oauth_factory.call
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
- File.delete(path) if path && File.exist?(path)
214
+ return false unless path && File.exist?(path)
215
+
216
+ File.delete(path)
217
+ true
256
218
  end
257
219
 
258
- def logout_anthropic
259
- oauth = @anthropic_oauth_factory.call
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
- provider_id = provider_id.to_s
43
- api_key = api_key.to_s.strip
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
- update("openrouter_api_key" => api_key)
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