kward 0.78.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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +7 -2
- data/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +24 -0
- data/Gemfile.lock +2 -2
- data/README.md +4 -2
- data/doc/agent-tools.md +1 -1
- data/doc/configuration.md +68 -2
- data/doc/extensibility.md +2 -0
- data/doc/getting-started.md +1 -1
- data/doc/local-models.md +130 -0
- data/doc/permissions.md +3 -2
- data/doc/releasing.md +1 -1
- data/doc/rpc.md +12 -1
- data/doc/sandboxing.md +120 -0
- data/doc/security.md +11 -5
- data/doc/skills.md +10 -0
- data/doc/workspace-tools.md +3 -3
- data/kward.gemspec +1 -1
- data/lib/kward/cli/doctor.rb +21 -0
- data/lib/kward/cli/runtime_helpers.rb +1 -1
- data/lib/kward/cli/settings.rb +66 -4
- data/lib/kward/cli/slash_commands.rb +109 -1
- data/lib/kward/cli.rb +2 -1
- data/lib/kward/config_files.rb +80 -1
- data/lib/kward/conversation.rb +14 -1
- data/lib/kward/local_command_runner.rb +2 -2
- data/lib/kward/model/client.rb +140 -4
- data/lib/kward/model/model_info.rb +11 -1
- data/lib/kward/model/payloads.rb +1 -0
- data/lib/kward/model/stream_parser.rb +58 -26
- data/lib/kward/pan/index.html.erb +50 -0
- data/lib/kward/pan/server.rb +49 -2
- data/lib/kward/plugin_registry.rb +1 -1
- data/lib/kward/prompt_interface/editor/controller.rb +36 -3
- data/lib/kward/prompt_interface.rb +18 -0
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts.rb +16 -5
- data/lib/kward/rpc/plugin_chat_manager.rb +4 -1
- data/lib/kward/rpc/server.rb +42 -4
- data/lib/kward/rpc/session_manager.rb +42 -8
- data/lib/kward/sandbox/capabilities.rb +39 -0
- data/lib/kward/sandbox/command_runner.rb +28 -0
- data/lib/kward/sandbox/environment.rb +24 -0
- data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
- data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
- data/lib/kward/sandbox/passthrough_runner.rb +13 -0
- data/lib/kward/sandbox/policy.rb +74 -0
- data/lib/kward/sandbox/runner_factory.rb +55 -0
- data/lib/kward/sandbox/unavailable_runner.rb +21 -0
- data/lib/kward/sandbox.rb +9 -0
- data/lib/kward/session_store.rb +26 -0
- data/lib/kward/skills/capture.rb +144 -0
- data/lib/kward/tools/registry.rb +19 -4
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace.rb +18 -3
- data/lib/kward/workspace_factory.rb +17 -0
- data/templates/default/fulldoc/html/js/kward.js +1 -0
- data/templates/default/kward_navigation.rb +4 -2
- data/templates/default/layout/html/setup.rb +1 -0
- metadata +16 -2
data/doc/skills.md
CHANGED
|
@@ -42,6 +42,16 @@ The `name` and `description` fields are required. The description matters: Kward
|
|
|
42
42
|
|
|
43
43
|
Skill names should be lowercase letters, numbers, and hyphens, such as `testing`, `code-review`, or `release-checklist`.
|
|
44
44
|
|
|
45
|
+
## Capture a skill from a session
|
|
46
|
+
|
|
47
|
+
Use `/skill capture` after a useful session. Choose any saved session; Kward uses its active branch and asks your active model to draft a reusable `SKILL.md`. Review and edit the draft in the terminal editor, then save it explicitly. Captured skills always go to your personal Kward skill directory and are not activated automatically.
|
|
48
|
+
|
|
49
|
+
The draft request includes the complete persisted branch, including available raw tool output and saved session context. Treat it like any other model request: do not capture a session whose contents you are not willing to send to the active provider. Kward rejects a source that does not fit the active model context instead of silently truncating it.
|
|
50
|
+
|
|
51
|
+
If the reviewed name already exists, Kward requires an explicit overwrite confirmation. Activate a saved draft later with `/skill <name>`.
|
|
52
|
+
|
|
53
|
+
Pan provides the same saved-session picker and editable review flow. RPC clients use `skills/captureSessions`, `skills/captureDraft`, and `skills/saveCapturedDraft`; see the [RPC protocol](rpc.md).
|
|
54
|
+
|
|
45
55
|
## Skill locations
|
|
46
56
|
|
|
47
57
|
User-level skills are available in every project:
|
data/doc/workspace-tools.md
CHANGED
|
@@ -12,14 +12,14 @@ Kward normally chooses these tools itself. You do not need to know their exact n
|
|
|
12
12
|
|
|
13
13
|
## Guardrails
|
|
14
14
|
|
|
15
|
-
Workspace tools use the active workspace as their boundary. File paths are workspace-relative by default, and file tools are guarded so Kward does not edit arbitrary unread files. Guardrails can be disabled with the `tools.workspace_guardrails` setting — see [Configuration](configuration.md). When disabled, file tools can access paths outside the workspace
|
|
15
|
+
Workspace tools use the active workspace as their boundary. File paths are workspace-relative by default, and file tools are guarded so Kward does not edit arbitrary unread files. Guardrails can be disabled with the `tools.workspace_guardrails` setting — see [Configuration](configuration.md). When disabled, file tools can access paths outside the workspace. These guardrails do not affect shell commands; model-requested commands can instead use the separate, opt-in [command sandbox](sandboxing.md).
|
|
16
16
|
|
|
17
17
|
Important behavior:
|
|
18
18
|
|
|
19
19
|
- Existing files must be read in the current conversation before `write_file` or `edit_file` can change them.
|
|
20
20
|
- Reads are bounded to avoid pulling very large files into context by accident. Files larger than 256 KB cannot be read or edited. Read output is capped at 50 KB or 2,000 lines, whichever comes first.
|
|
21
21
|
- Edits use exact text replacement, so accidental partial or fuzzy changes fail instead of guessing.
|
|
22
|
-
-
|
|
22
|
+
- With sandboxing off (the default), shell commands run as your operating-system user from the workspace. Enable [command sandboxing](sandboxing.md) to apply an OS boundary to model-requested `run_shell_command` workers. Command output is capped at 128 KB.
|
|
23
23
|
|
|
24
24
|
## Reading the workspace
|
|
25
25
|
|
|
@@ -124,7 +124,7 @@ Arguments:
|
|
|
124
124
|
- `command`: command to run.
|
|
125
125
|
- `timeout_seconds`: optional timeout, default 30 seconds.
|
|
126
126
|
|
|
127
|
-
Kward uses shell commands for tests, linters, build checks, and simple repository inspection. Command output is bounded at 128 KB and may be compacted before it is sent back into model context, while the original output remains available in the session record.
|
|
127
|
+
Kward uses shell commands for tests, linters, build checks, and simple repository inspection. When configured, the [command sandbox](sandboxing.md) applies to this tool and its descendants. Command output is bounded at 128 KB and may be compacted before it is sent back into model context, while the original output remains available in the session record.
|
|
128
128
|
|
|
129
129
|
The output format is `Exit status: N` followed by `STDOUT:` and `STDERR:` sections. On timeout, Kward sends SIGTERM then SIGKILL after 0.2 seconds and returns a timeout error. Commands support cooperative cancellation when the session is cancelled.
|
|
130
130
|
|
data/kward.gemspec
CHANGED
|
@@ -10,7 +10,7 @@ Gem::Specification.new do |spec|
|
|
|
10
10
|
spec.description = "Kward is a Ruby CLI coding agent with local workspace tools, configurable prompts, web search, sessions, and an experimental JSON-RPC backend."
|
|
11
11
|
spec.homepage = "https://github.com/kaiwood/kward"
|
|
12
12
|
spec.license = "MIT"
|
|
13
|
-
spec.required_ruby_version = ">= 3.
|
|
13
|
+
spec.required_ruby_version = ">= 3.4"
|
|
14
14
|
|
|
15
15
|
spec.metadata["rubygems_mfa_required"] = "true"
|
|
16
16
|
spec.metadata["source_code_uri"] = "https://github.com/kaiwood/kward"
|
data/lib/kward/cli/doctor.rb
CHANGED
|
@@ -25,6 +25,7 @@ module Kward
|
|
|
25
25
|
doctor_directory_check("Session directory", SessionStore.new(cwd: current_workspace_root).session_dir, create: true),
|
|
26
26
|
doctor_workspace_check,
|
|
27
27
|
doctor_model_check,
|
|
28
|
+
doctor_local_endpoint_check(config),
|
|
28
29
|
doctor_auth_check(config),
|
|
29
30
|
doctor_pan_check(config_result),
|
|
30
31
|
{ status: :ok, label: "Color", message: @color_enabled ? "enabled" : "disabled" }
|
|
@@ -89,14 +90,34 @@ module Kward
|
|
|
89
90
|
{ status: :warning, label: "Model", message: e.message }
|
|
90
91
|
end
|
|
91
92
|
|
|
93
|
+
def doctor_local_endpoint_check(config)
|
|
94
|
+
provider = ENV["KWARD_PROVIDER"].to_s.strip
|
|
95
|
+
provider = config["provider"].to_s.strip if provider.empty?
|
|
96
|
+
return { status: :ok, label: "Local endpoint", message: "not selected" } unless provider.casecmp?("local")
|
|
97
|
+
|
|
98
|
+
backend = ENV["KWARD_LOCAL_BACKEND"].to_s.strip
|
|
99
|
+
backend = config["local_backend"].to_s.strip if backend.empty?
|
|
100
|
+
defaults = Kward::Client::LOCAL_BASE_URLS
|
|
101
|
+
url = ENV["KWARD_LOCAL_BASE_URL"].to_s.strip
|
|
102
|
+
url = config["local_base_url"].to_s.strip if url.empty?
|
|
103
|
+
url = defaults.fetch(backend.empty? ? "ollama" : backend, defaults.fetch("ollama")) if url.empty?
|
|
104
|
+
uri = URI.parse(url)
|
|
105
|
+
loopback = ["127.0.0.1", "::1", "localhost"].include?(uri.host)
|
|
106
|
+
{ status: loopback ? :ok : :warning, label: "Local endpoint", message: "#{url}#{loopback ? " (loopback)" : " (non-loopback)"}" }
|
|
107
|
+
rescue URI::InvalidURIError
|
|
108
|
+
{ status: :error, label: "Local endpoint", message: "invalid URL: #{url}" }
|
|
109
|
+
end
|
|
110
|
+
|
|
92
111
|
def doctor_auth_check(config)
|
|
93
112
|
openai_auth = OpenAIOAuth.default_auth_path
|
|
94
113
|
github_auth = GithubOAuth.default_auth_path
|
|
95
114
|
has_openrouter = !config.to_h["openrouter_api_key"].to_s.empty? || !ENV["OPENROUTER_API_KEY"].to_s.empty?
|
|
115
|
+
local_provider = @client.respond_to?(:current_provider) && @client.current_provider == "Local"
|
|
96
116
|
paths = []
|
|
97
117
|
paths << "OpenAI OAuth" if File.exist?(openai_auth)
|
|
98
118
|
paths << "GitHub OAuth" if File.exist?(github_auth)
|
|
99
119
|
paths << "OpenRouter API key" if has_openrouter
|
|
120
|
+
paths << "Local endpoint (no authentication required)" if local_provider
|
|
100
121
|
return { status: :ok, label: "Auth", message: paths.join(", ") } if paths.any?
|
|
101
122
|
|
|
102
123
|
{ status: :warning, label: "Auth", message: "no saved credentials found; run `kward login`" }
|
|
@@ -318,7 +318,7 @@ module Kward
|
|
|
318
318
|
end
|
|
319
319
|
|
|
320
320
|
def configured_workspace(root: current_workspace_root)
|
|
321
|
-
|
|
321
|
+
WorkspaceFactory.build(root: root, guardrails: workspace_guardrails_enabled?, config: safely_read_config.to_h)
|
|
322
322
|
end
|
|
323
323
|
|
|
324
324
|
def workspace_guardrails_enabled?
|
data/lib/kward/cli/settings.rb
CHANGED
|
@@ -88,7 +88,7 @@ module Kward
|
|
|
88
88
|
def configure_model_settings(conversation)
|
|
89
89
|
initial_index = 0
|
|
90
90
|
loop do
|
|
91
|
-
selected, selected_index = select_settings_menu_item("Model & Reasoning", ["Provider", "Default model", "Reasoning effort", "Back"], initial_index)
|
|
91
|
+
selected, selected_index = select_settings_menu_item("Model & Reasoning", ["Provider", "Default model", "Local server preset", "Reasoning effort", "Back"], initial_index)
|
|
92
92
|
break unless selected
|
|
93
93
|
|
|
94
94
|
initial_index = selected_index if selected_index
|
|
@@ -97,6 +97,8 @@ module Kward
|
|
|
97
97
|
configure_provider(conversation)
|
|
98
98
|
when /\Adefault model/
|
|
99
99
|
configure_model(conversation)
|
|
100
|
+
when /\Alocal server preset/
|
|
101
|
+
configure_local_server_preset(conversation)
|
|
100
102
|
when /\Areasoning effort/
|
|
101
103
|
configure_reasoning(conversation)
|
|
102
104
|
else
|
|
@@ -118,7 +120,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
|
-
|
|
574
|
-
|
|
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
|
-
|
|
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?
|
data/lib/kward/cli.rb
CHANGED
|
@@ -38,13 +38,14 @@ require_relative "tab_store"
|
|
|
38
38
|
require_relative "tab_driver"
|
|
39
39
|
require_relative "session_trash"
|
|
40
40
|
require_relative "session_tree_renderer"
|
|
41
|
+
require_relative "skills/capture"
|
|
41
42
|
require_relative "starter_pack_installer"
|
|
42
43
|
require_relative "steering"
|
|
43
44
|
require_relative "update_check"
|
|
44
45
|
require_relative "tools/tool_call"
|
|
45
46
|
require_relative "tools/registry"
|
|
46
47
|
require_relative "telemetry/stats"
|
|
47
|
-
require_relative "
|
|
48
|
+
require_relative "workspace_factory"
|
|
48
49
|
require_relative "cli/commands"
|
|
49
50
|
require_relative "cli/auth_commands"
|
|
50
51
|
require_relative "cli/doctor"
|
data/lib/kward/config_files.rb
CHANGED
|
@@ -6,6 +6,7 @@ require_relative "frontmatter"
|
|
|
6
6
|
require_relative "private_file"
|
|
7
7
|
require_relative "path_guard"
|
|
8
8
|
require_relative "permissions/policy"
|
|
9
|
+
require_relative "sandbox/policy"
|
|
9
10
|
require_relative "ekwsh"
|
|
10
11
|
require_relative "editor_mode"
|
|
11
12
|
require_relative "diff_view_mode"
|
|
@@ -151,6 +152,9 @@ module Kward
|
|
|
151
152
|
"trust_project" => false
|
|
152
153
|
},
|
|
153
154
|
"enforce_workspace_agents_file" => false,
|
|
155
|
+
"system_prompt" => {
|
|
156
|
+
"include_principles" => true
|
|
157
|
+
},
|
|
154
158
|
"mcpServers" => {},
|
|
155
159
|
"tools" => {
|
|
156
160
|
"workspace_guardrails" => true
|
|
@@ -158,6 +162,12 @@ module Kward
|
|
|
158
162
|
"permissions" => {
|
|
159
163
|
"enabled" => false,
|
|
160
164
|
"mode" => "ask"
|
|
165
|
+
},
|
|
166
|
+
"sandbox" => {
|
|
167
|
+
"mode" => "off",
|
|
168
|
+
"network" => "deny",
|
|
169
|
+
"writable_roots" => [],
|
|
170
|
+
"protect_git_metadata" => true
|
|
161
171
|
}
|
|
162
172
|
}
|
|
163
173
|
end
|
|
@@ -498,6 +508,18 @@ module Kward
|
|
|
498
508
|
Permissions::Policy.from_config(config)
|
|
499
509
|
end
|
|
500
510
|
|
|
511
|
+
# Builds the user-controlled command sandbox policy for a workspace.
|
|
512
|
+
def sandbox_policy(workspace_root, config = read_config)
|
|
513
|
+
sandbox = config["sandbox"].is_a?(Hash) ? config["sandbox"] : {}
|
|
514
|
+
Sandbox::Policy.new(
|
|
515
|
+
mode: sandbox.fetch("mode", "off"),
|
|
516
|
+
network: sandbox.fetch("network", "deny"),
|
|
517
|
+
workspace_root: workspace_root,
|
|
518
|
+
writable_roots: sandbox.fetch("writable_roots", []),
|
|
519
|
+
protect_git_metadata: sandbox.fetch("protect_git_metadata", true)
|
|
520
|
+
)
|
|
521
|
+
end
|
|
522
|
+
|
|
501
523
|
# Returns whether project-level Agent Skills should be loaded from the workspace.
|
|
502
524
|
def project_skills_trusted?(config = read_config)
|
|
503
525
|
skills = config["skills"].is_a?(Hash) ? config["skills"] : {}
|
|
@@ -558,13 +580,61 @@ module Kward
|
|
|
558
580
|
# alias for existing installations.
|
|
559
581
|
#
|
|
560
582
|
# @return [String, nil] prompt text, or nil when absent/too large
|
|
561
|
-
def agents_prompt
|
|
583
|
+
def agents_prompt(config: read_config)
|
|
584
|
+
return nil unless include_config_principles?(config)
|
|
585
|
+
|
|
562
586
|
path = config_principles_path
|
|
563
587
|
return read_prompt_file(path, "Kward principles file") if File.exist?(path)
|
|
564
588
|
|
|
565
589
|
read_prompt_file(config_agents_path, "Kward AGENTS.md alias")
|
|
566
590
|
end
|
|
567
591
|
|
|
592
|
+
# Returns whether config-directory principles are included in normal system
|
|
593
|
+
# prompt assembly. Replacement prompt files bypass all assembled sections.
|
|
594
|
+
def include_config_principles?(config = read_config)
|
|
595
|
+
settings = config["system_prompt"]
|
|
596
|
+
return true unless settings.is_a?(Hash)
|
|
597
|
+
|
|
598
|
+
settings["include_principles"] != false
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# Returns the configured replacement system prompt path, or nil when normal
|
|
602
|
+
# prompt assembly should be used. Relative paths are anchored to the config
|
|
603
|
+
# directory so configuration remains portable with KWARD_CONFIG_PATH.
|
|
604
|
+
def system_prompt_file_path(config = read_config)
|
|
605
|
+
settings = config["system_prompt"]
|
|
606
|
+
value = settings.is_a?(Hash) ? settings["file"].to_s.strip : ""
|
|
607
|
+
return nil if value.empty?
|
|
608
|
+
|
|
609
|
+
File.expand_path(value, config_dir)
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
# Returns the replacement system prompt text when configured. A configured
|
|
613
|
+
# but unavailable file intentionally yields nil: callers must not fall back
|
|
614
|
+
# to Kward's larger assembled prompt in replacement mode.
|
|
615
|
+
def system_prompt_file(config = read_config)
|
|
616
|
+
path = system_prompt_file_path(config)
|
|
617
|
+
return nil unless path
|
|
618
|
+
|
|
619
|
+
read_prompt_file(path, "custom system prompt file")
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
def replacement_system_prompt?(config = read_config)
|
|
623
|
+
!system_prompt_file_path(config).nil?
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
# Returns a lightweight fingerprint for config-owned prompt sources. It is
|
|
627
|
+
# used by active conversations to pick up prompt edits without a restart.
|
|
628
|
+
def system_prompt_sources_fingerprint
|
|
629
|
+
config = read_config
|
|
630
|
+
paths = [config_path, system_prompt_file_path(config)]
|
|
631
|
+
if include_config_principles?(config)
|
|
632
|
+
paths << (File.exist?(config_principles_path) ? config_principles_path : config_agents_path)
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
paths.compact.map { |path| prompt_source_signature(path) }.join("\0")
|
|
636
|
+
end
|
|
637
|
+
|
|
568
638
|
def config_principles_path
|
|
569
639
|
File.join(config_dir, "PRINCIPLES.md")
|
|
570
640
|
end
|
|
@@ -686,6 +756,15 @@ module Kward
|
|
|
686
756
|
read_prompt_file(workspace_agents_path(workspace_root), "workspace AGENTS.md")
|
|
687
757
|
end
|
|
688
758
|
|
|
759
|
+
def prompt_source_signature(path)
|
|
760
|
+
return "#{path}:missing" unless File.exist?(path)
|
|
761
|
+
|
|
762
|
+
stat = File.stat(path)
|
|
763
|
+
"#{path}:#{stat.size}:#{stat.mtime.to_f}"
|
|
764
|
+
rescue StandardError
|
|
765
|
+
"#{path}:unavailable"
|
|
766
|
+
end
|
|
767
|
+
|
|
689
768
|
def read_prompt_file(path, label)
|
|
690
769
|
return nil unless File.exist?(path)
|
|
691
770
|
|
data/lib/kward/conversation.rb
CHANGED
|
@@ -87,6 +87,7 @@ module Kward
|
|
|
87
87
|
end
|
|
88
88
|
@compaction_system_message = compaction_system_message
|
|
89
89
|
@workspace_agents_mtime = workspace_agents_mtime
|
|
90
|
+
@system_prompt_sources_fingerprint = ConfigFiles.system_prompt_sources_fingerprint
|
|
90
91
|
@last_entry_compaction = false
|
|
91
92
|
@memory_context = memory_context
|
|
92
93
|
@session_memories = Array(session_memories)
|
|
@@ -191,6 +192,7 @@ module Kward
|
|
|
191
192
|
@on_system_message_change&.call(replacement)
|
|
192
193
|
@compaction_system_message = Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time)
|
|
193
194
|
@workspace_agents_mtime = workspace_agents_mtime
|
|
195
|
+
@system_prompt_sources_fingerprint = ConfigFiles.system_prompt_sources_fingerprint
|
|
194
196
|
replacement
|
|
195
197
|
end
|
|
196
198
|
|
|
@@ -206,7 +208,18 @@ module Kward
|
|
|
206
208
|
end
|
|
207
209
|
|
|
208
210
|
def refresh_system_message_if_workspace_agents_changed!
|
|
209
|
-
|
|
211
|
+
refresh_system_message_if_sources_changed!
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Refreshes runtime prompt state after workspace or config-owned prompt
|
|
215
|
+
# sources change. Keep the older public method above as a compatibility
|
|
216
|
+
# alias for existing callers.
|
|
217
|
+
def refresh_system_message_if_sources_changed!
|
|
218
|
+
return unless @system_message_enabled
|
|
219
|
+
|
|
220
|
+
changed = workspace_agents_mtime != @workspace_agents_mtime ||
|
|
221
|
+
ConfigFiles.system_prompt_sources_fingerprint != @system_prompt_sources_fingerprint
|
|
222
|
+
refresh_system_message! if changed
|
|
210
223
|
end
|
|
211
224
|
|
|
212
225
|
def mark_read(path)
|
|
@@ -18,7 +18,7 @@ module Kward
|
|
|
18
18
|
@terminate_on_output_limit = terminate_on_output_limit
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
def run(*command, env: {}, cwd: Dir.pwd, cancellation: nil, &block)
|
|
21
|
+
def run(*command, env: {}, cwd: Dir.pwd, cancellation: nil, unsetenv_others: false, &block)
|
|
22
22
|
cancellation&.raise_if_cancelled!
|
|
23
23
|
stdout_buffer = +""
|
|
24
24
|
stderr_buffer = +""
|
|
@@ -27,7 +27,7 @@ module Kward
|
|
|
27
27
|
timed_out = false
|
|
28
28
|
queue = Queue.new
|
|
29
29
|
|
|
30
|
-
Open3.popen3(env.to_h, *command, chdir: cwd.to_s, pgroup: true) do |stdin, stdout, stderr, wait_thread|
|
|
30
|
+
Open3.popen3(env.to_h, *command, chdir: cwd.to_s, pgroup: true, unsetenv_others: unsetenv_others) do |stdin, stdout, stderr, wait_thread|
|
|
31
31
|
stdin.close
|
|
32
32
|
readers = [
|
|
33
33
|
read_stream(stdout, :stdout, queue),
|