kward 0.67.1 → 0.69.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 (146) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +48 -0
  3. data/.yardopts +1 -0
  4. data/CHANGELOG.md +54 -0
  5. data/Gemfile.lock +8 -2
  6. data/README.md +37 -30
  7. data/Rakefile +14 -1
  8. data/doc/authentication.md +84 -43
  9. data/doc/code-search.md +55 -28
  10. data/doc/configuration.md +27 -2
  11. data/doc/extensibility.md +90 -129
  12. data/doc/getting-started.md +53 -57
  13. data/doc/memory.md +51 -118
  14. data/doc/personas.md +417 -0
  15. data/doc/plugins.md +55 -99
  16. data/doc/releasing.md +10 -9
  17. data/doc/rpc.md +7 -7
  18. data/doc/usage.md +125 -141
  19. data/doc/web-search.md +80 -14
  20. data/exe/kward +2 -0
  21. data/kward.gemspec +4 -0
  22. data/lib/kward/agent.rb +30 -3
  23. data/lib/kward/ansi.rb +3 -0
  24. data/lib/kward/auth/anthropic_oauth.rb +291 -0
  25. data/lib/kward/auth/file.rb +2 -0
  26. data/lib/kward/auth/github_oauth.rb +3 -0
  27. data/lib/kward/auth/openai_oauth.rb +4 -0
  28. data/lib/kward/auth/openrouter_api_key.rb +2 -0
  29. data/lib/kward/cancellation.rb +3 -0
  30. data/lib/kward/cli/auth_commands.rb +82 -0
  31. data/lib/kward/cli/commands.rb +229 -0
  32. data/lib/kward/cli/compaction.rb +25 -0
  33. data/lib/kward/cli/doctor.rb +121 -0
  34. data/lib/kward/cli/interactive_turn.rb +227 -0
  35. data/lib/kward/cli/memory_commands.rb +133 -0
  36. data/lib/kward/cli/plugins.rb +112 -0
  37. data/lib/kward/cli/prompt_interface.rb +134 -0
  38. data/lib/kward/cli/rendering.rb +378 -0
  39. data/lib/kward/cli/runtime_helpers.rb +170 -0
  40. data/lib/kward/cli/sessions.rb +376 -0
  41. data/lib/kward/cli/settings.rb +669 -0
  42. data/lib/kward/cli/slash_commands.rb +114 -0
  43. data/lib/kward/cli/stats.rb +64 -0
  44. data/lib/kward/cli/sysprompt.rb +57 -0
  45. data/lib/kward/cli/tool_summaries.rb +157 -0
  46. data/lib/kward/cli.rb +52 -2792
  47. data/lib/kward/cli_transcript_formatter.rb +40 -12
  48. data/lib/kward/clipboard.rb +1 -0
  49. data/lib/kward/compaction/file_operation_tracker.rb +3 -0
  50. data/lib/kward/compactor.rb +31 -9
  51. data/lib/kward/config_files.rb +78 -34
  52. data/lib/kward/conversation.rb +110 -13
  53. data/lib/kward/events.rb +2 -0
  54. data/lib/kward/export_path.rb +2 -0
  55. data/lib/kward/image_attachments.rb +2 -0
  56. data/lib/kward/markdown_transcript.rb +2 -0
  57. data/lib/kward/memory/manager.rb +144 -14
  58. data/lib/kward/message_access.rb +29 -2
  59. data/lib/kward/message_text.rb +45 -0
  60. data/lib/kward/model/chat_invocation.rb +2 -0
  61. data/lib/kward/model/client.rb +295 -77
  62. data/lib/kward/model/context_overflow.rb +2 -0
  63. data/lib/kward/model/context_usage.rb +14 -10
  64. data/lib/kward/model/model_info.rb +160 -4
  65. data/lib/kward/model/payloads.rb +254 -22
  66. data/lib/kward/model/retry_message.rb +2 -0
  67. data/lib/kward/model/stream_parser.rb +387 -25
  68. data/lib/kward/pan/server.rb +3 -1
  69. data/lib/kward/plugin_registry.rb +12 -0
  70. data/lib/kward/private_file.rb +2 -0
  71. data/lib/kward/prompt_interface/banner.rb +3 -0
  72. data/lib/kward/prompt_interface/composer_controller.rb +262 -0
  73. data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
  74. data/lib/kward/prompt_interface/composer_state.rb +221 -0
  75. data/lib/kward/prompt_interface/key_handler.rb +365 -0
  76. data/lib/kward/prompt_interface/layout.rb +31 -0
  77. data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
  78. data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
  79. data/lib/kward/prompt_interface/question_prompt.rb +328 -0
  80. data/lib/kward/prompt_interface/runtime_state.rb +59 -0
  81. data/lib/kward/prompt_interface/screen.rb +186 -0
  82. data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
  83. data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
  84. data/lib/kward/prompt_interface/stream_state.rb +65 -0
  85. data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
  86. data/lib/kward/prompt_interface/transcript_renderer.rb +151 -0
  87. data/lib/kward/prompt_interface.rb +69 -1832
  88. data/lib/kward/prompts/commands.rb +2 -0
  89. data/lib/kward/prompts/templates.rb +3 -0
  90. data/lib/kward/prompts.rb +63 -7
  91. data/lib/kward/question_contract.rb +66 -0
  92. data/lib/kward/resources/avatar_kward_logo.rb +2 -0
  93. data/lib/kward/resources/pixel_logo.rb +2 -0
  94. data/lib/kward/rpc/attachment_normalizer.rb +60 -0
  95. data/lib/kward/rpc/auth_manager.rb +65 -11
  96. data/lib/kward/rpc/config_manager.rb +11 -0
  97. data/lib/kward/rpc/prompt_bridge.rb +5 -26
  98. data/lib/kward/rpc/redactor.rb +3 -0
  99. data/lib/kward/rpc/runtime_payloads.rb +4 -1
  100. data/lib/kward/rpc/server.rb +43 -11
  101. data/lib/kward/rpc/session_manager.rb +139 -347
  102. data/lib/kward/rpc/session_metrics.rb +68 -0
  103. data/lib/kward/rpc/session_tree.rb +48 -0
  104. data/lib/kward/rpc/session_tree_rows.rb +208 -0
  105. data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
  106. data/lib/kward/rpc/tool_metadata.rb +3 -0
  107. data/lib/kward/rpc/transcript_normalizer.rb +50 -0
  108. data/lib/kward/rpc/transport.rb +3 -0
  109. data/lib/kward/session_diff.rb +2 -0
  110. data/lib/kward/session_store.rb +154 -25
  111. data/lib/kward/session_trash.rb +1 -0
  112. data/lib/kward/session_tree_renderer.rb +8 -41
  113. data/lib/kward/session_tree_tool_display.rb +56 -0
  114. data/lib/kward/skills/registry.rb +3 -0
  115. data/lib/kward/starter_pack_installer.rb +3 -2
  116. data/lib/kward/steering.rb +2 -0
  117. data/lib/kward/telemetry/logger.rb +3 -0
  118. data/lib/kward/telemetry/stats.rb +3 -0
  119. data/lib/kward/tools/ask_user_question.rb +20 -32
  120. data/lib/kward/tools/base.rb +8 -0
  121. data/lib/kward/tools/code_search.rb +5 -0
  122. data/lib/kward/tools/edit_file.rb +5 -0
  123. data/lib/kward/tools/fetch_content.rb +41 -0
  124. data/lib/kward/tools/fetch_raw.rb +40 -0
  125. data/lib/kward/tools/list_directory.rb +5 -0
  126. data/lib/kward/tools/read_file.rb +5 -0
  127. data/lib/kward/tools/read_skill.rb +5 -0
  128. data/lib/kward/tools/registry.rb +42 -4
  129. data/lib/kward/tools/run_shell_command.rb +5 -0
  130. data/lib/kward/tools/search/code.rb +7 -0
  131. data/lib/kward/tools/search/web.rb +20 -17
  132. data/lib/kward/tools/search/web_fetch.rb +202 -0
  133. data/lib/kward/tools/tool_call.rb +27 -5
  134. data/lib/kward/tools/web_search.rb +7 -1
  135. data/lib/kward/tools/write_file.rb +5 -0
  136. data/lib/kward/transcript_export.rb +2 -0
  137. data/lib/kward/version.rb +2 -1
  138. data/lib/kward/workspace.rb +45 -5
  139. data/templates/default/fulldoc/html/css/kward.css +1501 -0
  140. data/templates/default/fulldoc/html/images/kward_logo.png +0 -0
  141. data/templates/default/fulldoc/html/js/kward.js +296 -0
  142. data/templates/default/fulldoc/html/setup.rb +8 -0
  143. data/templates/default/layout/html/breadcrumb.erb +11 -0
  144. data/templates/default/layout/html/layout.erb +141 -0
  145. data/templates/default/layout/html/setup.rb +139 -0
  146. metadata +56 -1
@@ -1,6 +1,8 @@
1
1
  require_relative "../config_files"
2
2
 
3
+ # Namespace for the Kward CLI agent runtime.
3
4
  module Kward
5
+ # Prompt-template and slash-command parsing helpers.
4
6
  module PromptCommands
5
7
  BUILTIN_COMMANDS = [
6
8
  { name: "exit", description: "Exit the interactive session.", argument_hint: "" },
@@ -1,5 +1,8 @@
1
+ # Namespace for the Kward CLI agent runtime.
1
2
  module Kward
3
+ # Prompt template discovery from configured markdown files.
2
4
  module Prompts
5
+ # Parsed prompt template loaded from disk.
3
6
  class Templates
4
7
  def initialize(config_dir:, template_class:, markdown_parser:)
5
8
  @config_dir = config_dir
data/lib/kward/prompts.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  require_relative "config_files"
2
2
 
3
+ # Namespace for the Kward CLI agent runtime.
3
4
  module Kward
5
+ # System prompt assembly from config, workspace instructions, memory, and plugins.
4
6
  module Prompts
5
7
  module_function
6
8
 
@@ -12,18 +14,27 @@ module Kward
12
14
  end
13
15
 
14
16
  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)
15
- parts = [base_prompt, config_agents_prompt]
16
- parts << memory_context unless memory_context.to_s.empty?
17
- parts << persona_prompt(workspace_root, model: model, reasoning_effort: reasoning_effort, now: now) if include_workspace_personality
18
- parts << plugin_context unless plugin_context.to_s.empty? || !include_workspace_personality
19
- parts << skills_prompt
20
- parts << workspace_agents_prompt(workspace_root)
21
- parts
17
+ 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] }
18
+ end
19
+
20
+ 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)
21
+ sections = [prompt_section("Built-in system prompt", base_prompt)]
22
+ sections << prompt_section(config_agents_prompt_label, config_agents_prompt, source: config_agents_prompt_source)
23
+ sections << prompt_section("Memory context", memory_context) unless memory_context.to_s.empty?
24
+ if include_workspace_personality
25
+ sections << prompt_section("Persona", persona_prompt(workspace_root, model: model, reasoning_effort: reasoning_effort, now: now))
26
+ sections << prompt_section("Plugin context", plugin_context) unless plugin_context.to_s.empty?
27
+ end
28
+ sections << prompt_section("Configured skills", skills_prompt, source: ConfigFiles.skills.empty? ? nil : File.join(ConfigFiles.config_dir, "skills"))
29
+ sections << prompt_section(workspace_agents_context_label(workspace_root), workspace_agents_context(workspace_root), source: ConfigFiles.workspace_agents_file?(workspace_root) ? ConfigFiles.workspace_agents_path(workspace_root) : nil)
30
+ sections.compact
22
31
  end
23
32
 
24
33
  def base_prompt
25
34
  <<~PROMPT.strip
26
35
  You are Kward, a concise practical CLI coding agent. You are allowed to use the tools. Help users understand and modify software projects. Inspect files before changing them, make the smallest correct change, preserve existing style, and summarize what changed. Be honest about limitations.
36
+
37
+ For web research, use web_search to discover sources, then fetch_content for important human-readable pages before relying on them. Use fetch_raw for machine-readable resources such as JSON, YAML, XML, RSS, OpenAPI specs, and plain text. Prefer official or primary sources when practical, and cite or mention the URLs you relied on.
27
38
  PROMPT
28
39
  end
29
40
 
@@ -31,14 +42,59 @@ module Kward
31
42
  ConfigFiles.agents_prompt
32
43
  end
33
44
 
45
+ def config_agents_prompt_label
46
+ return "Config principles" if File.exist?(ConfigFiles.config_principles_path)
47
+ return "Config AGENTS.md alias" if File.exist?(ConfigFiles.config_agents_path)
48
+
49
+ "Config principles"
50
+ end
51
+
52
+ def config_agents_prompt_source
53
+ return ConfigFiles.config_principles_path if File.exist?(ConfigFiles.config_principles_path)
54
+ return ConfigFiles.config_agents_path if File.exist?(ConfigFiles.config_agents_path)
55
+
56
+ nil
57
+ end
58
+
34
59
  def persona_prompt(workspace_root = Dir.pwd, model: nil, reasoning_effort: nil, now: Time.now)
35
60
  ConfigFiles.persona_prompt(workspace_root, model: model, reasoning_effort: reasoning_effort, now: now)
36
61
  end
37
62
 
63
+ def workspace_agents_context(workspace_root = Dir.pwd)
64
+ if ConfigFiles.enforce_workspace_agents_file?
65
+ ConfigFiles.workspace_agents_prompt(workspace_root)
66
+ else
67
+ workspace_agents_hint(workspace_root)
68
+ end
69
+ end
70
+
38
71
  def workspace_agents_prompt(workspace_root = Dir.pwd)
39
72
  ConfigFiles.workspace_agents_prompt(workspace_root)
40
73
  end
41
74
 
75
+ def workspace_agents_hint(workspace_root = Dir.pwd)
76
+ return nil unless ConfigFiles.workspace_agents_file?(workspace_root)
77
+
78
+ path = ConfigFiles.workspace_agents_path(workspace_root)
79
+ <<~PROMPT.strip
80
+ Workspace guidance is available in AGENTS.md at the workspace root: #{path}
81
+ For tasks involving this repository, read it before analyzing or modifying project files, and follow it when it does not conflict with higher-priority instructions or the user's request.
82
+ PROMPT
83
+ end
84
+
85
+ def workspace_agents_context_label(workspace_root = Dir.pwd)
86
+ return "Workspace AGENTS.md" unless ConfigFiles.workspace_agents_file?(workspace_root)
87
+ return "Workspace AGENTS.md" if ConfigFiles.enforce_workspace_agents_file?
88
+
89
+ "Workspace AGENTS.md hint"
90
+ end
91
+
92
+ def prompt_section(label, content, source: nil)
93
+ return nil if content.to_s.empty?
94
+
95
+ { label: label, content: content, source: source }
96
+ end
97
+
42
98
  def skills_prompt
43
99
  skills = ConfigFiles.skills
44
100
  return nil if skills.empty?
@@ -0,0 +1,66 @@
1
+ require_relative "message_access"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Validates and normalizes structured clarification questions shared by CLI
6
+ # tools and RPC prompt bridging.
7
+ module QuestionContract
8
+ MIN_QUESTIONS = 1
9
+ MAX_QUESTIONS = 4
10
+ MIN_OPTIONS = 2
11
+ MAX_OPTIONS = 4
12
+
13
+ module_function
14
+
15
+ def normalize_questions(questions)
16
+ raise ArgumentError, "questions must be an array" unless questions.is_a?(Array)
17
+ unless questions.length.between?(MIN_QUESTIONS, MAX_QUESTIONS)
18
+ raise ArgumentError, "ui/question requires 1-4 questions"
19
+ end
20
+
21
+ questions.map.with_index(1) { |question, index| normalize_question(question, index) }
22
+ end
23
+
24
+ def normalize_question(question, index)
25
+ raise ArgumentError, "question #{index} must be an object" unless question.is_a?(Hash)
26
+ raise ArgumentError, "question #{index} multiSelect is unsupported" if has_key?(question, :multiSelect)
27
+
28
+ text = value(question, :question).to_s.strip
29
+ header = value(question, :header).to_s.strip
30
+ raise ArgumentError, "question #{index} requires question and header" if text.empty? || header.empty?
31
+
32
+ options = value(question, :options)
33
+ raise ArgumentError, "question #{index} options must be an array" unless options.is_a?(Array)
34
+ unless options.length.between?(MIN_OPTIONS, MAX_OPTIONS)
35
+ raise ArgumentError, "question #{index} requires 2-4 options"
36
+ end
37
+
38
+ {
39
+ question: text,
40
+ header: header,
41
+ options: options.map.with_index(1) { |option, option_index| normalize_option(option, index, option_index) }
42
+ }
43
+ end
44
+
45
+ def normalize_option(option, question_index, option_index)
46
+ raise ArgumentError, "question #{question_index} option #{option_index} must be an object" unless option.is_a?(Hash)
47
+ raise ArgumentError, "question #{question_index} preview is unsupported" if has_key?(option, :preview)
48
+
49
+ label = value(option, :label).to_s.strip
50
+ description = value(option, :description).to_s.strip
51
+ if label.empty? || description.empty?
52
+ raise ArgumentError, "question #{question_index} option #{option_index} requires label and description"
53
+ end
54
+
55
+ { label: label, description: description }
56
+ end
57
+
58
+ def value(object, key)
59
+ MessageAccess.value(object, key)
60
+ end
61
+
62
+ def has_key?(object, key)
63
+ object.key?(key) || object.key?(key.to_s)
64
+ end
65
+ end
66
+ end
@@ -1,7 +1,9 @@
1
1
  # Generated from avatar_kward_32x32.png as RGB terminal cells.
2
2
  # The interactive banner uses this data instead of decoding a PNG at runtime.
3
3
  module Kward
4
+ # Static avatar logo data used by generated resources.
4
5
  module Resources
6
+ # Static avatar logo data used by generated resources.
5
7
  module AvatarKwardLogo
6
8
  PIXELS = [
7
9
  [nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil],
@@ -1,6 +1,8 @@
1
1
  require "zlib"
2
2
 
3
+ # Namespace for the Kward CLI agent runtime.
3
4
  module Kward
5
+ # Pixel-art logo data and rendering helpers.
4
6
  module PixelLogo
5
7
  PNG_SIGNATURE = "\x89PNG\r\n\x1a\n".b.freeze
6
8
  TRANSPARENT_ALPHA = 128
@@ -0,0 +1,60 @@
1
+ require "base64"
2
+ require_relative "../tools/tool_call"
3
+
4
+ # Namespace for the Kward CLI agent runtime.
5
+ module Kward
6
+ # JSON-RPC backend namespace used by UI clients.
7
+ module RPC
8
+ # Validates and normalizes RPC image attachments.
9
+ class AttachmentNormalizer
10
+ IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"].freeze
11
+ MAX_BYTES = 10 * 1024 * 1024
12
+
13
+ def initialize(max_bytes: MAX_BYTES, mime_types: IMAGE_MIME_TYPES)
14
+ @max_bytes = max_bytes
15
+ @mime_types = mime_types
16
+ end
17
+
18
+ def normalize(attachments)
19
+ return [] if attachments.nil?
20
+ raise ArgumentError, "attachments must be an array" unless attachments.is_a?(Array)
21
+
22
+ attachments.map { |attachment| normalize_attachment(attachment) }
23
+ end
24
+
25
+ private
26
+
27
+ def normalize_attachment(attachment)
28
+ raise ArgumentError, "attachment must be an object" unless attachment.is_a?(Hash)
29
+
30
+ type = ToolCall.value(attachment, :type).to_s
31
+ raise ArgumentError, "Unsupported attachment type: #{type.empty? ? "unknown" : type}" unless type == "image"
32
+
33
+ mime_type = normalize_mime_type(ToolCall.value(attachment, :mimeType) || ToolCall.value(attachment, :mime_type) || ToolCall.value(attachment, :media_type))
34
+ raise ArgumentError, "Unsupported image MIME type: #{mime_type.empty? ? "unknown" : mime_type}" unless @mime_types.include?(mime_type)
35
+
36
+ data = ToolCall.value(attachment, :data).to_s
37
+ raise ArgumentError, "Image attachment data must be valid base64" if data.empty?
38
+ raise ArgumentError, "Image attachment data must be raw base64" if data.start_with?("data:")
39
+ declared_size = ToolCall.value(attachment, :sizeBytes) || ToolCall.value(attachment, :size_bytes)
40
+ raise ArgumentError, "Image attachment is too large" if declared_size && declared_size.to_i > @max_bytes
41
+
42
+ decoded_size = Base64.strict_decode64(data).bytesize
43
+ raise ArgumentError, "Image attachment is too large" if decoded_size > @max_bytes
44
+
45
+ result = { type: "image", data: data, mimeType: mime_type }
46
+ name = ToolCall.value(attachment, :name)
47
+ result[:alt] = name.to_s unless name.to_s.empty?
48
+ result
49
+ rescue ArgumentError => e
50
+ raise e if e.message.start_with?("Unsupported", "Image attachment", "attachment")
51
+
52
+ raise ArgumentError, "Image attachment data must be valid base64"
53
+ end
54
+
55
+ def normalize_mime_type(mime_type)
56
+ mime_type.to_s.downcase
57
+ end
58
+ end
59
+ end
60
+ end
@@ -1,19 +1,24 @@
1
1
  require "securerandom"
2
2
  require "thread"
3
+ require_relative "../auth/anthropic_oauth"
3
4
  require_relative "../auth/github_oauth"
4
5
  require_relative "../auth/openai_oauth"
5
6
  require_relative "../model/client"
6
7
  require_relative "config_manager"
7
8
 
9
+ # Namespace for the Kward CLI agent runtime.
8
10
  module Kward
11
+ # JSON-RPC backend namespace used by UI clients.
9
12
  module RPC
13
+ # RPC authentication manager for provider status, login, and logout requests.
10
14
  class AuthManager
11
- Login = Struct.new(:id, :oauth, :pkce, :state, :server, :redirect_uri, :status, :error, :thread, keyword_init: true)
15
+ Login = Struct.new(:id, :provider_id, :oauth, :pkce, :state, :server, :redirect_uri, :status, :error, :thread, keyword_init: true)
12
16
 
13
- def initialize(server:, oauth_factory: -> { OpenAIOAuth.new }, github_oauth_factory: -> { GithubOAuth.new }, config_manager: ConfigManager.new)
17
+ def initialize(server:, oauth_factory: -> { OpenAIOAuth.new }, github_oauth_factory: -> { GithubOAuth.new }, anthropic_oauth_factory: -> { AnthropicOAuth.new }, config_manager: ConfigManager.new)
14
18
  @server = server
15
19
  @oauth_factory = oauth_factory
16
20
  @github_oauth_factory = github_oauth_factory
21
+ @anthropic_oauth_factory = anthropic_oauth_factory
17
22
  @config_manager = config_manager
18
23
  @logins = {}
19
24
  @mutex = Mutex.new
@@ -27,6 +32,7 @@ module Kward
27
32
  openaiAccountId: oauth.respond_to?(:account_id) ? oauth.account_id : nil,
28
33
  openrouterApiKey: !ENV["OPENROUTER_API_KEY"].to_s.empty? || !config["openrouter_api_key"].to_s.empty?,
29
34
  openaiAccessToken: !ENV["OPENAI_ACCESS_TOKEN"].to_s.empty?,
35
+ anthropicOAuth: @anthropic_oauth_factory.call.logged_in?,
30
36
  githubOAuth: @github_oauth_factory.call.logged_in?
31
37
  }
32
38
  rescue StandardError => e
@@ -34,7 +40,7 @@ module Kward
34
40
  end
35
41
 
36
42
  def providers
37
- { providers: [openai_provider, openrouter_provider, github_provider] }
43
+ { providers: [openai_provider, anthropic_provider, openrouter_provider, github_provider] }
38
44
  end
39
45
 
40
46
  def login_with_api_key(provider_id:, api_key:)
@@ -49,6 +55,9 @@ module Kward
49
55
  when "openai"
50
56
  logout_openai
51
57
  { providerId: provider_id, message: "Logged out of OpenAI." }
58
+ when "anthropic"
59
+ logout_anthropic
60
+ { providerId: provider_id, message: "Logged out of Anthropic." }
52
61
  when "openrouter"
53
62
  @config_manager.delete_key("openrouter_api_key")
54
63
  { providerId: provider_id, message: "Logged out of OpenRouter." }
@@ -61,7 +70,9 @@ module Kward
61
70
  provider_id = provider_id.to_s
62
71
  case provider_id
63
72
  when "openai"
64
- start_openai_login(timeout_seconds: timeout_seconds)
73
+ start_oauth_login(provider_id: "openai", oauth: @oauth_factory.call, timeout_seconds: timeout_seconds)
74
+ when "anthropic"
75
+ start_oauth_login(provider_id: "anthropic", oauth: @anthropic_oauth_factory.call, timeout_seconds: timeout_seconds)
65
76
  when "github"
66
77
  raise "GitHub OAuth is supported in the CLI with `ruby lib/main.rb login github`, but RPC browser login is not implemented yet."
67
78
  else
@@ -70,7 +81,10 @@ module Kward
70
81
  end
71
82
 
72
83
  def start_openai_login(timeout_seconds: 120)
73
- oauth = @oauth_factory.call
84
+ start_oauth_login(provider_id: "openai", oauth: @oauth_factory.call, timeout_seconds: timeout_seconds)
85
+ end
86
+
87
+ def start_oauth_login(provider_id:, oauth:, timeout_seconds: 120)
74
88
  flow = oauth.start_login_flow
75
89
  pkce = flow.fetch(:pkce)
76
90
  state = flow.fetch(:state)
@@ -79,6 +93,7 @@ module Kward
79
93
  url = flow.fetch(:authorization_url)
80
94
  login = Login.new(
81
95
  id: SecureRandom.uuid,
96
+ provider_id: provider_id,
82
97
  oauth: oauth,
83
98
  pkce: pkce,
84
99
  state: state,
@@ -88,7 +103,7 @@ module Kward
88
103
  )
89
104
  @mutex.synchronize { @logins[login.id] = login }
90
105
  login.thread = Thread.new { wait_for_callback(login, timeout_seconds: timeout_seconds.to_i <= 0 ? 120 : timeout_seconds.to_i) }
91
- { providerId: "openai", loginId: login.id, authorizationUrl: url, redirectUri: redirect_uri, status: login.status }
106
+ { providerId: provider_id, loginId: login.id, authorizationUrl: url, redirectUri: redirect_uri, status: login.status }
92
107
  end
93
108
 
94
109
  def submit_openai_code(login_id:, code:)
@@ -146,6 +161,34 @@ module Kward
146
161
  }.compact
147
162
  end
148
163
 
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
180
+ {
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
+
149
192
  def openrouter_provider
150
193
  config = stored_config
151
194
  env_configured = !ENV["OPENROUTER_API_KEY"].to_s.empty?
@@ -200,6 +243,7 @@ module Kward
200
243
  case provider_id
201
244
  when "openrouter" then "OpenRouter"
202
245
  when "openai" then "OpenAI"
246
+ when "anthropic" then "Anthropic"
203
247
  when "github" then "GitHub"
204
248
  else provider_id
205
249
  end
@@ -211,6 +255,12 @@ module Kward
211
255
  File.delete(path) if path && File.exist?(path)
212
256
  end
213
257
 
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)
262
+ end
263
+
214
264
  def wait_for_callback(login, timeout_seconds:)
215
265
  code = login.oauth.wait_for_login_callback(login.server, expected_state: login.state, timeout_seconds: timeout_seconds)
216
266
  complete_login(login, code) unless code.to_s.empty?
@@ -239,7 +289,7 @@ module Kward
239
289
 
240
290
  def login_payload(login)
241
291
  {
242
- providerId: "openai",
292
+ providerId: provider_id_for_login(login),
243
293
  loginId: login.id,
244
294
  status: login.status,
245
295
  redirectUri: login.redirect_uri,
@@ -248,16 +298,20 @@ module Kward
248
298
  }.compact
249
299
  end
250
300
 
301
+ def provider_id_for_login(login)
302
+ login.provider_id || "openai"
303
+ end
304
+
251
305
  def login_status_message(status)
252
306
  case status
253
307
  when "completed"
254
- "Logged in to OpenAI."
308
+ "OAuth login completed."
255
309
  when "failed"
256
- "OpenAI login failed."
310
+ "OAuth login failed."
257
311
  when "cancelled"
258
- "OpenAI login cancelled."
312
+ "OAuth login cancelled."
259
313
  else
260
- "OpenAI login pending."
314
+ "OAuth login pending."
261
315
  end
262
316
  end
263
317
  end
@@ -3,8 +3,11 @@ require_relative "../config_files"
3
3
  require_relative "../model/model_info"
4
4
  require_relative "redactor"
5
5
 
6
+ # Namespace for the Kward CLI agent runtime.
6
7
  module Kward
8
+ # JSON-RPC backend namespace used by UI clients.
7
9
  module RPC
10
+ # RPC configuration manager for reading and updating user config.
8
11
  class ConfigManager
9
12
  def initialize(config_path: OpenAIOAuth.default_config_path)
10
13
  @config_path = File.expand_path(config_path)
@@ -48,6 +51,14 @@ module Kward
48
51
  ConfigFiles.delete_config_key(key, @config_path)
49
52
  end
50
53
 
54
+ def workspace_guardrails_enabled?
55
+ ConfigFiles.workspace_guardrails_enabled?(read(redacted: false))
56
+ end
57
+
58
+ def session_auto_resume_enabled?
59
+ ConfigFiles.session_auto_resume_enabled?(read(redacted: false))
60
+ end
61
+
51
62
  private
52
63
 
53
64
  def load_config
@@ -1,14 +1,13 @@
1
1
  require "securerandom"
2
2
  require_relative "../message_access"
3
+ require_relative "../question_contract"
3
4
 
5
+ # Namespace for the Kward CLI agent runtime.
4
6
  module Kward
7
+ # JSON-RPC backend namespace used by UI clients.
5
8
  module RPC
9
+ # RPC prompt bridge for structured user questions.
6
10
  class PromptBridge
7
- MIN_QUESTIONS = 1
8
- MAX_QUESTIONS = 4
9
- MIN_OPTIONS = 2
10
- MAX_OPTIONS = 4
11
-
12
11
  def initialize(server:, session_id:)
13
12
  @server = server
14
13
  @session_id = session_id
@@ -77,27 +76,7 @@ module Kward
77
76
  end
78
77
 
79
78
  def validate_questions(questions)
80
- raise ArgumentError, "questions must be an array" unless questions.is_a?(Array)
81
- unless questions.length.between?(MIN_QUESTIONS, MAX_QUESTIONS)
82
- raise ArgumentError, "ui/question requires 1-4 questions"
83
- end
84
-
85
- questions.each_with_index do |question, index|
86
- validate_question(question, index)
87
- end
88
- questions
89
- end
90
-
91
- def validate_question(question, index)
92
- raise ArgumentError, "question #{index + 1} must be an object" unless question.is_a?(Hash)
93
-
94
- options = MessageAccess.value(question, :options)
95
- raise ArgumentError, "question #{index + 1} options must be an array" unless options.is_a?(Array)
96
- unless options.length.between?(MIN_OPTIONS, MAX_OPTIONS)
97
- raise ArgumentError, "question #{index + 1} requires 2-4 options"
98
- end
99
- raise ArgumentError, "question #{index + 1} multiSelect is unsupported" if MessageAccess.value(question, :multiSelect) == true
100
- raise ArgumentError, "question #{index + 1} preview is unsupported" if options.any? { |option| option.is_a?(Hash) && MessageAccess.value(option, :preview) }
79
+ QuestionContract.normalize_questions(questions)
101
80
  end
102
81
  end
103
82
  end
@@ -1,5 +1,8 @@
1
+ # Namespace for the Kward CLI agent runtime.
1
2
  module Kward
3
+ # JSON-RPC backend namespace used by UI clients.
2
4
  module RPC
5
+ # Redacts sensitive configuration values before RPC responses.
3
6
  module Redactor
4
7
  SECRET_KEYS = /(?:token|secret|api[_-]?key|authorization|password|credential)/i
5
8
 
@@ -1,5 +1,8 @@
1
+ # Namespace for the Kward CLI agent runtime.
1
2
  module Kward
3
+ # JSON-RPC backend namespace used by UI clients.
2
4
  module RPC
5
+ # Builders for Tauren-compatible runtime state payloads.
3
6
  module RuntimePayloads
4
7
  module_function
5
8
 
@@ -46,7 +49,7 @@ module Kward
46
49
  toolCalls: counts[:toolCalls],
47
50
  toolResults: counts[:toolResults],
48
51
  totalMessages: counts[:totalMessages],
49
- usingSubscription: model[:provider] == "Codex",
52
+ usingSubscription: ["Codex", "Anthropic"].include?(model[:provider]),
50
53
  autoCompactionEnabled: compaction_enabled,
51
54
  autoCompactionReserveTokens: auto_compaction_reserve_tokens,
52
55
  contextUsage: context_usage