kward 0.77.0 → 0.79.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +7 -2
  3. data/.github/workflows/pages.yml +1 -1
  4. data/CHANGELOG.md +50 -0
  5. data/Gemfile.lock +2 -2
  6. data/README.md +5 -2
  7. data/doc/agent-tools.md +1 -1
  8. data/doc/code-search.md +9 -6
  9. data/doc/configuration.md +106 -1
  10. data/doc/extensibility.md +2 -0
  11. data/doc/getting-started.md +1 -1
  12. data/doc/local-models.md +130 -0
  13. data/doc/permissions.md +180 -0
  14. data/doc/plugins.md +58 -0
  15. data/doc/releasing.md +1 -1
  16. data/doc/rpc.md +73 -1
  17. data/doc/sandboxing.md +120 -0
  18. data/doc/security.md +15 -5
  19. data/doc/skills.md +10 -0
  20. data/doc/tabs.md +4 -3
  21. data/doc/web-search.md +4 -2
  22. data/doc/workspace-tools.md +3 -3
  23. data/kward.gemspec +1 -1
  24. data/lib/kward/auth/anthropic_oauth.rb +2 -2
  25. data/lib/kward/auth/github_oauth.rb +3 -3
  26. data/lib/kward/auth/oauth_helpers.rb +4 -2
  27. data/lib/kward/auth/openai_oauth.rb +2 -1
  28. data/lib/kward/cli/doctor.rb +21 -0
  29. data/lib/kward/cli/plugins.rb +22 -0
  30. data/lib/kward/cli/rendering.rb +7 -1
  31. data/lib/kward/cli/runtime_helpers.rb +15 -1
  32. data/lib/kward/cli/settings.rb +66 -4
  33. data/lib/kward/cli/slash_commands.rb +109 -1
  34. data/lib/kward/cli/tabs.rb +140 -34
  35. data/lib/kward/cli.rb +31 -10
  36. data/lib/kward/config_files.rb +90 -1
  37. data/lib/kward/conversation.rb +14 -1
  38. data/lib/kward/hooks/http_handler.rb +2 -1
  39. data/lib/kward/http.rb +18 -0
  40. data/lib/kward/local_command_runner.rb +2 -2
  41. data/lib/kward/model/client.rb +173 -15
  42. data/lib/kward/model/model_info.rb +11 -1
  43. data/lib/kward/model/payloads.rb +7 -1
  44. data/lib/kward/model/stream_parser.rb +58 -26
  45. data/lib/kward/openrouter_model_cache.rb +2 -1
  46. data/lib/kward/pan/index.html.erb +50 -0
  47. data/lib/kward/pan/server.rb +49 -2
  48. data/lib/kward/permissions/policy.rb +171 -0
  49. data/lib/kward/plugin_registry.rb +54 -2
  50. data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
  51. data/lib/kward/prompt_interface/editor/controller.rb +36 -3
  52. data/lib/kward/prompt_interface/question_prompt.rb +12 -3
  53. data/lib/kward/prompt_interface.rb +20 -0
  54. data/lib/kward/prompts/commands.rb +2 -0
  55. data/lib/kward/prompts.rb +16 -5
  56. data/lib/kward/rpc/plugin_chat_manager.rb +302 -0
  57. data/lib/kward/rpc/server.rb +76 -3
  58. data/lib/kward/rpc/session_manager.rb +43 -8
  59. data/lib/kward/rpc/transcript_normalizer.rb +14 -5
  60. data/lib/kward/sandbox/capabilities.rb +39 -0
  61. data/lib/kward/sandbox/command_runner.rb +28 -0
  62. data/lib/kward/sandbox/environment.rb +24 -0
  63. data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
  64. data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
  65. data/lib/kward/sandbox/passthrough_runner.rb +13 -0
  66. data/lib/kward/sandbox/policy.rb +74 -0
  67. data/lib/kward/sandbox/runner_factory.rb +55 -0
  68. data/lib/kward/sandbox/unavailable_runner.rb +21 -0
  69. data/lib/kward/sandbox.rb +9 -0
  70. data/lib/kward/session_store.rb +26 -0
  71. data/lib/kward/skills/capture.rb +144 -0
  72. data/lib/kward/starter_pack_installer.rb +3 -1
  73. data/lib/kward/tab_driver.rb +87 -0
  74. data/lib/kward/tab_store.rb +74 -12
  75. data/lib/kward/tools/code_search.rb +8 -2
  76. data/lib/kward/tools/fetch_content.rb +4 -2
  77. data/lib/kward/tools/fetch_raw.rb +3 -1
  78. data/lib/kward/tools/registry.rb +56 -8
  79. data/lib/kward/tools/search/code.rb +46 -12
  80. data/lib/kward/tools/search/web.rb +60 -17
  81. data/lib/kward/tools/search/web_fetch.rb +206 -38
  82. data/lib/kward/tools/web_search.rb +3 -1
  83. data/lib/kward/update_check.rb +2 -1
  84. data/lib/kward/version.rb +1 -1
  85. data/lib/kward/workspace.rb +18 -3
  86. data/lib/kward/workspace_factory.rb +17 -0
  87. data/templates/default/fulldoc/html/js/kward.js +1 -0
  88. data/templates/default/kward_navigation.rb +5 -2
  89. data/templates/default/layout/html/layout.erb +2 -0
  90. data/templates/default/layout/html/setup.rb +2 -0
  91. metadata +22 -2
@@ -0,0 +1,28 @@
1
+ require_relative "../local_command_runner"
2
+
3
+ # Namespace for operating-system command sandboxing.
4
+ module Kward
5
+ module Sandbox
6
+ # Base command runner. Platform runners provide an argv that is then executed
7
+ # through LocalCommandRunner, preserving Kward's timeout and cancellation behavior.
8
+ class CommandRunner
9
+ def initialize(policy:, capabilities:)
10
+ @policy = policy
11
+ @capabilities = capabilities
12
+ end
13
+
14
+ attr_reader :policy, :capabilities
15
+
16
+ def run(command, cwd:, timeout_seconds:, max_output_bytes:, cancellation: nil, &block)
17
+ LocalCommandRunner.new(
18
+ timeout_seconds: timeout_seconds,
19
+ max_output_bytes: max_output_bytes
20
+ ).run(*command_argv(command, cwd: cwd), cwd: cwd, cancellation: cancellation, &block)
21
+ end
22
+
23
+ def command_argv(command, cwd:)
24
+ raise NotImplementedError, "#{self.class} must implement #command_argv"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,24 @@
1
+ # Namespace for operating-system command sandboxing.
2
+ module Kward
3
+ module Sandbox
4
+ # Builds a minimal environment for sandboxed command workers. In particular,
5
+ # credentials and language-runtime injection variables are not inherited.
6
+ module Environment
7
+ SAFE_VARIABLES = %w[PATH LANG LC_ALL LC_CTYPE TERM COLORTERM NO_COLOR].freeze
8
+
9
+ module_function
10
+
11
+ def command_worker(temporary_root, source: ENV)
12
+ SAFE_VARIABLES.each_with_object({}) do |name, environment|
13
+ value = source[name].to_s
14
+ environment[name] = value unless value.empty?
15
+ end.merge(
16
+ "HOME" => temporary_root,
17
+ "TMPDIR" => temporary_root,
18
+ "TMP" => temporary_root,
19
+ "TEMP" => temporary_root
20
+ )
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,71 @@
1
+ require "fileutils"
2
+ require "tmpdir"
3
+ require_relative "command_runner"
4
+ require_relative "environment"
5
+
6
+ # Namespace for operating-system command sandboxing.
7
+ module Kward
8
+ module Sandbox
9
+ # Runs a command in a Bubblewrap mount namespace on Linux.
10
+ class LinuxBubblewrapRunner < CommandRunner
11
+ def self.executable
12
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).filter_map do |directory|
13
+ path = File.expand_path("bwrap", directory)
14
+ path if File.file?(path) && File.executable?(path)
15
+ end.first
16
+ end
17
+
18
+ def self.capabilities(platform: RUBY_PLATFORM, executable: self.executable)
19
+ available = platform.to_s.include?("linux") && !executable.to_s.empty?
20
+ return Capabilities.new(available: true, filesystem_enforced: true, child_network_enforced: true, backend: "linux_bubblewrap") if available
21
+
22
+ Capabilities.new(
23
+ available: false,
24
+ filesystem_enforced: false,
25
+ child_network_enforced: false,
26
+ backend: "linux_bubblewrap",
27
+ reason: "Bubblewrap is unavailable"
28
+ )
29
+ end
30
+
31
+ def initialize(policy:, executable: self.class.executable, capabilities: self.class.capabilities(executable: executable))
32
+ raise ArgumentError, "Linux Bubblewrap is unavailable: #{capabilities.reason}" unless capabilities.available?
33
+
34
+ super(policy:, capabilities:)
35
+ @executable = executable
36
+ end
37
+
38
+ def run(command, cwd:, timeout_seconds:, max_output_bytes:, cancellation: nil, &block)
39
+ temporary_root = Dir.mktmpdir("kward-sandbox")
40
+ environment = Environment.command_worker(temporary_root)
41
+
42
+ LocalCommandRunner.new(
43
+ timeout_seconds: timeout_seconds,
44
+ max_output_bytes: max_output_bytes
45
+ ).run(
46
+ *command_argv(command, cwd: cwd, temporary_root: temporary_root),
47
+ env: environment,
48
+ cwd: cwd,
49
+ cancellation: cancellation,
50
+ unsetenv_others: true,
51
+ &block
52
+ )
53
+ ensure
54
+ FileUtils.remove_entry(temporary_root) if temporary_root && File.exist?(temporary_root)
55
+ end
56
+
57
+ def command_argv(command, cwd:, temporary_root: nil)
58
+ temporary_root ||= Dir.tmpdir
59
+ workspace_root = policy.workspace_root
60
+ writable_roots = policy.command_writable_roots + [File.realpath(temporary_root)]
61
+ argv = [@executable, "--die-with-parent", "--new-session", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc"]
62
+ argv << "--unshare-net" unless policy.child_network_allowed?
63
+ argv.concat(["--bind", workspace_root, workspace_root]) if policy.workspace_write?
64
+ writable_roots.reject { |path| path == workspace_root }.each { |path| argv.concat(["--bind", path, path]) }
65
+ argv.concat(["--ro-bind", File.join(workspace_root, ".git"), File.join(workspace_root, ".git")]) if policy.protect_git_metadata? && File.exist?(File.join(workspace_root, ".git"))
66
+ argv.concat(["--setenv", "TMPDIR", temporary_root, "--setenv", "TMP", temporary_root, "--setenv", "TEMP", temporary_root])
67
+ argv.concat(["--chdir", cwd.to_s, "--", "/bin/sh", "-lc", command.to_s])
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,96 @@
1
+ require "fileutils"
2
+ require "tmpdir"
3
+ require_relative "command_runner"
4
+ require_relative "environment"
5
+
6
+ # Namespace for operating-system command sandboxing.
7
+ module Kward
8
+ module Sandbox
9
+ # Runs a command under macOS Seatbelt. This backend deliberately constrains
10
+ # writes and child networking first; host-process and plugin access remain
11
+ # outside the command worker boundary.
12
+ class MacOSSeatbeltRunner < CommandRunner
13
+ EXECUTABLE = "/usr/bin/sandbox-exec".freeze
14
+ PROTECTED_READ_DIRECTORIES = %w[.aws .gnupg .kward .ssh .config/gcloud .config/gh].freeze
15
+
16
+ def self.capabilities(platform: RUBY_PLATFORM)
17
+ available = platform.to_s.include?("darwin") && File.executable?(EXECUTABLE)
18
+ return Capabilities.new(available: true, filesystem_enforced: true, child_network_enforced: true, backend: "macos_seatbelt") if available
19
+
20
+ Capabilities.new(
21
+ available: false,
22
+ filesystem_enforced: false,
23
+ child_network_enforced: false,
24
+ backend: "macos_seatbelt",
25
+ reason: "#{EXECUTABLE} is unavailable"
26
+ )
27
+ end
28
+
29
+ def initialize(policy:, capabilities: self.class.capabilities)
30
+ raise ArgumentError, "macOS Seatbelt is unavailable: #{capabilities.reason}" unless capabilities.available?
31
+
32
+ super
33
+ end
34
+
35
+ def run(command, cwd:, timeout_seconds:, max_output_bytes:, cancellation: nil, &block)
36
+ temporary_root = Dir.mktmpdir("kward-sandbox")
37
+ environment = Environment.command_worker(temporary_root)
38
+
39
+ LocalCommandRunner.new(
40
+ timeout_seconds: timeout_seconds,
41
+ max_output_bytes: max_output_bytes
42
+ ).run(
43
+ *command_argv(command, cwd: cwd, temporary_root: temporary_root),
44
+ env: environment,
45
+ cwd: cwd,
46
+ cancellation: cancellation,
47
+ unsetenv_others: true,
48
+ &block
49
+ )
50
+ ensure
51
+ FileUtils.remove_entry(temporary_root) if temporary_root && File.exist?(temporary_root)
52
+ end
53
+
54
+ def command_argv(command, cwd:, temporary_root: nil)
55
+ temporary_root ||= Dir.tmpdir
56
+ [EXECUTABLE, "-p", profile(temporary_root), "/bin/zsh", "-lc", command.to_s]
57
+ end
58
+
59
+ private
60
+
61
+ def profile(temporary_root)
62
+ writable_roots = policy.command_writable_roots + [File.realpath(temporary_root)]
63
+ rules = [
64
+ "(version 1)",
65
+ "(deny default)",
66
+ "(allow process*)",
67
+ "(allow signal)",
68
+ "(allow file-read*)",
69
+ "(allow file-write-data (literal \"/dev/null\"))",
70
+ "(allow sysctl-read)",
71
+ "(allow mach-lookup)",
72
+ "(allow ipc-posix-shm*)",
73
+ "(allow user-preference-read)",
74
+ "(allow pseudo-tty)"
75
+ ]
76
+ rules << "(allow network*)" if policy.child_network_allowed?
77
+ rules.concat(writable_roots.map { |path| "(allow file-write* (subpath #{seatbelt_string(path)}))" })
78
+ rules.concat(protected_read_roots.map { |path| "(deny file-read* (subpath #{seatbelt_string(path)}))" })
79
+ rules << "(deny file-write* (subpath #{seatbelt_string(File.join(policy.workspace_root, ".git"))}))" if policy.protect_git_metadata?
80
+ rules.join("\n")
81
+ end
82
+
83
+ def protected_read_roots
84
+ home = ENV.fetch("HOME", Dir.home)
85
+ PROTECTED_READ_DIRECTORIES.map do |path|
86
+ root = File.join(home, path)
87
+ File.exist?(root) ? File.realpath(root) : root
88
+ end
89
+ end
90
+
91
+ def seatbelt_string(value)
92
+ %Q{"#{value.to_s.gsub(/([\\"])/, '\\\\1')}"}
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,13 @@
1
+ require_relative "command_runner"
2
+
3
+ # Namespace for operating-system command sandboxing.
4
+ module Kward
5
+ module Sandbox
6
+ # Preserves existing LocalCommandRunner behavior when sandboxing is off.
7
+ class PassthroughRunner < CommandRunner
8
+ def command_argv(command, cwd:)
9
+ [command.to_s]
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,74 @@
1
+ require "pathname"
2
+
3
+ # Namespace for operating-system command sandboxing.
4
+ module Kward
5
+ module Sandbox
6
+ # Immutable, user-configured restrictions for a command worker.
7
+ class Policy
8
+ MODES = %w[off read_only workspace_write].freeze
9
+ NETWORK_MODES = %w[deny allow].freeze
10
+
11
+ attr_reader :mode, :network, :workspace_root, :writable_roots
12
+
13
+ def initialize(mode: "off", network: "deny", workspace_root:, writable_roots: [], protect_git_metadata: true)
14
+ @mode = normalize_mode(mode)
15
+ @network = normalize_network(network)
16
+ @workspace_root = canonical_path(workspace_root)
17
+ @writable_roots = normalize_writable_roots(writable_roots)
18
+ @protect_git_metadata = protect_git_metadata != false
19
+ end
20
+
21
+ def enabled?
22
+ mode != "off"
23
+ end
24
+
25
+ def read_only?
26
+ mode == "read_only"
27
+ end
28
+
29
+ def workspace_write?
30
+ mode == "workspace_write"
31
+ end
32
+
33
+ def protect_git_metadata?
34
+ @protect_git_metadata
35
+ end
36
+
37
+ def child_network_allowed?
38
+ network == "allow"
39
+ end
40
+
41
+ def command_writable_roots
42
+ return [] unless workspace_write?
43
+
44
+ ([workspace_root] + writable_roots).uniq
45
+ end
46
+
47
+ private
48
+
49
+ def normalize_mode(value)
50
+ mode = value.to_s
51
+ return mode if MODES.include?(mode)
52
+
53
+ raise ArgumentError, "sandbox mode must be one of: #{MODES.join(", ")}"
54
+ end
55
+
56
+ def normalize_network(value)
57
+ network = value.to_s
58
+ return network if NETWORK_MODES.include?(network)
59
+
60
+ raise ArgumentError, "sandbox network must be one of: #{NETWORK_MODES.join(", ")}"
61
+ end
62
+
63
+ def normalize_writable_roots(roots)
64
+ Array(roots).map { |root| canonical_path(root) }.uniq.reject { |root| root == workspace_root }
65
+ end
66
+
67
+ def canonical_path(path)
68
+ Pathname.new(path.to_s).realpath.to_s
69
+ rescue Errno::ENOENT
70
+ raise ArgumentError, "sandbox path does not exist: #{path}"
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,55 @@
1
+ require_relative "capabilities"
2
+ require_relative "passthrough_runner"
3
+ require_relative "unavailable_runner"
4
+ require_relative "macos_seatbelt_runner"
5
+ require_relative "linux_bubblewrap_runner"
6
+
7
+ # Namespace for operating-system command sandboxing.
8
+ module Kward
9
+ module Sandbox
10
+ # Selects the command runner that can enforce a policy on the current host.
11
+ module RunnerFactory
12
+ module_function
13
+
14
+ def build(policy, platform: RUBY_PLATFORM)
15
+ return PassthroughRunner.new(policy:, capabilities: off_capabilities) unless policy.enabled?
16
+
17
+ runner_class = runner_class_for(platform)
18
+ return UnavailableRunner.new(policy:, capabilities: unsupported_capabilities(platform)) unless runner_class
19
+
20
+ capabilities = runner_class.capabilities(platform:)
21
+ return UnavailableRunner.new(policy:, capabilities:) unless capabilities.available?
22
+
23
+ runner_class.new(policy:, capabilities:)
24
+ end
25
+
26
+ def off_capabilities
27
+ Capabilities.new(
28
+ available: true,
29
+ filesystem_enforced: false,
30
+ child_network_enforced: false,
31
+ backend: "off"
32
+ )
33
+ end
34
+
35
+ def unsupported_capabilities(platform)
36
+ Capabilities.new(
37
+ available: false,
38
+ filesystem_enforced: false,
39
+ child_network_enforced: false,
40
+ backend: "unsupported",
41
+ reason: "Sandboxing is unsupported on #{platform}"
42
+ )
43
+ end
44
+
45
+ def runner_class_for(platform)
46
+ value = platform.to_s
47
+ return MacOSSeatbeltRunner if value.include?("darwin")
48
+ return LinuxBubblewrapRunner if value.include?("linux")
49
+
50
+ nil
51
+ end
52
+ private_class_method :runner_class_for
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,21 @@
1
+ require_relative "command_runner"
2
+
3
+ # Namespace for operating-system command sandboxing.
4
+ module Kward
5
+ module Sandbox
6
+ # Raised when a requested sandbox cannot be enforced on this host.
7
+ class UnavailableError < StandardError
8
+ end
9
+
10
+ # Fails closed rather than running a requested sandbox policy unrestricted.
11
+ class UnavailableRunner < CommandRunner
12
+ def command_argv(command, cwd:)
13
+ []
14
+ end
15
+
16
+ def run(command, cwd:, timeout_seconds:, max_output_bytes:, cancellation: nil, &block)
17
+ raise UnavailableError, "Sandbox #{policy.mode} is unavailable: #{capabilities.reason}"
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,9 @@
1
+ require_relative "sandbox/policy"
2
+ require_relative "sandbox/capabilities"
3
+ require_relative "sandbox/environment"
4
+ require_relative "sandbox/command_runner"
5
+ require_relative "sandbox/passthrough_runner"
6
+ require_relative "sandbox/unavailable_runner"
7
+ require_relative "sandbox/macos_seatbelt_runner"
8
+ require_relative "sandbox/linux_bubblewrap_runner"
9
+ require_relative "sandbox/runner_factory"
@@ -315,6 +315,16 @@ module Kward
315
315
  recent_sessions(limit: limit, keep_empty_path: keep_empty_path)
316
316
  end
317
317
 
318
+ # Lists all persisted sessions under this config directory for an explicit
319
+ # user-selected operation such as skill capture. Unlike #recent, this is not
320
+ # scoped to the store's current workspace.
321
+ def capture_candidates
322
+ pattern = File.join(@config_dir, "sessions", "**", "*.jsonl")
323
+ Dir.glob(pattern).filter_map { |path| session_info(path) }.sort_by(&:modified_at).reverse
324
+ rescue StandardError
325
+ []
326
+ end
327
+
318
328
  # Persists the last active session pointer for workspace auto-resume.
319
329
  def remember_last_session(session)
320
330
  return unless session&.path
@@ -430,6 +440,22 @@ module Kward
430
440
  current_leaf_id(records_from_file(resolve_session_path(path)))
431
441
  end
432
442
 
443
+ # Returns the complete persisted active branch for an explicit export-like
444
+ # consumer. Unlike #load, this does not attach a conversation or mutate the
445
+ # session file.
446
+ #
447
+ # @return [Hash] session metadata, saved system-prompt snapshots, and active branch records
448
+ def capture_branch(path)
449
+ resolved_path = resolve_session_path(path)
450
+ records = records_from_file(resolved_path)
451
+ {
452
+ path: resolved_path,
453
+ header: session_header(records, resolved_path),
454
+ system_prompts: records.select { |record| record["type"] == "system_prompt" },
455
+ entries: branch_records(records)
456
+ }
457
+ end
458
+
433
459
  def append_record(path, record)
434
460
  File.open(path, "a", 0o600) do |file|
435
461
  file.write(JSON.generate(record))
@@ -0,0 +1,144 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require "tempfile"
4
+ require "yaml"
5
+ require_relative "../compaction/token_estimator"
6
+ require_relative "../config_files"
7
+ require_relative "../message_access"
8
+
9
+ # Namespace for the Kward CLI agent runtime.
10
+ module Kward
11
+ module Skills
12
+ # Generates and persists reviewed personal skills from saved session branches.
13
+ class Capture
14
+ Draft = Struct.new(:content, :source_path, :name, :description, keyword_init: true)
15
+
16
+ class Error < StandardError; end
17
+ class SourceTooLargeError < Error; end
18
+ class InvalidDraftError < Error; end
19
+ class ConflictError < Error; end
20
+
21
+ OUTPUT_TOKEN_RESERVE = 4_096
22
+ PROMPT_TOKEN_RESERVE = 1_024
23
+ NAME_PATTERN = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/
24
+
25
+ def initialize(session_store:, client:, config_dir: ConfigFiles.config_dir, token_estimator: Compaction::TokenEstimator.new)
26
+ @session_store = session_store
27
+ @client = client
28
+ @config_dir = config_dir
29
+ @token_estimator = token_estimator
30
+ end
31
+
32
+ def generate(session_path, cancellation: nil)
33
+ ensure_personal_session_path!(session_path)
34
+ source = @session_store.capture_branch(session_path)
35
+ raise Error, "Session has no active branch to capture" if source[:entries].empty?
36
+
37
+ source_json = JSON.pretty_generate(source)
38
+ ensure_source_fits!(source_json)
39
+ response = @client.chat(draft_messages(source_json), tools: [], cancellation: cancellation)
40
+ draft(source_path: source[:path], content: MessageAccess.content(response).to_s)
41
+ end
42
+
43
+ def save(content, overwrite: false)
44
+ reviewed_draft = draft(content: content)
45
+ path = skill_path(reviewed_draft.name)
46
+ if File.exist?(path) && !overwrite
47
+ raise ConflictError, "A personal skill named #{reviewed_draft.name.inspect} already exists"
48
+ end
49
+
50
+ FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
51
+ write_atomically(path, reviewed_draft.content)
52
+ reviewed_draft
53
+ end
54
+
55
+ def skill_path(name)
56
+ File.join(@config_dir, "skills", name.to_s, "SKILL.md")
57
+ end
58
+
59
+ private
60
+
61
+ def ensure_personal_session_path!(session_path)
62
+ sessions_root = File.realpath(File.join(@config_dir, "sessions"))
63
+ source_path = File.realpath(session_path)
64
+ return if source_path.start_with?("#{sessions_root}#{File::SEPARATOR}")
65
+
66
+ raise Error, "Skill capture source must be a persisted Kward session"
67
+ rescue Errno::ENOENT
68
+ raise Error, "Skill capture source must be a persisted Kward session"
69
+ end
70
+
71
+ def ensure_source_fits!(source_json)
72
+ context_window = @client.current_context_window
73
+ return unless context_window
74
+
75
+ available_tokens = context_window.to_i - OUTPUT_TOKEN_RESERVE - PROMPT_TOKEN_RESERVE
76
+ source_tokens = @token_estimator.estimate_tokens(source_json)
77
+ return if source_tokens <= available_tokens
78
+
79
+ raise SourceTooLargeError,
80
+ "Selected session needs about #{source_tokens} tokens, but only #{[available_tokens, 0].max} are available for capture"
81
+ end
82
+
83
+ def draft_messages(source_json)
84
+ [
85
+ {
86
+ role: "system",
87
+ content: <<~INSTRUCTIONS
88
+ Create one reusable Kward Agent Skill from the saved session supplied by the user.
89
+ Return only a complete SKILL.md document. Include YAML frontmatter with a lowercase
90
+ hyphenated `name` and a concise `description`, followed by practical reusable
91
+ instructions. Derive guidance from demonstrated workflow, commands, and conventions;
92
+ omit credentials, private values, and project-specific incidental details. Do not
93
+ claim the skill grants permissions or tool access.
94
+ INSTRUCTIONS
95
+ },
96
+ {
97
+ role: "user",
98
+ content: "Saved session active branch (complete persisted records):\n\n#{source_json}"
99
+ }
100
+ ]
101
+ end
102
+
103
+ def draft(source_path: nil, content:)
104
+ metadata, body = parse_skill(content)
105
+ name = metadata.fetch("name", "").to_s.strip
106
+ description = metadata.fetch("description", "").to_s.strip
107
+ raise InvalidDraftError, "Skill frontmatter must include a name" if name.empty?
108
+ raise InvalidDraftError, "Skill name must use lowercase letters, digits, and hyphens" unless NAME_PATTERN.match?(name)
109
+ raise InvalidDraftError, "Skill frontmatter must include a description" if description.empty?
110
+ raise InvalidDraftError, "Skill description exceeds 1024 characters" if description.length > 1024
111
+ raise InvalidDraftError, "Skill instructions must not be empty" if body.strip.empty?
112
+
113
+ Draft.new(content: normalize_content(content), source_path: source_path, name: name, description: description)
114
+ end
115
+
116
+ def parse_skill(content)
117
+ match = content.to_s.match(/\A---\r?\n(.*?)\r?\n---\r?\n(.*)\z/m)
118
+ raise InvalidDraftError, "Skill must begin with YAML frontmatter" unless match
119
+
120
+ metadata = YAML.safe_load(match[1], permitted_classes: [], aliases: false)
121
+ raise InvalidDraftError, "Skill frontmatter must be a mapping" unless metadata.is_a?(Hash)
122
+
123
+ [metadata.transform_keys(&:to_s), match[2]]
124
+ rescue Psych::SyntaxError => error
125
+ raise InvalidDraftError, "Invalid skill frontmatter: #{error.message}"
126
+ end
127
+
128
+ def normalize_content(content)
129
+ "#{content.to_s.rstrip}\n"
130
+ end
131
+
132
+ def write_atomically(path, content)
133
+ Tempfile.create(["SKILL", ".tmp"], File.dirname(path)) do |file|
134
+ file.write(content)
135
+ file.flush
136
+ file.fsync
137
+ File.chmod(0o600, file.path)
138
+ File.rename(file.path, path)
139
+ end
140
+ File.chmod(0o600, path)
141
+ end
142
+ end
143
+ end
144
+ end
@@ -6,6 +6,7 @@ require "tmpdir"
6
6
  require "uri"
7
7
  require "zlib"
8
8
  require_relative "config_files"
9
+ require_relative "http"
9
10
 
10
11
  # Namespace for the Kward CLI agent runtime.
11
12
  module Kward
@@ -56,8 +57,9 @@ module Kward
56
57
 
57
58
  def download(url)
58
59
  uri = URI(url)
60
+ request = Http.apply_user_agent(Net::HTTP::Get.new(uri))
59
61
  response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 30) do |http|
60
- http.get(uri.request_uri)
62
+ http.request(request)
61
63
  end
62
64
  raise "Starter pack download failed with HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
63
65