kward 0.77.0 → 0.79.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +7 -2
  3. data/.github/workflows/pages.yml +1 -1
  4. data/CHANGELOG.md +50 -0
  5. data/Gemfile.lock +2 -2
  6. data/README.md +5 -2
  7. data/doc/agent-tools.md +1 -1
  8. data/doc/code-search.md +9 -6
  9. data/doc/configuration.md +106 -1
  10. data/doc/extensibility.md +2 -0
  11. data/doc/getting-started.md +1 -1
  12. data/doc/local-models.md +130 -0
  13. data/doc/permissions.md +180 -0
  14. data/doc/plugins.md +58 -0
  15. data/doc/releasing.md +1 -1
  16. data/doc/rpc.md +73 -1
  17. data/doc/sandboxing.md +120 -0
  18. data/doc/security.md +15 -5
  19. data/doc/skills.md +10 -0
  20. data/doc/tabs.md +4 -3
  21. data/doc/web-search.md +4 -2
  22. data/doc/workspace-tools.md +3 -3
  23. data/kward.gemspec +1 -1
  24. data/lib/kward/auth/anthropic_oauth.rb +2 -2
  25. data/lib/kward/auth/github_oauth.rb +3 -3
  26. data/lib/kward/auth/oauth_helpers.rb +4 -2
  27. data/lib/kward/auth/openai_oauth.rb +2 -1
  28. data/lib/kward/cli/doctor.rb +21 -0
  29. data/lib/kward/cli/plugins.rb +22 -0
  30. data/lib/kward/cli/rendering.rb +7 -1
  31. data/lib/kward/cli/runtime_helpers.rb +15 -1
  32. data/lib/kward/cli/settings.rb +66 -4
  33. data/lib/kward/cli/slash_commands.rb +109 -1
  34. data/lib/kward/cli/tabs.rb +140 -34
  35. data/lib/kward/cli.rb +31 -10
  36. data/lib/kward/config_files.rb +90 -1
  37. data/lib/kward/conversation.rb +14 -1
  38. data/lib/kward/hooks/http_handler.rb +2 -1
  39. data/lib/kward/http.rb +18 -0
  40. data/lib/kward/local_command_runner.rb +2 -2
  41. data/lib/kward/model/client.rb +173 -15
  42. data/lib/kward/model/model_info.rb +11 -1
  43. data/lib/kward/model/payloads.rb +7 -1
  44. data/lib/kward/model/stream_parser.rb +58 -26
  45. data/lib/kward/openrouter_model_cache.rb +2 -1
  46. data/lib/kward/pan/index.html.erb +50 -0
  47. data/lib/kward/pan/server.rb +49 -2
  48. data/lib/kward/permissions/policy.rb +171 -0
  49. data/lib/kward/plugin_registry.rb +54 -2
  50. data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
  51. data/lib/kward/prompt_interface/editor/controller.rb +36 -3
  52. data/lib/kward/prompt_interface/question_prompt.rb +12 -3
  53. data/lib/kward/prompt_interface.rb +20 -0
  54. data/lib/kward/prompts/commands.rb +2 -0
  55. data/lib/kward/prompts.rb +16 -5
  56. data/lib/kward/rpc/plugin_chat_manager.rb +302 -0
  57. data/lib/kward/rpc/server.rb +76 -3
  58. data/lib/kward/rpc/session_manager.rb +43 -8
  59. data/lib/kward/rpc/transcript_normalizer.rb +14 -5
  60. data/lib/kward/sandbox/capabilities.rb +39 -0
  61. data/lib/kward/sandbox/command_runner.rb +28 -0
  62. data/lib/kward/sandbox/environment.rb +24 -0
  63. data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
  64. data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
  65. data/lib/kward/sandbox/passthrough_runner.rb +13 -0
  66. data/lib/kward/sandbox/policy.rb +74 -0
  67. data/lib/kward/sandbox/runner_factory.rb +55 -0
  68. data/lib/kward/sandbox/unavailable_runner.rb +21 -0
  69. data/lib/kward/sandbox.rb +9 -0
  70. data/lib/kward/session_store.rb +26 -0
  71. data/lib/kward/skills/capture.rb +144 -0
  72. data/lib/kward/starter_pack_installer.rb +3 -1
  73. data/lib/kward/tab_driver.rb +87 -0
  74. data/lib/kward/tab_store.rb +74 -12
  75. data/lib/kward/tools/code_search.rb +8 -2
  76. data/lib/kward/tools/fetch_content.rb +4 -2
  77. data/lib/kward/tools/fetch_raw.rb +3 -1
  78. data/lib/kward/tools/registry.rb +56 -8
  79. data/lib/kward/tools/search/code.rb +46 -12
  80. data/lib/kward/tools/search/web.rb +60 -17
  81. data/lib/kward/tools/search/web_fetch.rb +206 -38
  82. data/lib/kward/tools/web_search.rb +3 -1
  83. data/lib/kward/update_check.rb +2 -1
  84. data/lib/kward/version.rb +1 -1
  85. data/lib/kward/workspace.rb +18 -3
  86. data/lib/kward/workspace_factory.rb +17 -0
  87. data/templates/default/fulldoc/html/js/kward.js +1 -0
  88. data/templates/default/kward_navigation.rb +5 -2
  89. data/templates/default/layout/html/layout.erb +2 -0
  90. data/templates/default/layout/html/setup.rb +2 -0
  91. metadata +22 -2
@@ -202,8 +202,8 @@ module Kward
202
202
  parse_successful_json(response, "Anthropic OAuth token exchange")
203
203
  end
204
204
 
205
- def post_json(uri, params)
206
- super(uri, params, headers: { "Accept" => "application/json" })
205
+ def post_json(uri, params = nil, **keyword_params)
206
+ super(uri, params, headers: { "Accept" => "application/json" }, **keyword_params)
207
207
  end
208
208
  end
209
209
  end
@@ -4,6 +4,7 @@ require "time"
4
4
  require "uri"
5
5
  require_relative "file"
6
6
  require_relative "../config_files"
7
+ require_relative "../http"
7
8
 
8
9
  # Namespace for the Kward CLI agent runtime.
9
10
  module Kward
@@ -15,7 +16,6 @@ module Kward
15
16
  DEFAULT_SCOPE = "read:user"
16
17
  DEFAULT_CLIENT_ID = "Iv1.b507a08c87ecfe98"
17
18
  COPILOT_HEADERS = {
18
- "User-Agent" => "GitHubCopilotChat/0.35.0",
19
19
  "Editor-Version" => "vscode/1.107.0",
20
20
  "Editor-Plugin-Version" => "copilot-chat/0.35.0",
21
21
  "Copilot-Integration-Id" => "vscode-chat"
@@ -150,7 +150,7 @@ module Kward
150
150
  end
151
151
 
152
152
  def post_form(uri, params)
153
- request = Net::HTTP::Post.new(uri)
153
+ request = Http.apply_user_agent(Net::HTTP::Post.new(uri))
154
154
  request["Content-Type"] = "application/x-www-form-urlencoded"
155
155
  request["Accept"] = "application/json"
156
156
  request.body = URI.encode_www_form(params)
@@ -160,7 +160,7 @@ module Kward
160
160
  end
161
161
 
162
162
  def get_json(uri, headers = {})
163
- request = Net::HTTP::Get.new(uri)
163
+ request = Http.apply_user_agent(Net::HTTP::Get.new(uri))
164
164
  request["Accept"] = "application/json"
165
165
  COPILOT_HEADERS.merge(headers).each { |key, value| request[key] = value }
166
166
 
@@ -6,6 +6,7 @@ require "securerandom"
6
6
  require "socket"
7
7
  require "time"
8
8
  require "uri"
9
+ require_relative "../http"
9
10
 
10
11
  # Namespace for the Kward CLI agent runtime.
11
12
  module Kward
@@ -58,8 +59,9 @@ module Kward
58
59
  socket&.close
59
60
  end
60
61
 
61
- def post_json(uri, params, headers: {})
62
- request = Net::HTTP::Post.new(uri)
62
+ def post_json(uri, params = nil, headers: {}, **keyword_params)
63
+ params = (params || {}).merge(keyword_params)
64
+ request = Http.apply_user_agent(Net::HTTP::Post.new(uri))
63
65
  request["Content-Type"] = "application/json"
64
66
  headers.each { |key, value| request[key] = value }
65
67
  request.body = JSON.dump(params)
@@ -5,6 +5,7 @@ require "time"
5
5
  require "uri"
6
6
  require_relative "file"
7
7
  require_relative "oauth_helpers"
8
+ require_relative "../http"
8
9
 
9
10
  # Namespace for the Kward CLI agent runtime.
10
11
  module Kward
@@ -215,7 +216,7 @@ module Kward
215
216
  end
216
217
 
217
218
  def post_form(uri, params)
218
- request = Net::HTTP::Post.new(uri)
219
+ request = Http.apply_user_agent(Net::HTTP::Post.new(uri))
219
220
  request["Content-Type"] = "application/x-www-form-urlencoded"
220
221
  request.body = URI.encode_www_form(params)
221
222
 
@@ -25,6 +25,7 @@ module Kward
25
25
  doctor_directory_check("Session directory", SessionStore.new(cwd: current_workspace_root).session_dir, create: true),
26
26
  doctor_workspace_check,
27
27
  doctor_model_check,
28
+ doctor_local_endpoint_check(config),
28
29
  doctor_auth_check(config),
29
30
  doctor_pan_check(config_result),
30
31
  { status: :ok, label: "Color", message: @color_enabled ? "enabled" : "disabled" }
@@ -89,14 +90,34 @@ module Kward
89
90
  { status: :warning, label: "Model", message: e.message }
90
91
  end
91
92
 
93
+ def doctor_local_endpoint_check(config)
94
+ provider = ENV["KWARD_PROVIDER"].to_s.strip
95
+ provider = config["provider"].to_s.strip if provider.empty?
96
+ return { status: :ok, label: "Local endpoint", message: "not selected" } unless provider.casecmp?("local")
97
+
98
+ backend = ENV["KWARD_LOCAL_BACKEND"].to_s.strip
99
+ backend = config["local_backend"].to_s.strip if backend.empty?
100
+ defaults = Kward::Client::LOCAL_BASE_URLS
101
+ url = ENV["KWARD_LOCAL_BASE_URL"].to_s.strip
102
+ url = config["local_base_url"].to_s.strip if url.empty?
103
+ url = defaults.fetch(backend.empty? ? "ollama" : backend, defaults.fetch("ollama")) if url.empty?
104
+ uri = URI.parse(url)
105
+ loopback = ["127.0.0.1", "::1", "localhost"].include?(uri.host)
106
+ { status: loopback ? :ok : :warning, label: "Local endpoint", message: "#{url}#{loopback ? " (loopback)" : " (non-loopback)"}" }
107
+ rescue URI::InvalidURIError
108
+ { status: :error, label: "Local endpoint", message: "invalid URL: #{url}" }
109
+ end
110
+
92
111
  def doctor_auth_check(config)
93
112
  openai_auth = OpenAIOAuth.default_auth_path
94
113
  github_auth = GithubOAuth.default_auth_path
95
114
  has_openrouter = !config.to_h["openrouter_api_key"].to_s.empty? || !ENV["OPENROUTER_API_KEY"].to_s.empty?
115
+ local_provider = @client.respond_to?(:current_provider) && @client.current_provider == "Local"
96
116
  paths = []
97
117
  paths << "OpenAI OAuth" if File.exist?(openai_auth)
98
118
  paths << "GitHub OAuth" if File.exist?(github_auth)
99
119
  paths << "OpenRouter API key" if has_openrouter
120
+ paths << "Local endpoint (no authentication required)" if local_provider
100
121
  return { status: :ok, label: "Auth", message: paths.join(", ") } if paths.any?
101
122
 
102
123
  { status: :warning, label: "Auth", message: "no saved credentials found; run `kward login`" }
@@ -102,6 +102,17 @@ module Kward
102
102
  end
103
103
  end
104
104
 
105
+ def update_plugin_tab_slash_commands(tab)
106
+ return unless @prompt.respond_to?(:update_slash_commands)
107
+
108
+ entries = if tab&.driver&.respond_to?(:slash_command_entries)
109
+ tab.driver.slash_command_entries
110
+ else
111
+ []
112
+ end
113
+ @prompt.update_slash_commands(slash_command_entries + Array(entries))
114
+ end
115
+
105
116
  def slash_command_entries
106
117
  prompt_entries = prompt_templates.map do |template|
107
118
  {
@@ -192,6 +203,17 @@ module Kward
192
203
  plugin_registry.notify_transcript_event(event, plugin_context(conversation, ""))
193
204
  end
194
205
 
206
+ def notify_plugin_tab_transcript_event(event, driver)
207
+ return if plugin_registry.transcript_event_handlers.empty?
208
+
209
+ context = PluginRegistry::Context.new(
210
+ conversation: driver,
211
+ workspace_root: current_workspace_root,
212
+ say_callback: lambda { |message| runtime_output(message) }
213
+ )
214
+ plugin_registry.notify_transcript_event(event, context)
215
+ end
216
+
195
217
  end
196
218
  end
197
219
  end
@@ -7,9 +7,15 @@ module Kward
7
7
  private
8
8
 
9
9
  def render_conversation_transcript(conversation)
10
+ render_transcript_messages(conversation.messages)
11
+ end
12
+
13
+ # Renders a transcript supplied by either a Kward conversation or a
14
+ # plugin-owned tab driver.
15
+ def render_transcript_messages(messages)
10
16
  tool_calls_by_id = {}
11
17
  @prompt.say("\n#{colored("Transcript", :gray, :bold)}\n")
12
- conversation.messages.each do |message|
18
+ Array(messages).each do |message|
13
19
  role = message_role(message)
14
20
  next if role == "system"
15
21
 
@@ -50,6 +50,7 @@ module Kward
50
50
  tool_registry = ToolRegistry.new(
51
51
  workspace: workspace,
52
52
  prompt: @prompt,
53
+ tool_approval: interactive_tool_approval_callback,
53
54
  hook_manager: hook_manager,
54
55
  hook_context: hook_context
55
56
  )
@@ -64,6 +65,19 @@ module Kward
64
65
  )
65
66
  end
66
67
 
68
+ def interactive_tool_approval_callback
69
+ return nil unless @prompt.respond_to?(:ask_tool_approval)
70
+ return nil unless ConfigFiles.permission_policy(safely_read_config.to_h).enabled?
71
+
72
+ lambda do |tool_call:, name:, args:, cancellation:|
73
+ @prompt.ask_tool_approval(
74
+ tool_name: name,
75
+ args: args,
76
+ reason: args["hook_message"] || args[:hook_message]
77
+ )
78
+ end
79
+ end
80
+
67
81
  def handle_interactive_shell_command(input, agent)
68
82
  command = input.to_s.sub(/\A!\s*/, "")
69
83
  if command.strip.empty?
@@ -304,7 +318,7 @@ module Kward
304
318
  end
305
319
 
306
320
  def configured_workspace(root: current_workspace_root)
307
- Workspace.new(root: root, guardrails: workspace_guardrails_enabled?)
321
+ WorkspaceFactory.build(root: root, guardrails: workspace_guardrails_enabled?, config: safely_read_config.to_h)
308
322
  end
309
323
 
310
324
  def workspace_guardrails_enabled?
@@ -88,7 +88,7 @@ module Kward
88
88
  def configure_model_settings(conversation)
89
89
  initial_index = 0
90
90
  loop do
91
- selected, selected_index = select_settings_menu_item("Model & Reasoning", ["Provider", "Default model", "Reasoning effort", "Back"], initial_index)
91
+ selected, selected_index = select_settings_menu_item("Model & Reasoning", ["Provider", "Default model", "Local server preset", "Reasoning effort", "Back"], initial_index)
92
92
  break unless selected
93
93
 
94
94
  initial_index = selected_index if selected_index
@@ -97,6 +97,8 @@ module Kward
97
97
  configure_provider(conversation)
98
98
  when /\Adefault model/
99
99
  configure_model(conversation)
100
+ when /\Alocal server preset/
101
+ configure_local_server_preset(conversation)
100
102
  when /\Areasoning effort/
101
103
  configure_reasoning(conversation)
102
104
  else
@@ -118,7 +120,7 @@ module Kward
118
120
 
119
121
  def provider_choices
120
122
  current = current_model_provider
121
- ["Codex", "Anthropic", "OpenRouter", "Copilot"].map do |provider|
123
+ ["Codex", "Anthropic", "OpenRouter", "Copilot", "Local"].map do |provider|
122
124
  label = provider.dup
123
125
  label += " (current)" if provider == current
124
126
  label
@@ -131,10 +133,39 @@ module Kward
131
133
  return "Anthropic" if text.start_with?("anthropic") || text.start_with?("claude")
132
134
  return "OpenRouter" if text.start_with?("openrouter")
133
135
  return "Copilot" if text.start_with?("copilot")
136
+ return "Local" if text.start_with?("local")
134
137
 
135
138
  nil
136
139
  end
137
140
 
141
+ def configure_local_server_preset(conversation)
142
+ choices = [
143
+ "Ollama (http://127.0.0.1:11434/v1)",
144
+ "LM Studio (http://127.0.0.1:1234/v1)",
145
+ "llama.cpp (http://127.0.0.1:8080/v1)",
146
+ "Custom endpoint (edit config)",
147
+ "Back"
148
+ ]
149
+ selected = @prompt.select("Local server preset", choices, title: "Settings")
150
+ return unless selected
151
+
152
+ backend, url = case selected.to_s
153
+ when /\AOllama/ then ["ollama", "http://127.0.0.1:11434/v1"]
154
+ when /\ALM Studio/ then ["lm_studio", "http://127.0.0.1:1234/v1"]
155
+ when /\Allama\.cpp/ then ["llama_cpp", "http://127.0.0.1:8080/v1"]
156
+ when /\ACustom endpoint/
157
+ runtime_output("Set local_base_url in #{ConfigFiles.config_path}.")
158
+ return
159
+ else
160
+ return
161
+ end
162
+
163
+ ConfigFiles.update_config("local_backend" => backend, "local_base_url" => url)
164
+ reload_client_config
165
+ refresh_conversation_runtime(conversation)
166
+ @prompt.redraw if @prompt.respond_to?(:redraw)
167
+ end
168
+
138
169
  def configure_account_settings
139
170
  initial_index = 0
140
171
  loop do
@@ -524,6 +555,10 @@ module Kward
524
555
  case selected.to_s.downcase
525
556
  when /\Adefault persona/
526
557
  configure_default_persona(conversation)
558
+ when /\Aenable global principles/, /\Adisable global principles/
559
+ toggle_global_principles(conversation)
560
+ when /\Aglobal principles \(ignored/
561
+ runtime_output("Global principles are ignored while a replacement system prompt is configured.")
527
562
  when /\Aactive instructions/
528
563
  show_active_instructions_summary(conversation)
529
564
  else
@@ -533,13 +568,31 @@ module Kward
533
568
  end
534
569
 
535
570
  def personalization_setting_choices(conversation)
571
+ config = safely_read_config.to_h
572
+ replacement = ConfigFiles.system_prompt_file_path(config)
573
+ principles = if replacement
574
+ "Global principles (ignored by replacement)"
575
+ elsif ConfigFiles.include_config_principles?(config)
576
+ "Disable global principles"
577
+ else
578
+ "Enable global principles"
579
+ end
536
580
  [
537
581
  "Default persona (#{default_persona_label})",
582
+ principles,
538
583
  "Active instructions summary",
539
584
  "Back"
540
585
  ]
541
586
  end
542
587
 
588
+ def toggle_global_principles(conversation)
589
+ config = safely_read_config.to_h
590
+ settings = config["system_prompt"].is_a?(Hash) ? config["system_prompt"] : {}
591
+ ConfigFiles.update_config("system_prompt" => settings.merge("include_principles" => !ConfigFiles.include_config_principles?(config)))
592
+ conversation&.refresh_system_message!
593
+ @prompt.redraw if @prompt.respond_to?(:redraw)
594
+ end
595
+
543
596
  def default_persona_label
544
597
  personas = safely_read_config.to_h["personas"]
545
598
  value = personas.is_a?(Hash) ? personas["default"] : nil
@@ -569,9 +622,18 @@ module Kward
569
622
 
570
623
  def show_active_instructions_summary(conversation)
571
624
  label = ConfigFiles.active_persona_label(workspace_root: current_workspace_root, model: current_model_id, config: safely_read_config.to_h)
625
+ config = safely_read_config.to_h
626
+ replacement_path = ConfigFiles.system_prompt_file_path(config)
572
627
  lines = ["Active persona: #{label || "none"}"]
573
- lines << "Global PRINCIPLES.md: #{ConfigFiles.agents_prompt ? "present" : "absent"}"
574
- lines << "Workspace AGENTS.md: #{ConfigFiles.workspace_agents_prompt(current_workspace_root) ? "present" : "absent"}"
628
+ if replacement_path
629
+ lines << "System prompt: replacement (#{replacement_path})"
630
+ lines << "Global PRINCIPLES.md: ignored by replacement"
631
+ lines << "Workspace AGENTS.md: ignored by replacement"
632
+ else
633
+ lines << "System prompt: Kward default"
634
+ lines << "Global PRINCIPLES.md: #{ConfigFiles.include_config_principles?(config) ? (ConfigFiles.agents_prompt ? "present" : "absent") : "disabled"}"
635
+ lines << "Workspace AGENTS.md: #{ConfigFiles.workspace_agents_prompt(current_workspace_root) ? "present" : "absent"}"
636
+ end
575
637
  lines << "Messages: #{conversation.messages.length}" if conversation&.respond_to?(:messages)
576
638
  runtime_output(lines.join("\n"))
577
639
  end
@@ -15,6 +15,9 @@ module Kward
15
15
  when "stats"
16
16
  run_busy_local_command_and_requeue { print_stats(argument) }
17
17
  [true, nil]
18
+ when "sandbox"
19
+ run_busy_local_command_and_requeue { handle_sandbox_command(argument, interactive_workspace_root(agent)) }
20
+ [true, nil]
18
21
  when "memory"
19
22
  activity = memory_summarize_command?(argument) ? "summarizing" : "loading"
20
23
  run_busy_local_command_and_requeue(activity: activity) { handle_memory_command(argument, agent) }
@@ -23,7 +26,11 @@ module Kward
23
26
  run_busy_local_command_and_requeue { handle_hooks_command(argument) }
24
27
  [true, nil]
25
28
  when "skill"
26
- run_busy_local_command_and_requeue { activate_skill_command(argument, agent) }
29
+ if argument.to_s.strip == "capture"
30
+ capture_skill_from_session(session_store)
31
+ else
32
+ run_busy_local_command_and_requeue { activate_skill_command(argument, agent) }
33
+ end
27
34
  [true, nil]
28
35
  when "redraw"
29
36
  run_busy_local_command_and_requeue { @prompt.redraw if @prompt.respond_to?(:redraw) }
@@ -114,6 +121,48 @@ module Kward
114
121
  PromptCommands.parse(command) || [nil, ""]
115
122
  end
116
123
 
124
+ def handle_sandbox_command(argument, workspace_root)
125
+ action, value = argument.to_s.strip.split(/\s+/, 2)
126
+ case action
127
+ when nil, "", "status"
128
+ print_sandbox_status(workspace_root)
129
+ when *Sandbox::Policy::MODES
130
+ ConfigFiles.update_nested_config("sandbox", { "mode" => action })
131
+ runtime_output("Command sandbox mode set to #{action}. New sessions and tabs use the updated policy.")
132
+ when "network"
133
+ network = value.to_s
134
+ unless Sandbox::Policy::NETWORK_MODES.include?(network)
135
+ runtime_output("Usage: /sandbox network allow|deny")
136
+ return
137
+ end
138
+
139
+ ConfigFiles.update_nested_config("sandbox", { "network" => network })
140
+ runtime_output("Command sandbox child network set to #{network}. New sessions and tabs use the updated policy.")
141
+ else
142
+ runtime_output("Usage: /sandbox [status|off|read_only|workspace_write|network allow|network deny]")
143
+ end
144
+ rescue ArgumentError => error
145
+ runtime_output("Sandbox configuration error: #{error.message}")
146
+ end
147
+
148
+ def print_sandbox_status(workspace_root)
149
+ config = ConfigFiles.read_config
150
+ policy = ConfigFiles.sandbox_policy(workspace_root, config)
151
+ runner = Sandbox::RunnerFactory.build(policy)
152
+ capabilities = runner.capabilities
153
+ lines = [
154
+ "Command sandbox",
155
+ "Mode: #{policy.mode}",
156
+ "Child network: #{policy.network}",
157
+ "Backend: #{capabilities.backend}",
158
+ "Filesystem enforcement: #{capabilities.filesystem_enforced? ? "active" : "inactive"}",
159
+ "Child-network enforcement: #{capabilities.child_network_enforced? ? "active" : "inactive"}",
160
+ "Scope: model-requested run_shell_command workers only"
161
+ ]
162
+ lines << "Reason: #{capabilities.reason}" unless capabilities.reason.to_s.empty?
163
+ runtime_output(lines.join("\n"))
164
+ end
165
+
117
166
  def open_or_resume_session(session_store, argument)
118
167
  unless session_store
119
168
  say_sessions_unavailable
@@ -144,6 +193,65 @@ module Kward
144
193
  end
145
194
  end
146
195
 
196
+ def capture_skill_from_session(session_store)
197
+ unless session_store
198
+ say_sessions_unavailable
199
+ return
200
+ end
201
+ unless @prompt.respond_to?(:review_document)
202
+ runtime_output("Skill capture is available only in the interactive terminal prompt.")
203
+ return
204
+ end
205
+
206
+ source_path = select_skill_capture_session(session_store)
207
+ return unless source_path
208
+
209
+ capture = Skills::Capture.new(session_store: session_store, client: @client)
210
+ draft = run_busy_local_command_and_requeue(activity: "capturing skill") { capture.generate(source_path) }
211
+ review_captured_skill(capture, draft)
212
+ rescue Skills::Capture::Error => error
213
+ runtime_output("Skill capture failed: #{error.message}")
214
+ end
215
+
216
+ def select_skill_capture_session(session_store)
217
+ sessions = session_store.capture_candidates
218
+ if sessions.empty?
219
+ runtime_output("No saved sessions found.")
220
+ return nil
221
+ end
222
+
223
+ labels = session_picker_labels(sessions)
224
+ if @prompt.respond_to?(:select)
225
+ choice = @prompt.select("Capture session>", labels, title: "Capture skill from session")
226
+ return sessions[labels.index(choice)]&.path
227
+ end
228
+
229
+ runtime_output((["Saved sessions:"] + labels.each_with_index.map { |label, index| "#{index + 1}. #{label}" }).join("\n"))
230
+ answer = @prompt.ask("Session number or path>").to_s.strip
231
+ answer.match?(/\A\d+\z/) ? sessions[answer.to_i - 1]&.path : answer
232
+ end
233
+
234
+ def review_captured_skill(capture, draft)
235
+ overwrite = false
236
+ saved = nil
237
+ @prompt.review_document(title: "Review captured skill", content: draft.content) do |content|
238
+ begin
239
+ saved = capture.save(content, overwrite: overwrite)
240
+ nil
241
+ rescue Skills::Capture::ConflictError
242
+ if overwrite
243
+ "Could not overwrite the existing skill. Review the name and try again."
244
+ else
245
+ overwrite = true
246
+ "A personal skill with this name exists. Press Ctrl+S again to overwrite it."
247
+ end
248
+ rescue Skills::Capture::Error => error
249
+ error.message
250
+ end
251
+ end
252
+ runtime_output("Saved personal skill: #{capture.skill_path(saved.name)}") if saved
253
+ end
254
+
147
255
  def activate_skill_command(name, agent)
148
256
  skill_name = name.to_s.strip
149
257
  if skill_name.empty?