kward 0.66.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 (93) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +9 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Gemfile +8 -0
  5. data/Gemfile.lock +90 -0
  6. data/LICENSE +21 -0
  7. data/README.md +101 -0
  8. data/Rakefile +20 -0
  9. data/doc/authentication.md +105 -0
  10. data/doc/code-search.md +56 -0
  11. data/doc/configuration.md +310 -0
  12. data/doc/extensibility.md +186 -0
  13. data/doc/getting-started.md +127 -0
  14. data/doc/memory.md +192 -0
  15. data/doc/plugins.md +223 -0
  16. data/doc/releasing.md +36 -0
  17. data/doc/rpc.md +635 -0
  18. data/doc/usage.md +179 -0
  19. data/doc/web-search.md +28 -0
  20. data/exe/kward +5 -0
  21. data/kward.gemspec +33 -0
  22. data/lib/kward/agent.rb +234 -0
  23. data/lib/kward/ansi.rb +276 -0
  24. data/lib/kward/auth/file.rb +11 -0
  25. data/lib/kward/auth/github_oauth.rb +222 -0
  26. data/lib/kward/auth/openai_oauth.rb +323 -0
  27. data/lib/kward/auth/openrouter_api_key.rb +40 -0
  28. data/lib/kward/cancellation.rb +54 -0
  29. data/lib/kward/cli.rb +2122 -0
  30. data/lib/kward/clipboard.rb +84 -0
  31. data/lib/kward/compactor.rb +998 -0
  32. data/lib/kward/config_files.rb +564 -0
  33. data/lib/kward/conversation.rb +148 -0
  34. data/lib/kward/events.rb +13 -0
  35. data/lib/kward/export_path.rb +28 -0
  36. data/lib/kward/image_attachments.rb +331 -0
  37. data/lib/kward/markdown_transcript.rb +72 -0
  38. data/lib/kward/memory/manager.rb +652 -0
  39. data/lib/kward/message_access.rb +42 -0
  40. data/lib/kward/model/chat_invocation.rb +23 -0
  41. data/lib/kward/model/client.rb +875 -0
  42. data/lib/kward/model/context_overflow.rb +55 -0
  43. data/lib/kward/model/context_usage.rb +104 -0
  44. data/lib/kward/model/model_info.rb +188 -0
  45. data/lib/kward/model/retry_message.rb +11 -0
  46. data/lib/kward/model/stream_parser.rb +205 -0
  47. data/lib/kward/pan/index.html.erb +143 -0
  48. data/lib/kward/pan/server.rb +397 -0
  49. data/lib/kward/plugin_registry.rb +327 -0
  50. data/lib/kward/private_file.rb +18 -0
  51. data/lib/kward/prompt_interface.rb +2437 -0
  52. data/lib/kward/prompts/commands.rb +50 -0
  53. data/lib/kward/prompts/templates.rb +60 -0
  54. data/lib/kward/prompts.rb +58 -0
  55. data/lib/kward/resources/avatar_kward_logo.rb +48 -0
  56. data/lib/kward/resources/pixel_logo.rb +230 -0
  57. data/lib/kward/rpc/auth_manager.rb +265 -0
  58. data/lib/kward/rpc/config_manager.rb +58 -0
  59. data/lib/kward/rpc/prompt_bridge.rb +104 -0
  60. data/lib/kward/rpc/redactor.rb +47 -0
  61. data/lib/kward/rpc/server.rb +639 -0
  62. data/lib/kward/rpc/session_manager.rb +1122 -0
  63. data/lib/kward/rpc/tool_event_normalizer.rb +68 -0
  64. data/lib/kward/rpc/tool_metadata.rb +80 -0
  65. data/lib/kward/rpc/transcript_normalizer.rb +307 -0
  66. data/lib/kward/rpc/transport.rb +58 -0
  67. data/lib/kward/session_diff.rb +125 -0
  68. data/lib/kward/session_store.rb +493 -0
  69. data/lib/kward/skills/registry.rb +76 -0
  70. data/lib/kward/starter_pack_installer.rb +110 -0
  71. data/lib/kward/steering.rb +56 -0
  72. data/lib/kward/telemetry/logger.rb +195 -0
  73. data/lib/kward/telemetry/stats.rb +466 -0
  74. data/lib/kward/tools/ask_user_question.rb +107 -0
  75. data/lib/kward/tools/base.rb +45 -0
  76. data/lib/kward/tools/code_search.rb +65 -0
  77. data/lib/kward/tools/edit_file.rb +41 -0
  78. data/lib/kward/tools/list_directory.rb +21 -0
  79. data/lib/kward/tools/read_file.rb +30 -0
  80. data/lib/kward/tools/read_skill.rb +27 -0
  81. data/lib/kward/tools/registry.rb +117 -0
  82. data/lib/kward/tools/run_shell_command.rb +28 -0
  83. data/lib/kward/tools/search/code.rb +445 -0
  84. data/lib/kward/tools/search/web.rb +747 -0
  85. data/lib/kward/tools/tool_call.rb +87 -0
  86. data/lib/kward/tools/web_search.rb +48 -0
  87. data/lib/kward/tools/write_file.rb +29 -0
  88. data/lib/kward/transcript_export.rb +40 -0
  89. data/lib/kward/version.rb +4 -0
  90. data/lib/kward/workspace.rb +377 -0
  91. data/lib/kward.rb +6 -0
  92. data/lib/main.rb +3 -0
  93. metadata +232 -0
@@ -0,0 +1,493 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require "securerandom"
4
+ require "time"
5
+ require_relative "config_files"
6
+ require_relative "conversation"
7
+ require_relative "message_access"
8
+ require_relative "rpc/tool_event_normalizer"
9
+ require_relative "tools/tool_call"
10
+ require_relative "workspace"
11
+
12
+ module Kward
13
+ class SessionStore
14
+ VERSION = 1
15
+
16
+ SessionInfo = Struct.new(:id, :path, :cwd, :created_at, :modified_at, :name, :first_message, :message_count, :parent_id, :parent_path, :depth, :is_last, :ancestor_continues, keyword_init: true)
17
+
18
+ class Session
19
+ attr_reader :id, :path, :cwd, :created_at, :parent_id, :parent_path
20
+ attr_accessor :name
21
+
22
+ def initialize(store:, id:, path:, cwd:, created_at:, name: nil, parent_id: nil, parent_path: nil)
23
+ @store = store
24
+ @id = id
25
+ @path = path
26
+ @cwd = cwd
27
+ @created_at = created_at
28
+ @name = name
29
+ @parent_id = parent_id
30
+ @parent_path = parent_path
31
+ end
32
+
33
+ def attach(conversation)
34
+ conversation.on_append = lambda { |message| append_message(message) }
35
+ conversation.on_compact = lambda { |message| compact(message) }
36
+ conversation.on_tool_execution = lambda { |tool_call, content| append_tool_execution(tool_call, content) }
37
+ self
38
+ end
39
+
40
+ def append_message(message)
41
+ @store.append_record(@path, {
42
+ type: "message",
43
+ timestamp: Time.now.utc.iso8601(3),
44
+ message: message
45
+ })
46
+ end
47
+
48
+ def compact(message)
49
+ @store.append_record(@path, {
50
+ type: "compaction",
51
+ timestamp: Time.now.utc.iso8601(3),
52
+ message: message
53
+ })
54
+ end
55
+
56
+ def append_tool_execution(tool_call, content)
57
+ @store.append_record(@path, RPC::ToolEventNormalizer.new(tool_call, content: content).execution_record)
58
+ end
59
+
60
+ def update_memory_state(session_memories:, last_retrieval: nil)
61
+ @store.append_record(@path, {
62
+ type: "memory_state",
63
+ timestamp: Time.now.utc.iso8601(3),
64
+ sessionMemories: Array(session_memories),
65
+ lastRetrieval: last_retrieval
66
+ })
67
+ end
68
+
69
+ def rename(name)
70
+ @name = name.to_s.strip.empty? ? nil : name.to_s.strip
71
+ @store.append_record(@path, {
72
+ type: "session_info",
73
+ timestamp: Time.now.utc.iso8601(3),
74
+ name: @name
75
+ })
76
+ end
77
+
78
+ def update_runtime(model:, reasoning_effort:)
79
+ @store.append_record(@path, {
80
+ type: "session_info",
81
+ timestamp: Time.now.utc.iso8601(3),
82
+ name: @name,
83
+ model: model.to_s,
84
+ reasoningEffort: reasoning_effort.to_s
85
+ }.delete_if { |_key, value| value.to_s.empty? })
86
+ end
87
+
88
+ def delete_if_unused
89
+ @store.delete_unused_session(self)
90
+ end
91
+ end
92
+
93
+ def initialize(config_dir: ConfigFiles.config_dir, cwd: Dir.pwd)
94
+ @config_dir = config_dir
95
+ @cwd = File.expand_path(cwd)
96
+ end
97
+
98
+ attr_reader :cwd
99
+
100
+ def create(model: nil, reasoning_effort: nil, parent_id: nil, parent_path: nil)
101
+ dir = session_dir
102
+ FileUtils.mkdir_p(dir, mode: 0o700)
103
+ created_at = Time.now.utc
104
+ id = SecureRandom.uuid
105
+ path = File.join(dir, "#{created_at.iso8601(3).tr(':', '-')}_#{id}.jsonl")
106
+ header = {
107
+ type: "session",
108
+ version: VERSION,
109
+ id: id,
110
+ timestamp: created_at.iso8601(3),
111
+ cwd: @cwd,
112
+ model: model.to_s,
113
+ reasoningEffort: reasoning_effort.to_s,
114
+ parentId: parent_id.to_s,
115
+ parentPath: parent_path.to_s
116
+ }.delete_if { |_key, value| value.to_s.empty? }
117
+
118
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
119
+ file.write(JSON.generate(header))
120
+ file.write("\n")
121
+ end
122
+ File.chmod(0o600, path)
123
+
124
+ Session.new(store: self, id: id, path: path, cwd: @cwd, created_at: created_at, parent_id: parent_id, parent_path: parent_path)
125
+ end
126
+
127
+ def create_from_conversation(conversation, parent_session: nil)
128
+ session = create(model: conversation.model, reasoning_effort: conversation.reasoning_effort, parent_id: parent_session&.id, parent_path: parent_session&.path)
129
+ persisted_messages(conversation).each { |message| session.append_message(message) }
130
+ session.attach(conversation)
131
+ session
132
+ end
133
+
134
+ def create_independent_from_conversation(conversation, parent_session: nil)
135
+ create_independent_from_messages(
136
+ persisted_messages(conversation),
137
+ read_paths: Array(conversation.read_paths),
138
+ model: conversation.model,
139
+ reasoning_effort: conversation.reasoning_effort,
140
+ parent_session: parent_session
141
+ )
142
+ end
143
+
144
+ def create_independent_from_messages(messages, read_paths: [], model: nil, reasoning_effort: nil, parent_session: nil)
145
+ session = create(model: model, reasoning_effort: reasoning_effort, parent_id: parent_session&.id, parent_path: parent_session&.path)
146
+ persisted = deep_copy(messages)
147
+ persisted.each { |message| session.append_message(message) }
148
+ conversation = Conversation.new(messages: deep_copy(persisted), read_paths: read_paths, workspace_root: @cwd, model: model, reasoning_effort: reasoning_effort)
149
+ session.attach(conversation)
150
+ [session, conversation]
151
+ end
152
+
153
+ def session_location(path)
154
+ resolved_path = resolve_session_path(path)
155
+ records = records_from_file(resolved_path)
156
+ header = session_header(records, resolved_path)
157
+ { path: resolved_path, cwd: header["cwd"].to_s.empty? ? @cwd : header["cwd"].to_s }
158
+ end
159
+
160
+ def load(path, workspace: Workspace.new, model: nil, reasoning_effort: nil)
161
+ resolved_path = resolve_session_path(path)
162
+ records = records_from_file(resolved_path)
163
+ header = session_header(records, resolved_path)
164
+
165
+ messages = restored_messages(records)
166
+ name = session_name(records)
167
+ read_paths = restored_read_paths(messages, workspace)
168
+ memory_state = restored_memory_state(records)
169
+
170
+ runtime = session_runtime(records, header)
171
+ conversation = Conversation.new(
172
+ messages: messages,
173
+ read_paths: read_paths,
174
+ workspace_root: workspace.root,
175
+ model: runtime["model"] || model,
176
+ reasoning_effort: runtime["reasoningEffort"] || reasoning_effort,
177
+ session_memories: memory_state["sessionMemories"],
178
+ last_memory_retrieval: memory_state["lastRetrieval"]
179
+ )
180
+ conversation.mark_last_entry_compaction! if latest_record_type(records) == "compaction"
181
+ session = Session.new(
182
+ store: self,
183
+ id: header["id"],
184
+ path: resolved_path,
185
+ cwd: header["cwd"].to_s,
186
+ created_at: parse_time(header["timestamp"]) || File.mtime(resolved_path),
187
+ name: name,
188
+ parent_id: header["parentId"],
189
+ parent_path: header["parentPath"]
190
+ )
191
+ session.attach(conversation)
192
+ [session, conversation]
193
+ end
194
+
195
+ def recent(limit: 20)
196
+ recent_sessions.first(limit)
197
+ end
198
+
199
+ def recent_tree(limit: 20)
200
+ decorate_tree(recent_sessions.first(limit))
201
+ end
202
+
203
+ def delete_unused_session(session)
204
+ path = session.path
205
+ return false if session_named?(session)
206
+ return false unless unused_session_file?(path)
207
+
208
+ File.delete(path)
209
+ true
210
+ rescue StandardError
211
+ false
212
+ end
213
+
214
+ def session_dir
215
+ File.join(@config_dir, "sessions", self.class.safe_cwd(@cwd))
216
+ end
217
+
218
+ def append_record(path, record)
219
+ File.open(path, "a", 0o600) do |file|
220
+ file.write(JSON.generate(record))
221
+ file.write("\n")
222
+ end
223
+ end
224
+
225
+ def self.safe_cwd(cwd)
226
+ "--#{File.expand_path(cwd).sub(%r{\A[/\\]}, "").gsub(%r{[/\\:]}, "-")}--"
227
+ end
228
+
229
+ private
230
+
231
+ def resolve_session_path(path)
232
+ expanded = path.to_s.start_with?("~/") ? File.join(Dir.home, path.to_s[2..]) : path.to_s
233
+ resolved = File.expand_path(expanded, @cwd)
234
+ raise "Session file not found: #{resolved}" unless File.file?(resolved)
235
+
236
+ resolved
237
+ end
238
+
239
+ def records_from_file(path)
240
+ File.readlines(path, chomp: true).filter_map do |line|
241
+ JSON.parse(line)
242
+ rescue JSON::ParserError
243
+ nil
244
+ end
245
+ end
246
+
247
+ def session_header(records, path)
248
+ header = records.find { |record| record["type"] == "session" }
249
+ raise "Invalid Kward session file: #{path}" unless header && header["id"].to_s != ""
250
+
251
+ header
252
+ end
253
+
254
+ def session_named?(session)
255
+ return true unless session.name.to_s.strip.empty?
256
+
257
+ name = session_name(records_from_file(session.path))
258
+ !name.to_s.strip.empty?
259
+ rescue StandardError
260
+ true
261
+ end
262
+
263
+ def unused_session_file?(path)
264
+ records = strict_records_from_file(path)
265
+ return false unless records
266
+
267
+ header = records.find { |record| record["type"] == "session" }
268
+ return false unless header && header["id"].to_s != ""
269
+
270
+ records.none? do |record|
271
+ next false unless record["type"] == "message"
272
+
273
+ ["user", "assistant", "tool"].include?(message_role(record["message"] || {}))
274
+ end
275
+ end
276
+
277
+ def latest_record_type(records)
278
+ records.reverse_each do |record|
279
+ type = record["type"]
280
+ return type if ["message", "compaction"].include?(type)
281
+ end
282
+ nil
283
+ end
284
+
285
+ def session_name(records)
286
+ record = records.select { |item| item["type"] == "session_info" }.last
287
+ record ? record["name"] : nil
288
+ end
289
+
290
+ def restored_memory_state(records)
291
+ records.reverse.find { |record| record["type"] == "memory_state" } || { "sessionMemories" => [], "lastRetrieval" => nil }
292
+ end
293
+
294
+ def session_runtime(records, header)
295
+ result = {
296
+ "model" => header["model"],
297
+ "reasoningEffort" => header["reasoningEffort"]
298
+ }
299
+ records.each do |record|
300
+ next unless record["type"] == "session_info"
301
+
302
+ result["model"] = record["model"] if record.key?("model")
303
+ result["reasoningEffort"] = record["reasoningEffort"] if record.key?("reasoningEffort")
304
+ end
305
+ result.delete_if { |_key, value| value.to_s.empty? }
306
+ end
307
+
308
+ def restored_messages(records)
309
+ records.each_with_object([]) do |record, messages|
310
+ message = record["message"]
311
+ case record["type"]
312
+ when "message"
313
+ messages << message if message.is_a?(Hash)
314
+ when "compaction"
315
+ messages.replace(rebuilt_compacted_messages(message, messages)) if message.is_a?(Hash)
316
+ end
317
+ end
318
+ end
319
+
320
+ def rebuilt_compacted_messages(compaction_message, previous_messages)
321
+ first_kept_entry_id = compaction_message["first_kept_entry_id"] || compaction_message["firstKeptEntryId"]
322
+ return [compaction_message] if first_kept_entry_id.to_s.empty?
323
+
324
+ messages = previous_messages.reject { |message| message_role(message) == "system" }
325
+ previous_compaction_index = messages.rindex { |message| message_role(message) == "compactionSummary" }
326
+ branch_messages = previous_compaction_index ? messages[previous_compaction_index..] : messages
327
+
328
+ branch_index = branch_messages.each_with_index.find do |message, message_index|
329
+ message["id"] == first_kept_entry_id || "message:#{message_index}" == first_kept_entry_id
330
+ end&.last
331
+ if branch_index
332
+ kept = branch_messages[branch_index..]
333
+ else
334
+ index = messages.each_with_index.find do |message, message_index|
335
+ message["id"] == first_kept_entry_id || "message:#{message_index}" == first_kept_entry_id
336
+ end&.last
337
+ kept = index ? messages[index..] : []
338
+ end
339
+ [compaction_message] + kept
340
+ end
341
+
342
+ def strict_records_from_file(path)
343
+ File.readlines(path, chomp: true).map { |line| JSON.parse(line) }
344
+ rescue JSON::ParserError
345
+ nil
346
+ end
347
+
348
+ def recent_sessions
349
+ Dir.glob(File.join(session_dir, "*.jsonl")).filter_map do |path|
350
+ session_info(path)
351
+ end.sort_by { |info| info.modified_at || Time.at(0) }.reverse
352
+ end
353
+
354
+ def decorate_tree(sessions)
355
+ by_parent = Hash.new { |hash, key| hash[key] = [] }
356
+ ids = sessions.map(&:id).to_h { |id| [id, true] }
357
+ sessions.each do |session|
358
+ parent_id = session.parent_id.to_s
359
+ key = parent_id.empty? || !ids[parent_id] ? nil : parent_id
360
+ by_parent[key] << session
361
+ end
362
+ by_parent.each_value { |children| children.sort_by! { |info| info.modified_at || Time.at(0) }.reverse! }
363
+
364
+ result = []
365
+ walk_tree(by_parent, nil, 0, [], result)
366
+ result
367
+ end
368
+
369
+ def walk_tree(by_parent, parent_id, depth, ancestor_continues, result)
370
+ children = by_parent[parent_id]
371
+ children.each_with_index do |session, index|
372
+ is_last = index == children.length - 1
373
+ session.depth = depth
374
+ session.is_last = is_last
375
+ session.ancestor_continues = ancestor_continues.dup
376
+ result << session
377
+ child_ancestor_continues = depth.zero? ? [] : ancestor_continues + [!is_last]
378
+ walk_tree(by_parent, session.id, depth + 1, child_ancestor_continues, result)
379
+ end
380
+ end
381
+
382
+ def session_info(path)
383
+ records = records_from_file(path)
384
+ header = records.find { |record| record["type"] == "session" }
385
+ return nil unless header && header["id"].to_s != ""
386
+
387
+ messages = restored_messages(records)
388
+ name = session_name(records)
389
+ first_message = messages.find { |message| ["user", "compactionSummary"].include?(message_role(message)) }
390
+ stats = File.stat(path)
391
+
392
+ SessionInfo.new(
393
+ id: header["id"],
394
+ path: path,
395
+ cwd: header["cwd"].to_s,
396
+ created_at: parse_time(header["timestamp"]) || stats.mtime,
397
+ modified_at: stats.mtime,
398
+ name: name,
399
+ first_message: first_message ? message_text(first_message) : "",
400
+ message_count: messages.count { |message| ["user", "assistant", "tool", "toolResult", "compactionSummary"].include?(message_role(message)) },
401
+ parent_id: header["parentId"],
402
+ parent_path: header["parentPath"],
403
+ depth: 0,
404
+ is_last: true,
405
+ ancestor_continues: []
406
+ )
407
+ rescue StandardError
408
+ nil
409
+ end
410
+
411
+ def persisted_messages(conversation)
412
+ conversation.messages.reject { |message| message_role(message) == "system" }.map { |message| deep_copy(message) }
413
+ end
414
+
415
+ def deep_copy(value)
416
+ JSON.parse(JSON.generate(value))
417
+ end
418
+
419
+ def restored_read_paths(messages, workspace)
420
+ tool_paths = {}
421
+ read_paths = []
422
+
423
+ messages.each do |message|
424
+ role = message_role(message)
425
+ if role == "assistant"
426
+ tool_calls(message).each do |tool_call|
427
+ next unless ToolCall.name(tool_call) == "read_file"
428
+
429
+ path = ToolCall.value(ToolCall.arguments(tool_call), :path)
430
+ tool_paths[ToolCall.id(tool_call)] = path if path
431
+ end
432
+ elsif role == "tool" && message_name(message) == "read_file"
433
+ path = tool_paths[message_tool_call_id(message)]
434
+ content = message_content(message).to_s
435
+ next unless path && !content.start_with?("Error:")
436
+
437
+ begin
438
+ read_paths << workspace.resolved_path(path)
439
+ rescue Errno::ENOENT, SecurityError
440
+ next
441
+ end
442
+ end
443
+ end
444
+
445
+ read_paths
446
+ end
447
+
448
+ def tool_calls(message)
449
+ MessageAccess.tool_calls(message)
450
+ end
451
+
452
+ def message_role(message)
453
+ MessageAccess.role(message)
454
+ end
455
+
456
+ def message_name(message)
457
+ MessageAccess.name(message)
458
+ end
459
+
460
+ def message_tool_call_id(message)
461
+ MessageAccess.tool_call_id(message)
462
+ end
463
+
464
+ def message_content(message)
465
+ MessageAccess.content(message)
466
+ end
467
+
468
+ def message_display_content(message)
469
+ MessageAccess.display_content(message)
470
+ end
471
+
472
+ def message_text(message)
473
+ return MessageAccess.summary(message).to_s.gsub(/\s+/, " ").strip.slice(0, 120) if message_role(message) == "compactionSummary"
474
+
475
+ display_content = message_display_content(message)
476
+ return display_content.to_s.gsub(/\s+/, " ").strip.slice(0, 120) unless display_content.nil?
477
+
478
+ content = message_content(message)
479
+ text = if content.is_a?(Array)
480
+ content.filter_map { |part| part["text"] || part[:text] }.join(" ")
481
+ else
482
+ content.to_s
483
+ end
484
+ text.gsub(/\s+/, " ").strip.slice(0, 120)
485
+ end
486
+
487
+ def parse_time(value)
488
+ Time.iso8601(value.to_s)
489
+ rescue ArgumentError
490
+ nil
491
+ end
492
+ end
493
+ end
@@ -0,0 +1,76 @@
1
+ require "pathname"
2
+
3
+ module Kward
4
+ module Skills
5
+ class Registry
6
+ def initialize(config_dir:, skill_class:, max_file_bytes:, markdown_parser:, inside_directory:)
7
+ @config_dir = config_dir
8
+ @skill_class = skill_class
9
+ @max_file_bytes = max_file_bytes
10
+ @markdown_parser = markdown_parser
11
+ @inside_directory = inside_directory
12
+ end
13
+
14
+ def skills
15
+ skills_root = File.join(@config_dir, "skills")
16
+ return [] unless Dir.exist?(skills_root)
17
+
18
+ seen = {}
19
+ Dir.glob(File.join(skills_root, "*", "SKILL.md")).sort.filter_map do |path|
20
+ skill = parse_skill(path)
21
+ next unless skill
22
+
23
+ if seen[skill.name]
24
+ warn "Warning: skipping duplicate Kward skill #{skill.name.inspect}: #{path}"
25
+ next
26
+ end
27
+
28
+ seen[skill.name] = true
29
+ skill
30
+ end
31
+ rescue StandardError => e
32
+ warn "Warning: skipping Kward skills in #{skills_root}: #{e.message}"
33
+ []
34
+ end
35
+
36
+ def read_skill_file(name, relative_path = nil)
37
+ skill = skills.find { |candidate| candidate.name == name.to_s }
38
+ return "Error: unknown skill: #{name}" unless skill
39
+
40
+ path = relative_path.to_s.empty? ? "SKILL.md" : relative_path.to_s
41
+ return "Error: skill path must be relative" if Pathname.new(path).absolute?
42
+
43
+ base = File.realpath(skill.folder)
44
+ target = File.expand_path(path, base)
45
+ real_target = File.realpath(target)
46
+ unless @inside_directory.call(real_target, base)
47
+ return "Error: skill path outside skill folder: #{path}"
48
+ end
49
+ return "Error: skill path is not a file: #{path}" unless File.file?(real_target)
50
+
51
+ size = File.size(real_target)
52
+ return "Error: skill file too large: #{path} (#{size} bytes)" if size > @max_file_bytes
53
+
54
+ File.read(real_target)
55
+ rescue Errno::ENOENT
56
+ "Error: skill file not found: #{path}"
57
+ rescue StandardError => e
58
+ "Error: could not read skill file #{path}: #{e.message}"
59
+ end
60
+
61
+ private
62
+
63
+ def parse_skill(path)
64
+ frontmatter, = @markdown_parser.call(path)
65
+ name = frontmatter.fetch("name", "").to_s.strip
66
+ name = File.basename(File.dirname(path)) if name.empty?
67
+ description = frontmatter.fetch("description", "").to_s.strip
68
+
69
+ @skill_class.new(name: name, description: description, folder: File.dirname(path), path: path)
70
+ rescue StandardError => e
71
+ warn "Warning: skipping Kward skill #{path}: #{e.message}"
72
+ nil
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,110 @@
1
+ require "fileutils"
2
+ require "net/http"
3
+ require "rubygems/package"
4
+ require "stringio"
5
+ require "tmpdir"
6
+ require "uri"
7
+ require "zlib"
8
+ require_relative "config_files"
9
+
10
+ module Kward
11
+ # Installs Kward's starter prompt/instruction files into the user config dir.
12
+ class StarterPackInstaller
13
+ VERSION = "v1.0.0"
14
+ ARCHIVE_URL = "https://codeload.github.com/kaiwood/kward-starter-pack/tar.gz/refs/tags/#{VERSION}".freeze
15
+ ALLOWED_FILES = ["AGENTS.md"].freeze
16
+ ALLOWED_PREFIXES = ["prompts/", "skills/"].freeze
17
+ Result = Struct.new(:installed, :skipped, keyword_init: true)
18
+
19
+ def self.install(**kwargs)
20
+ new(**kwargs).install
21
+ end
22
+
23
+ def initialize(config_dir: ConfigFiles.config_dir, archive_url: ARCHIVE_URL, downloader: nil)
24
+ @config_dir = File.expand_path(config_dir)
25
+ @archive_url = archive_url
26
+ @downloader = downloader || method(:download)
27
+ end
28
+
29
+ def install
30
+ archive = @downloader.call(@archive_url)
31
+ installed = []
32
+ skipped = []
33
+
34
+ Dir.mktmpdir("kward-starter-pack") do |dir|
35
+ files = extract_allowed_files(archive, dir)
36
+ files.each do |relative_path, source_path|
37
+ destination = destination_path(relative_path)
38
+ if File.exist?(destination)
39
+ skipped << relative_path
40
+ next
41
+ end
42
+
43
+ FileUtils.mkdir_p(File.dirname(destination), mode: 0o700)
44
+ File.open(destination, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
45
+ file.write(File.binread(source_path))
46
+ end
47
+ installed << relative_path
48
+ end
49
+ end
50
+
51
+ Result.new(installed: installed, skipped: skipped)
52
+ end
53
+
54
+ private
55
+
56
+ def download(url)
57
+ uri = URI(url)
58
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 30) do |http|
59
+ http.get(uri.request_uri)
60
+ end
61
+ raise "Starter pack download failed with HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
62
+
63
+ response.body
64
+ rescue URI::InvalidURIError, SocketError, SystemCallError, Timeout::Error => e
65
+ raise "Starter pack download failed: #{e.message}"
66
+ end
67
+
68
+ def extract_allowed_files(archive, dir)
69
+ files = []
70
+ Zlib::GzipReader.wrap(StringIO.new(archive)) do |gzip|
71
+ Gem::Package::TarReader.new(gzip) do |tar|
72
+ tar.each do |entry|
73
+ next unless entry.file?
74
+
75
+ relative_path = starter_pack_relative_path(entry.full_name)
76
+ next unless allowed_file?(relative_path)
77
+
78
+ output = File.join(dir, relative_path)
79
+ FileUtils.mkdir_p(File.dirname(output), mode: 0o700)
80
+ File.binwrite(output, entry.read)
81
+ files << [relative_path, output]
82
+ end
83
+ end
84
+ end
85
+ files
86
+ rescue Zlib::GzipFile::Error, Gem::Package::TarInvalidError => e
87
+ raise "Starter pack archive could not be read: #{e.message}"
88
+ end
89
+
90
+ def starter_pack_relative_path(path)
91
+ parts = path.to_s.split("/")
92
+ raise "Unsafe starter pack archive path: #{path}" if parts.any? { |part| part.empty? || part == "." || part == ".." }
93
+ raise "Unsafe starter pack archive path: #{path}" if path.start_with?("/")
94
+
95
+ parts[1..]&.join("/").to_s
96
+ end
97
+
98
+ def allowed_file?(relative_path)
99
+ ALLOWED_FILES.include?(relative_path) || ALLOWED_PREFIXES.any? { |prefix| relative_path.start_with?(prefix) }
100
+ end
101
+
102
+ def destination_path(relative_path)
103
+ path = File.expand_path(File.join(@config_dir, relative_path))
104
+ root = @config_dir.end_with?(File::SEPARATOR) ? @config_dir : "#{@config_dir}#{File::SEPARATOR}"
105
+ raise "Unsafe starter pack destination: #{relative_path}" unless path.start_with?(root)
106
+
107
+ path
108
+ end
109
+ end
110
+ end