kward 0.80.1 → 0.81.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/CHANGELOG.md +33 -0
- data/Gemfile.lock +2 -2
- data/README.md +1 -1
- data/doc/composer.md +5 -8
- data/doc/configuration.md +13 -3
- data/doc/permissions.md +1 -1
- data/doc/rpc.md +2 -1
- data/doc/sandboxing.md +4 -4
- data/doc/security.md +6 -9
- data/doc/shell.md +161 -196
- data/doc/skills.md +17 -5
- data/doc/tabs.md +1 -1
- data/doc/usage.md +6 -5
- data/lib/kward/ansi.rb +9 -1
- data/lib/kward/cli/commands.rb +7 -0
- data/lib/kward/cli/interactive_turn.rb +1 -1
- data/lib/kward/cli/plugins.rb +1 -1
- data/lib/kward/cli/project_skills.rb +99 -0
- data/lib/kward/cli/project_skills_commands.rb +87 -0
- data/lib/kward/cli/prompt_interface.rb +18 -4
- data/lib/kward/cli/rendering.rb +28 -2
- data/lib/kward/cli/runtime_helpers.rb +156 -19
- data/lib/kward/cli/sessions.rb +6 -4
- data/lib/kward/cli/settings.rb +1 -1
- data/lib/kward/cli/slash_commands.rb +6 -0
- data/lib/kward/cli/tabs.rb +3 -1
- data/lib/kward/cli.rb +19 -1
- data/lib/kward/config_files.rb +9 -4
- data/lib/kward/conversation.rb +8 -5
- data/lib/kward/ekwsh.rb +37 -16
- data/lib/kward/interactive_pty_runner.rb +88 -22
- data/lib/kward/prompt_interface/composer_controller.rb +13 -1
- data/lib/kward/prompt_interface/editor/controller.rb +4 -0
- data/lib/kward/prompt_interface/key_handler.rb +78 -43
- data/lib/kward/prompt_interface/overlay_renderer.rb +12 -0
- data/lib/kward/prompt_interface/screen.rb +5 -0
- data/lib/kward/prompt_interface.rb +93 -21
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts.rb +6 -6
- data/lib/kward/rpc/server.rb +1 -0
- data/lib/kward/session_store.rb +3 -2
- data/lib/kward/skills/registry.rb +52 -2
- data/lib/kward/skills/trust_coordinator.rb +45 -0
- data/lib/kward/skills/trust_store.rb +107 -0
- data/lib/kward/version.rb +1 -1
- metadata +5 -1
|
@@ -11,12 +11,24 @@ module Kward
|
|
|
11
11
|
return selection_overlay_rows(width, height: height) if @select_state
|
|
12
12
|
return git_overlay_rows(width, height: height) if @git_state
|
|
13
13
|
return project_browser_rows(width, height: height) if project_browser_visible?
|
|
14
|
+
return completion_overlay_rows(width, height: height) if @completion_overlay
|
|
14
15
|
return history_search_overlay_rows(width, height: height) if history_search_active?
|
|
15
16
|
return file_overlay_rows(width, height: height) if file_overlay_visible?
|
|
16
17
|
|
|
17
18
|
slash_overlay_rows(width, height: height)
|
|
18
19
|
end
|
|
19
20
|
|
|
21
|
+
def completion_overlay_rows(width, height: screen_height)
|
|
22
|
+
candidates = @completion_overlay[:candidates]
|
|
23
|
+
max_rows = max_overlay_list_rows(height)
|
|
24
|
+
selected = @completion_overlay[:index].to_i
|
|
25
|
+
start = centered_list_window_start(selected, candidates.length, max_rows)
|
|
26
|
+
rows = (candidates[start, max_rows] || []).each_with_index.map do |candidate, offset|
|
|
27
|
+
overlay_choice_line(candidate, selected: start + offset == selected)
|
|
28
|
+
end
|
|
29
|
+
overlay_card_rows("Completions", rows, width)
|
|
30
|
+
end
|
|
31
|
+
|
|
20
32
|
def history_search_overlay_rows(width, height: screen_height)
|
|
21
33
|
matches = history_search_matches
|
|
22
34
|
if matches.empty?
|
|
@@ -87,6 +87,11 @@ module Kward
|
|
|
87
87
|
ensure_scroll_region_locked(rows.length, width: width, height: height)
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
+
def make_room_for_composer_after_handoff_locked
|
|
91
|
+
rows, = composer_layout(screen_width, screen_height)
|
|
92
|
+
@output_io.print("\r\n" * rows.length)
|
|
93
|
+
end
|
|
94
|
+
|
|
90
95
|
def ensure_scroll_region_locked(row_count, redraw_transcript: true, width: screen_width, height: screen_height)
|
|
91
96
|
new_reserved_rows = [[row_count, 1].max, [height - 1, 1].max].min
|
|
92
97
|
return if @reserved_rows == new_reserved_rows && @last_height == height
|
|
@@ -128,7 +128,7 @@ 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, 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)
|
|
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, redraw_handler: nil)
|
|
132
132
|
@input_io = input
|
|
133
133
|
@output_io = output
|
|
134
134
|
@reader = TTY::Reader.new(input: input, output: output, interrupt: :error)
|
|
@@ -162,6 +162,10 @@ module Kward
|
|
|
162
162
|
@restoring_transcript = false
|
|
163
163
|
@pending_keys = []
|
|
164
164
|
@completion_provider = nil
|
|
165
|
+
@completion_cycle = nil
|
|
166
|
+
@completion_overlay = nil
|
|
167
|
+
@redraw_handler = redraw_handler
|
|
168
|
+
@transcript_redraw_requested = false
|
|
165
169
|
@original_console_mode = nil
|
|
166
170
|
@raw_mode_active = false
|
|
167
171
|
@slash_commands = normalize_slash_commands(slash_commands)
|
|
@@ -258,24 +262,11 @@ module Kward
|
|
|
258
262
|
end
|
|
259
263
|
|
|
260
264
|
def say(message)
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
if @restoring_transcript
|
|
264
|
-
write_transcript_text_locked(text)
|
|
265
|
-
write_transcript_text_locked("\n") unless text.end_with?("\n")
|
|
266
|
-
@stream_state.finish_block
|
|
267
|
-
next
|
|
268
|
-
end
|
|
265
|
+
write_transcript_message(message, preserve_composer: false)
|
|
266
|
+
end
|
|
269
267
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
write_transcript_text_locked(text)
|
|
273
|
-
write_transcript_text_locked("\n") unless text.end_with?("\n")
|
|
274
|
-
@stream_state.finish_block
|
|
275
|
-
render_prompt_after_output_locked
|
|
276
|
-
end
|
|
277
|
-
@output_io.flush
|
|
278
|
-
end
|
|
268
|
+
def write_transcript(message)
|
|
269
|
+
write_transcript_message(message, preserve_composer: true)
|
|
279
270
|
end
|
|
280
271
|
|
|
281
272
|
def say_visual(message)
|
|
@@ -328,6 +319,16 @@ module Kward
|
|
|
328
319
|
@slash_overlay_disabled = previous_slash_overlay_disabled
|
|
329
320
|
end
|
|
330
321
|
|
|
322
|
+
def update_completion_provider(provider, slash_overlay: true)
|
|
323
|
+
@mutex.synchronize do
|
|
324
|
+
@completion_provider = provider
|
|
325
|
+
@completion_cycle = nil
|
|
326
|
+
@completion_overlay = nil
|
|
327
|
+
@slash_overlay_disabled = !slash_overlay
|
|
328
|
+
render_prompt_locked if @started
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
331
332
|
def with_prompt_history(history)
|
|
332
333
|
previous_history = @prompt_history
|
|
333
334
|
@prompt_history = history
|
|
@@ -435,6 +436,8 @@ module Kward
|
|
|
435
436
|
reset_history_navigation
|
|
436
437
|
end
|
|
437
438
|
@pending_keys.clear
|
|
439
|
+
@completion_cycle = nil
|
|
440
|
+
@completion_overlay = nil
|
|
438
441
|
@asking = true
|
|
439
442
|
@busy = false
|
|
440
443
|
@queued_count = 0
|
|
@@ -454,11 +457,14 @@ module Kward
|
|
|
454
457
|
render_prompt_locked unless result.is_a?(String) || result == EXIT_INPUT || prompt_action_result?(result)
|
|
455
458
|
end
|
|
456
459
|
end
|
|
460
|
+
perform_pending_transcript_redraw
|
|
457
461
|
return result if result.is_a?(String) || prompt_action_result?(result)
|
|
458
462
|
return nil if result == EXIT_INPUT
|
|
459
463
|
|
|
460
464
|
sleep 0.02 if key.nil?
|
|
461
465
|
end
|
|
466
|
+
ensure
|
|
467
|
+
perform_pending_transcript_redraw
|
|
462
468
|
end
|
|
463
469
|
|
|
464
470
|
def yes?(message, default: false)
|
|
@@ -573,13 +579,19 @@ module Kward
|
|
|
573
579
|
end
|
|
574
580
|
end
|
|
575
581
|
|
|
576
|
-
def with_terminal_handoff
|
|
582
|
+
def with_terminal_handoff(preserve_composer: false)
|
|
577
583
|
start
|
|
578
584
|
input = nil
|
|
579
585
|
output = nil
|
|
580
586
|
@mutex.synchronize do
|
|
581
|
-
|
|
582
|
-
|
|
587
|
+
if preserve_composer
|
|
588
|
+
handle_resize_locked
|
|
589
|
+
reserve_composer_region_locked
|
|
590
|
+
move_to_transcript_cursor_locked
|
|
591
|
+
else
|
|
592
|
+
clear_prompt_for_output_locked
|
|
593
|
+
restore_scroll_region_locked
|
|
594
|
+
end
|
|
583
595
|
disable_editor_mouse_reporting(force: true)
|
|
584
596
|
@output_io.print(BRACKETED_PASTE_RESTORE)
|
|
585
597
|
@output_io.print(KEYBOARD_PROTOCOL_RESTORE)
|
|
@@ -594,6 +606,8 @@ module Kward
|
|
|
594
606
|
yield(input, output)
|
|
595
607
|
ensure
|
|
596
608
|
@mutex.synchronize do
|
|
609
|
+
restore_scroll_region_locked if preserve_composer
|
|
610
|
+
make_room_for_composer_after_handoff_locked if !preserve_composer && @started && @asking
|
|
597
611
|
enter_raw_mode_locked
|
|
598
612
|
@output_io.print(KEYBOARD_PROTOCOL_ENABLE)
|
|
599
613
|
@output_io.print(BRACKETED_PASTE_ENABLE)
|
|
@@ -683,6 +697,8 @@ module Kward
|
|
|
683
697
|
|
|
684
698
|
def restore_composer_snapshot_locked(snapshot)
|
|
685
699
|
@composer = snapshot[:composer] || new_composer_state_with_history
|
|
700
|
+
@completion_cycle = nil
|
|
701
|
+
@completion_overlay = nil
|
|
686
702
|
@prompt_label = snapshot[:prompt_label].to_s.empty? ? "You>" : snapshot[:prompt_label].to_s
|
|
687
703
|
self.composer_input = @composer.input
|
|
688
704
|
self.composer_cursor = @composer.cursor
|
|
@@ -811,6 +827,8 @@ module Kward
|
|
|
811
827
|
render_prompt_locked unless [EXIT_INPUT, CANCEL_INPUT].include?(result) || prompt_action_result?(result)
|
|
812
828
|
[EXIT_INPUT, CANCEL_INPUT].include?(result) ? result : result
|
|
813
829
|
end
|
|
830
|
+
ensure
|
|
831
|
+
perform_pending_transcript_redraw
|
|
814
832
|
end
|
|
815
833
|
|
|
816
834
|
def update_assistant_label(label)
|
|
@@ -855,6 +873,21 @@ module Kward
|
|
|
855
873
|
end
|
|
856
874
|
end
|
|
857
875
|
|
|
876
|
+
def record_transient_terminal_output(text)
|
|
877
|
+
value = text.to_s
|
|
878
|
+
return if value.empty?
|
|
879
|
+
|
|
880
|
+
@mutex.synchronize do
|
|
881
|
+
append_transcript_buffer(value)
|
|
882
|
+
append_transcript_buffer("\n") unless value.end_with?("\n")
|
|
883
|
+
@stream_state.finish_block
|
|
884
|
+
width, height = screen_size
|
|
885
|
+
remember_transcript_viewport_locked(height)
|
|
886
|
+
with_synchronized_output_locked { redraw_screen_locked(width: width, height: height) }
|
|
887
|
+
@output_io.flush
|
|
888
|
+
end
|
|
889
|
+
end
|
|
890
|
+
|
|
858
891
|
def write_transcript_delta(delta)
|
|
859
892
|
@mutex.synchronize do
|
|
860
893
|
with_synchronized_output_locked do
|
|
@@ -904,6 +937,45 @@ module Kward
|
|
|
904
937
|
|
|
905
938
|
private
|
|
906
939
|
|
|
940
|
+
def write_transcript_message(message, preserve_composer:)
|
|
941
|
+
@mutex.synchronize do
|
|
942
|
+
text = message.to_s
|
|
943
|
+
if @restoring_transcript
|
|
944
|
+
write_transcript_text_locked(text)
|
|
945
|
+
write_transcript_text_locked("\n") unless text.end_with?("\n")
|
|
946
|
+
@stream_state.finish_block
|
|
947
|
+
next
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
with_synchronized_output_locked do
|
|
951
|
+
if preserve_composer
|
|
952
|
+
prepare_transcript_output_locked
|
|
953
|
+
else
|
|
954
|
+
clear_prompt_for_output_locked
|
|
955
|
+
end
|
|
956
|
+
write_transcript_text_locked(text)
|
|
957
|
+
write_transcript_text_locked("\n") unless text.end_with?("\n")
|
|
958
|
+
@stream_state.finish_block
|
|
959
|
+
preserve_composer ? restore_composer_cursor_locked : render_prompt_after_output_locked
|
|
960
|
+
end
|
|
961
|
+
@output_io.flush
|
|
962
|
+
end
|
|
963
|
+
end
|
|
964
|
+
|
|
965
|
+
def request_transcript_redraw
|
|
966
|
+
redraw_screen_locked
|
|
967
|
+
@transcript_redraw_requested = true
|
|
968
|
+
end
|
|
969
|
+
|
|
970
|
+
def perform_pending_transcript_redraw
|
|
971
|
+
requested = @mutex.synchronize do
|
|
972
|
+
pending = @transcript_redraw_requested
|
|
973
|
+
@transcript_redraw_requested = false
|
|
974
|
+
pending
|
|
975
|
+
end
|
|
976
|
+
@redraw_handler&.call if requested
|
|
977
|
+
end
|
|
978
|
+
|
|
907
979
|
def prompt_workspace_root
|
|
908
980
|
@workspace_root
|
|
909
981
|
end
|
|
@@ -28,6 +28,7 @@ module Kward
|
|
|
28
28
|
{ name: "diff", description: "Open the file changes recorded in this session.", argument_hint: "" },
|
|
29
29
|
{ name: "files", description: "Browse project files.", argument_hint: "" },
|
|
30
30
|
{ name: "shell", description: "Open the embedded Kward shell.", argument_hint: "" },
|
|
31
|
+
{ name: "capture", description: "Run a command with bounded transcript capture.", argument_hint: "<command>" },
|
|
31
32
|
{ name: "scratchpad", description: "Open an unsaved editor buffer.", argument_hint: "[text|markdown|ruby]" },
|
|
32
33
|
{ name: "pty", description: "Run a command in an interactive PTY passthrough session.", argument_hint: "<command>" },
|
|
33
34
|
{ name: "tab", description: "Manage tabs.", argument_hint: "[1-n|move|close|new|name|worktree]" },
|
|
@@ -36,6 +37,7 @@ module Kward
|
|
|
36
37
|
{ name: "sandbox", description: "Inspect or configure model command sandboxing.", argument_hint: "[status|off|read_only|workspace_write|network allow|network deny]" },
|
|
37
38
|
{ name: "hooks", description: "Inspect lifecycle hooks.", argument_hint: "[list|events|logs|doctor|trust|untrust]" },
|
|
38
39
|
{ name: "skill", description: "Activate a skill or capture one from a saved session.", argument_hint: "<name>|capture" },
|
|
40
|
+
{ name: "skills", description: "Review project skill trust.", argument_hint: "[status|trust|untrust]" },
|
|
39
41
|
{ name: "memory", description: "Inspect and manage Kward memory.", argument_hint: "[enable|disable|auto-summary|core|add|list|forget|promote|relax|inspect|why|summarize]" }
|
|
40
42
|
].freeze
|
|
41
43
|
BUILTIN_RESERVED_COMMAND_NAMES = BUILTIN_COMMANDS.map { |command| command[:name] }.freeze
|
data/lib/kward/prompts.rb
CHANGED
|
@@ -22,22 +22,22 @@ 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, execution_profile_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, project_skill_paths: 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, execution_profile_context: execution_profile_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, project_skill_paths: project_skill_paths).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, 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] }
|
|
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, project_skill_paths: 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, project_skill_paths: project_skill_paths).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, execution_profile_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, project_skill_paths: 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)]
|
|
@@ -48,7 +48,7 @@ module Kward
|
|
|
48
48
|
sections << prompt_section("Persona", persona_prompt(workspace_root, model: model, reasoning_effort: reasoning_effort, now: now))
|
|
49
49
|
sections << prompt_section("Plugin context", plugin_context) unless plugin_context.to_s.empty?
|
|
50
50
|
end
|
|
51
|
-
skills = ConfigFiles.skills(workspace_root: workspace_root)
|
|
51
|
+
skills = ConfigFiles.skills(workspace_root: workspace_root, project_skill_paths: project_skill_paths)
|
|
52
52
|
sections << prompt_section("Configured skills", skills_prompt(skills), source: skills.empty? ? nil : File.join(ConfigFiles.config_dir, "skills"))
|
|
53
53
|
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)
|
|
54
54
|
sections.compact
|
data/lib/kward/rpc/server.rb
CHANGED
|
@@ -579,6 +579,7 @@ module Kward
|
|
|
579
579
|
stability: { protocol: "stable", compatibility: "additive-fields-unless-protocol-version-changes", experimentalCapabilities: [] },
|
|
580
580
|
commands: { supported: true, methods: COMMAND_METHODS, method: COMMAND_METHODS[0], runMethod: COMMAND_METHODS[1], sources: ["builtin", "prompt", "skill", "plugin"], executableSources: ["builtin", "plugin"] },
|
|
581
581
|
skillCapture: { supported: true, methods: SKILL_CAPTURE_METHODS, destination: "personal", source: "savedSessionActiveLeaf", reviewRequired: true, overwrite: "explicit", autoActivate: false },
|
|
582
|
+
projectSkillTrust: { supported: false, trustRequired: true, reason: "RPC has no interactive trust decision bridge; project skills remain skipped unless globally enabled." },
|
|
582
583
|
mcp: {
|
|
583
584
|
supported: true,
|
|
584
585
|
transport: "stdio",
|
data/lib/kward/session_store.rb
CHANGED
|
@@ -297,7 +297,7 @@ module Kward
|
|
|
297
297
|
# `workspace` is used both for the active root and to restore read-before-write
|
|
298
298
|
# paths from successful read tool results. If a session moved workspaces, load
|
|
299
299
|
# it through `session_location` first so the original cwd is respected.
|
|
300
|
-
def load(path, workspace: Workspace.new, provider: nil, model: nil, reasoning_effort: nil)
|
|
300
|
+
def load(path, workspace: Workspace.new, provider: nil, model: nil, reasoning_effort: nil, project_skill_paths: nil)
|
|
301
301
|
resolved_path = resolve_session_path(path)
|
|
302
302
|
records = records_from_file(resolved_path)
|
|
303
303
|
header = session_header(records, resolved_path)
|
|
@@ -317,7 +317,8 @@ module Kward
|
|
|
317
317
|
model: runtime["model"] || model,
|
|
318
318
|
reasoning_effort: runtime["reasoningEffort"] || reasoning_effort,
|
|
319
319
|
session_memories: memory_state["sessionMemories"],
|
|
320
|
-
last_memory_retrieval: memory_state["lastRetrieval"]
|
|
320
|
+
last_memory_retrieval: memory_state["lastRetrieval"],
|
|
321
|
+
project_skill_paths: project_skill_paths
|
|
321
322
|
)
|
|
322
323
|
restore_tool_output_artifacts(records, conversation)
|
|
323
324
|
conversation.mark_last_entry_compaction! if latest_record_type(records) == "compaction"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "pathname"
|
|
2
|
+
require_relative "trust_store"
|
|
2
3
|
|
|
3
4
|
# Namespace for the Kward CLI agent runtime.
|
|
4
5
|
module Kward
|
|
@@ -13,8 +14,9 @@ module Kward
|
|
|
13
14
|
# @api public
|
|
14
15
|
class Registry
|
|
15
16
|
SkillSource = Struct.new(:root, :label, :scope, :precedence, keyword_init: true)
|
|
17
|
+
SkillCandidate = Struct.new(:path, :root, :label, :scope, :digest, keyword_init: true)
|
|
16
18
|
|
|
17
|
-
def initialize(config_dir:, workspace_root:, project_skills_trusted:, skill_class:, max_file_bytes:, markdown_parser:, inside_directory:, warning_sink: nil)
|
|
19
|
+
def initialize(config_dir:, workspace_root:, project_skills_trusted:, skill_class:, max_file_bytes:, markdown_parser:, inside_directory:, warning_sink: nil, project_skill_paths: nil)
|
|
18
20
|
@config_dir = config_dir
|
|
19
21
|
@workspace_root = workspace_root
|
|
20
22
|
@project_skills_trusted = project_skills_trusted
|
|
@@ -23,6 +25,32 @@ module Kward
|
|
|
23
25
|
@markdown_parser = markdown_parser
|
|
24
26
|
@inside_directory = inside_directory
|
|
25
27
|
@warning_sink = warning_sink
|
|
28
|
+
@project_skill_paths = project_skill_paths&.map do |path|
|
|
29
|
+
File.realpath(path)
|
|
30
|
+
rescue SystemCallError
|
|
31
|
+
File.expand_path(path)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns project skill files without activating their instructions.
|
|
36
|
+
#
|
|
37
|
+
# @return [Array<SkillCandidate>]
|
|
38
|
+
# @api public
|
|
39
|
+
def project_skill_candidates
|
|
40
|
+
skill_sources.select { |source| source.scope == :project }.flat_map do |source|
|
|
41
|
+
discover_source(source).filter_map do |path|
|
|
42
|
+
SkillCandidate.new(
|
|
43
|
+
path: path,
|
|
44
|
+
root: source.root,
|
|
45
|
+
label: source.label,
|
|
46
|
+
scope: source.scope,
|
|
47
|
+
digest: skill_digest(path)
|
|
48
|
+
)
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
emit_warning "Warning: skipping Kward skill #{path}: #{e.message}"
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
end
|
|
26
54
|
end
|
|
27
55
|
|
|
28
56
|
# Returns discovered, validated skills in precedence order.
|
|
@@ -99,17 +127,39 @@ module Kward
|
|
|
99
127
|
|
|
100
128
|
def scan_source(source)
|
|
101
129
|
return [] unless Dir.exist?(source.root)
|
|
102
|
-
if source.scope == :project && !@project_skills_trusted
|
|
130
|
+
if source.scope == :project && @project_skill_paths.nil? && !@project_skills_trusted
|
|
103
131
|
emit_warning "Warning: skipping #{source.label} in #{source.root}: project skills are not trusted"
|
|
104
132
|
return []
|
|
105
133
|
end
|
|
106
134
|
|
|
135
|
+
paths = discover_source(source)
|
|
136
|
+
return paths unless source.scope == :project && @project_skill_paths
|
|
137
|
+
|
|
138
|
+
paths.select { |path| @project_skill_paths.include?(canonical_path(path)) }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def canonical_path(path)
|
|
142
|
+
File.realpath(path)
|
|
143
|
+
rescue SystemCallError
|
|
144
|
+
File.expand_path(path)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def discover_source(source)
|
|
107
148
|
Dir.glob(File.join(source.root, "*", "SKILL.md")).sort
|
|
108
149
|
rescue StandardError => e
|
|
109
150
|
emit_warning "Warning: skipping #{source.label} in #{source.root}: #{e.message}"
|
|
110
151
|
[]
|
|
111
152
|
end
|
|
112
153
|
|
|
154
|
+
def skill_digest(path)
|
|
155
|
+
TrustStore.digest_files(skill_files(path), root: @workspace_root)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def skill_files(path)
|
|
159
|
+
folder = File.dirname(path)
|
|
160
|
+
[path] + skill_resources(folder).map { |resource| File.join(folder, resource) }
|
|
161
|
+
end
|
|
162
|
+
|
|
113
163
|
def skill_content(skill, content)
|
|
114
164
|
lines = [
|
|
115
165
|
%(<skill_content name="#{xml_escape(skill.name)}">),
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
require_relative "trust_store"
|
|
2
|
+
|
|
3
|
+
# Namespace for Agent Skills discovery and trust management.
|
|
4
|
+
module Kward
|
|
5
|
+
module Skills
|
|
6
|
+
# Resolves which discovered project skills need a user decision.
|
|
7
|
+
class TrustCoordinator
|
|
8
|
+
def initialize(workspace_root:, trust_store:)
|
|
9
|
+
@workspace_root = workspace_root
|
|
10
|
+
@trust_store = trust_store
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def decision(candidate)
|
|
14
|
+
@trust_store.decision(
|
|
15
|
+
workspace_root: @workspace_root,
|
|
16
|
+
skill_path: candidate.path,
|
|
17
|
+
digest: candidate.digest
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def pending(candidates)
|
|
22
|
+
candidates.reject { |candidate| decision(candidate) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def allowed_paths(candidates)
|
|
26
|
+
candidates.filter_map { |candidate| candidate.path if decision(candidate) == "allow" }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def remove_workspace!
|
|
30
|
+
@trust_store.remove_workspace!(workspace_root: @workspace_root)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def record!(candidates, decision)
|
|
34
|
+
candidates.each do |candidate|
|
|
35
|
+
@trust_store.set_decision!(
|
|
36
|
+
workspace_root: @workspace_root,
|
|
37
|
+
skill_path: candidate.path,
|
|
38
|
+
digest: candidate.digest,
|
|
39
|
+
decision: decision
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "json"
|
|
3
|
+
require "pathname"
|
|
4
|
+
require_relative "../path_guard"
|
|
5
|
+
require_relative "../private_file"
|
|
6
|
+
|
|
7
|
+
# Namespace for Agent Skills discovery and trust management.
|
|
8
|
+
module Kward
|
|
9
|
+
module Skills
|
|
10
|
+
# Persists workspace-scoped decisions for project skill snapshots.
|
|
11
|
+
class TrustStore
|
|
12
|
+
DECISIONS = %w[allow deny].freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :path
|
|
15
|
+
|
|
16
|
+
def initialize(config_dir:)
|
|
17
|
+
@path = File.join(File.expand_path(config_dir), "trusted_project_skills.json")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def decision(workspace_root:, skill_path:, digest:)
|
|
21
|
+
record = skill_record(workspace_root: workspace_root, skill_path: skill_path)
|
|
22
|
+
return unless record
|
|
23
|
+
return unless record["digest"] == digest.to_s
|
|
24
|
+
|
|
25
|
+
decision = record["decision"]
|
|
26
|
+
decision if DECISIONS.include?(decision)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def set_decision!(workspace_root:, skill_path:, digest:, decision:)
|
|
30
|
+
decision = decision.to_s
|
|
31
|
+
raise ArgumentError, "invalid project skill trust decision: #{decision}" unless DECISIONS.include?(decision)
|
|
32
|
+
|
|
33
|
+
config = read_config
|
|
34
|
+
workspace = workspace_key(workspace_root)
|
|
35
|
+
skill = skill_key(workspace_root, skill_path)
|
|
36
|
+
config["workspaces"] ||= {}
|
|
37
|
+
config["workspaces"][workspace] ||= { "skills" => {} }
|
|
38
|
+
config["workspaces"][workspace]["skills"] ||= {}
|
|
39
|
+
config["workspaces"][workspace]["skills"][skill] = {
|
|
40
|
+
"digest" => digest.to_s,
|
|
41
|
+
"decision" => decision
|
|
42
|
+
}
|
|
43
|
+
PrivateFile.write_json(path, config)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def remove_workspace!(workspace_root:)
|
|
47
|
+
config = read_config
|
|
48
|
+
config.fetch("workspaces", {}).delete(workspace_key(workspace_root))
|
|
49
|
+
PrivateFile.write_json(path, config)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def remove_skill!(workspace_root:, skill_path:)
|
|
53
|
+
config = read_config
|
|
54
|
+
workspace = config.fetch("workspaces", {})[workspace_key(workspace_root)]
|
|
55
|
+
workspace&.fetch("skills", {})&.delete(skill_key(workspace_root, skill_path))
|
|
56
|
+
PrivateFile.write_json(path, config)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.digest_files(paths, root: nil)
|
|
60
|
+
root = File.realpath(root) if root
|
|
61
|
+
digest = Digest::SHA256.new
|
|
62
|
+
paths.map { |path| File.realpath(path) }.sort.each do |path|
|
|
63
|
+
raise ArgumentError, "file is outside digest root" if root && !PathGuard.inside?(path, root)
|
|
64
|
+
|
|
65
|
+
relative_path = root ? Pathname.new(path).relative_path_from(Pathname.new(root)).to_s : path
|
|
66
|
+
digest.update(relative_path)
|
|
67
|
+
digest.update("\0")
|
|
68
|
+
digest.update(File.binread(path))
|
|
69
|
+
digest.update("\0")
|
|
70
|
+
end
|
|
71
|
+
digest.hexdigest
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def skill_record(workspace_root:, skill_path:)
|
|
77
|
+
workspace = read_config.fetch("workspaces", {})[workspace_key(workspace_root)]
|
|
78
|
+
workspace&.fetch("skills", {})&.[](skill_key(workspace_root, skill_path))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def read_config
|
|
82
|
+
return {} unless File.file?(path)
|
|
83
|
+
|
|
84
|
+
config = JSON.parse(File.read(path))
|
|
85
|
+
config.is_a?(Hash) ? config : {}
|
|
86
|
+
rescue JSON::ParserError, SystemCallError
|
|
87
|
+
{}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def workspace_key(workspace_root)
|
|
91
|
+
File.realpath(workspace_root)
|
|
92
|
+
rescue SystemCallError
|
|
93
|
+
File.expand_path(workspace_root)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def skill_key(workspace_root, skill_path)
|
|
97
|
+
workspace = workspace_key(workspace_root)
|
|
98
|
+
skill = File.realpath(skill_path)
|
|
99
|
+
raise ArgumentError, "project skill is outside workspace" unless PathGuard.inside?(skill, workspace)
|
|
100
|
+
|
|
101
|
+
Pathname.new(skill).relative_path_from(Pathname.new(workspace)).to_s
|
|
102
|
+
rescue SystemCallError
|
|
103
|
+
raise ArgumentError, "project skill path does not exist"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
data/lib/kward/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: kward
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.81.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kai Wood
|
|
@@ -191,6 +191,8 @@ files:
|
|
|
191
191
|
- lib/kward/cli/memory_commands.rb
|
|
192
192
|
- lib/kward/cli/openrouter_commands.rb
|
|
193
193
|
- lib/kward/cli/plugins.rb
|
|
194
|
+
- lib/kward/cli/project_skills.rb
|
|
195
|
+
- lib/kward/cli/project_skills_commands.rb
|
|
194
196
|
- lib/kward/cli/prompt_interface.rb
|
|
195
197
|
- lib/kward/cli/rendering.rb
|
|
196
198
|
- lib/kward/cli/runtime_helpers.rb
|
|
@@ -353,6 +355,8 @@ files:
|
|
|
353
355
|
- lib/kward/session_tree_tool_display.rb
|
|
354
356
|
- lib/kward/skills/capture.rb
|
|
355
357
|
- lib/kward/skills/registry.rb
|
|
358
|
+
- lib/kward/skills/trust_coordinator.rb
|
|
359
|
+
- lib/kward/skills/trust_store.rb
|
|
356
360
|
- lib/kward/starter_pack_installer.rb
|
|
357
361
|
- lib/kward/steering.rb
|
|
358
362
|
- lib/kward/tab_driver.rb
|