kward 0.80.0 → 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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -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 +19 -5
  13. data/doc/tabs.md +1 -1
  14. data/doc/usage.md +6 -5
  15. data/lib/kward/agent.rb +11 -6
  16. data/lib/kward/ansi.rb +9 -1
  17. data/lib/kward/cli/commands.rb +7 -0
  18. data/lib/kward/cli/interactive_turn.rb +1 -1
  19. data/lib/kward/cli/memory_commands.rb +2 -2
  20. data/lib/kward/cli/plugins.rb +1 -1
  21. data/lib/kward/cli/project_skills.rb +99 -0
  22. data/lib/kward/cli/project_skills_commands.rb +87 -0
  23. data/lib/kward/cli/prompt_interface.rb +54 -4
  24. data/lib/kward/cli/rendering.rb +28 -2
  25. data/lib/kward/cli/runtime_helpers.rb +157 -19
  26. data/lib/kward/cli/sessions.rb +6 -4
  27. data/lib/kward/cli/settings.rb +1 -1
  28. data/lib/kward/cli/slash_commands.rb +7 -1
  29. data/lib/kward/cli/tabs.rb +4 -1
  30. data/lib/kward/cli.rb +21 -1
  31. data/lib/kward/compactor.rb +7 -2
  32. data/lib/kward/config_files.rb +30 -9
  33. data/lib/kward/conversation.rb +8 -5
  34. data/lib/kward/ekwsh.rb +37 -16
  35. data/lib/kward/hooks/audit_log.rb +5 -2
  36. data/lib/kward/interactive_pty_runner.rb +88 -22
  37. data/lib/kward/plugin_registry.rb +20 -13
  38. data/lib/kward/prompt_interface/composer_controller.rb +13 -1
  39. data/lib/kward/prompt_interface/editor/controller.rb +4 -0
  40. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +35 -10
  41. data/lib/kward/prompt_interface/key_handler.rb +78 -43
  42. data/lib/kward/prompt_interface/overlay_renderer.rb +12 -0
  43. data/lib/kward/prompt_interface/screen.rb +5 -0
  44. data/lib/kward/prompt_interface.rb +93 -21
  45. data/lib/kward/prompts/commands.rb +2 -0
  46. data/lib/kward/prompts/templates.rb +11 -6
  47. data/lib/kward/prompts.rb +6 -6
  48. data/lib/kward/rpc/server.rb +1 -0
  49. data/lib/kward/session_store.rb +3 -2
  50. data/lib/kward/skills/registry.rb +67 -12
  51. data/lib/kward/skills/trust_coordinator.rb +45 -0
  52. data/lib/kward/skills/trust_store.rb +107 -0
  53. data/lib/kward/telemetry/logger.rb +5 -2
  54. data/lib/kward/version.rb +1 -1
  55. metadata +5 -1
@@ -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:)
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
@@ -22,6 +24,33 @@ module Kward
22
24
  @max_file_bytes = max_file_bytes
23
25
  @markdown_parser = markdown_parser
24
26
  @inside_directory = inside_directory
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
25
54
  end
26
55
 
27
56
  # Returns discovered, validated skills in precedence order.
@@ -36,7 +65,7 @@ module Kward
36
65
  next unless skill
37
66
 
38
67
  if seen[skill.name]
39
- warn "Warning: skipping duplicate Kward skill #{skill.name.inspect}: #{path}"
68
+ emit_warning "Warning: skipping duplicate Kward skill #{skill.name.inspect}: #{path}"
40
69
  next
41
70
  end
42
71
 
@@ -45,7 +74,7 @@ module Kward
45
74
  end
46
75
  end
47
76
  rescue StandardError => e
48
- warn "Warning: skipping Kward skills: #{e.message}"
77
+ emit_warning "Warning: skipping Kward skills: #{e.message}"
49
78
  []
50
79
  end
51
80
 
@@ -98,17 +127,39 @@ module Kward
98
127
 
99
128
  def scan_source(source)
100
129
  return [] unless Dir.exist?(source.root)
101
- if source.scope == :project && !@project_skills_trusted
102
- warn "Warning: skipping #{source.label} in #{source.root}: project skills are not trusted"
130
+ if source.scope == :project && @project_skill_paths.nil? && !@project_skills_trusted
131
+ emit_warning "Warning: skipping #{source.label} in #{source.root}: project skills are not trusted"
103
132
  return []
104
133
  end
105
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)
106
148
  Dir.glob(File.join(source.root, "*", "SKILL.md")).sort
107
149
  rescue StandardError => e
108
- warn "Warning: skipping #{source.label} in #{source.root}: #{e.message}"
150
+ emit_warning "Warning: skipping #{source.label} in #{source.root}: #{e.message}"
109
151
  []
110
152
  end
111
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
+
112
163
  def skill_content(skill, content)
113
164
  lines = [
114
165
  %(<skill_content name="#{xml_escape(skill.name)}">),
@@ -151,12 +202,12 @@ module Kward
151
202
  return warn_skip(path, "missing description") if description.empty?
152
203
  return warn_skip(path, "description exceeds 1024 characters") if description.length > 1024
153
204
 
154
- warn "Warning: Kward skill #{path}: name does not match parent directory" if name != File.basename(File.dirname(path))
155
- warn "Warning: Kward skill #{path}: name exceeds 64 characters" if name.length > 64
156
- warn "Warning: Kward skill #{path}: name contains invalid characters" unless valid_name?(name)
205
+ emit_warning "Warning: Kward skill #{path}: name does not match parent directory" if name != File.basename(File.dirname(path))
206
+ emit_warning "Warning: Kward skill #{path}: name exceeds 64 characters" if name.length > 64
207
+ emit_warning "Warning: Kward skill #{path}: name contains invalid characters" unless valid_name?(name)
157
208
 
158
209
  compatibility = optional_text(frontmatter["compatibility"])
159
- warn "Warning: Kward skill #{path}: compatibility exceeds 500 characters" if compatibility && compatibility.length > 500
210
+ emit_warning "Warning: Kward skill #{path}: compatibility exceeds 500 characters" if compatibility && compatibility.length > 500
160
211
 
161
212
  @skill_class.new(
162
213
  name: name,
@@ -169,7 +220,7 @@ module Kward
169
220
  allowed_tools: optional_text(frontmatter["allowed-tools"])
170
221
  )
171
222
  rescue StandardError => e
172
- warn "Warning: skipping Kward skill #{path}: #{e.message}"
223
+ emit_warning "Warning: skipping Kward skill #{path}: #{e.message}"
173
224
  nil
174
225
  end
175
226
 
@@ -183,9 +234,13 @@ module Kward
183
234
  end
184
235
 
185
236
  def warn_skip(path, reason)
186
- warn "Warning: skipping Kward skill #{path}: #{reason}"
237
+ emit_warning "Warning: skipping Kward skill #{path}: #{reason}"
187
238
  nil
188
239
  end
240
+
241
+ def emit_warning(message)
242
+ @warning_sink ? @warning_sink.call(message) : warn(message)
243
+ end
189
244
  end
190
245
  end
191
246
  end
@@ -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
@@ -20,13 +20,14 @@ module Kward
20
20
  DEFAULT_MAX_BYTES = 10 * 1024 * 1024
21
21
 
22
22
  # Creates an object for telemetry event logging.
23
- def initialize(config_path: ConfigFiles.config_path, log_dir: nil, max_bytes: DEFAULT_MAX_BYTES, clock: Time, monotonic_clock: Process, error_output: $stderr)
23
+ def initialize(config_path: ConfigFiles.config_path, log_dir: nil, max_bytes: DEFAULT_MAX_BYTES, clock: Time, monotonic_clock: Process, error_output: $stderr, warning_sink: nil)
24
24
  @config_path = config_path
25
25
  @log_dir = log_dir
26
26
  @max_bytes = max_bytes.to_i.positive? ? max_bytes.to_i : DEFAULT_MAX_BYTES
27
27
  @clock = clock
28
28
  @monotonic_clock = monotonic_clock
29
29
  @error_output = error_output
30
+ @warning_sink = warning_sink
30
31
  @mutex = Mutex.new
31
32
  @warned = false
32
33
  end
@@ -192,7 +193,9 @@ module Kward
192
193
  return if @warned
193
194
 
194
195
  @warned = true
195
- @error_output&.puts("Warning: telemetry logging failed: #{error.message}")
196
+ message = "Warning: telemetry logging failed: #{error.message}"
197
+ sink = @warning_sink || ConfigFiles.warning_sink
198
+ sink ? sink.call(message) : @error_output&.puts(message)
196
199
  rescue StandardError
197
200
  nil
198
201
  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.0"
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.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