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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +1 -1
  5. data/doc/composer.md +5 -8
  6. data/doc/configuration.md +13 -3
  7. data/doc/permissions.md +1 -1
  8. data/doc/rpc.md +2 -1
  9. data/doc/sandboxing.md +4 -4
  10. data/doc/security.md +6 -9
  11. data/doc/shell.md +161 -196
  12. data/doc/skills.md +17 -5
  13. data/doc/tabs.md +1 -1
  14. data/doc/usage.md +6 -5
  15. data/lib/kward/ansi.rb +9 -1
  16. data/lib/kward/cli/commands.rb +7 -0
  17. data/lib/kward/cli/interactive_turn.rb +1 -1
  18. data/lib/kward/cli/plugins.rb +1 -1
  19. data/lib/kward/cli/project_skills.rb +99 -0
  20. data/lib/kward/cli/project_skills_commands.rb +87 -0
  21. data/lib/kward/cli/prompt_interface.rb +18 -4
  22. data/lib/kward/cli/rendering.rb +28 -2
  23. data/lib/kward/cli/runtime_helpers.rb +156 -19
  24. data/lib/kward/cli/sessions.rb +6 -4
  25. data/lib/kward/cli/settings.rb +1 -1
  26. data/lib/kward/cli/slash_commands.rb +6 -0
  27. data/lib/kward/cli/tabs.rb +3 -1
  28. data/lib/kward/cli.rb +19 -1
  29. data/lib/kward/config_files.rb +9 -4
  30. data/lib/kward/conversation.rb +8 -5
  31. data/lib/kward/ekwsh.rb +37 -16
  32. data/lib/kward/interactive_pty_runner.rb +88 -22
  33. data/lib/kward/prompt_interface/composer_controller.rb +13 -1
  34. data/lib/kward/prompt_interface/editor/controller.rb +4 -0
  35. data/lib/kward/prompt_interface/key_handler.rb +78 -43
  36. data/lib/kward/prompt_interface/overlay_renderer.rb +12 -0
  37. data/lib/kward/prompt_interface/screen.rb +5 -0
  38. data/lib/kward/prompt_interface.rb +93 -21
  39. data/lib/kward/prompts/commands.rb +2 -0
  40. data/lib/kward/prompts.rb +6 -6
  41. data/lib/kward/rpc/server.rb +1 -0
  42. data/lib/kward/session_store.rb +3 -2
  43. data/lib/kward/skills/registry.rb +52 -2
  44. data/lib/kward/skills/trust_coordinator.rb +45 -0
  45. data/lib/kward/skills/trust_store.rb +107 -0
  46. data/lib/kward/version.rb +1 -1
  47. 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
- @mutex.synchronize do
262
- text = message.to_s
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
- with_synchronized_output_locked do
271
- clear_prompt_for_output_locked
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
- clear_prompt_for_output_locked
582
- restore_scroll_region_locked
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
@@ -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",
@@ -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
@@ -1,5 +1,5 @@
1
1
  # Namespace for the Kward CLI agent runtime.
2
2
  module Kward
3
3
  # Current gem version.
4
- VERSION = "0.80.1"
4
+ VERSION = "0.81.0"
5
5
  end
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.80.1
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