kward 0.82.0 → 0.84.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -15
  3. data/CONTRIBUTING.md +74 -0
  4. data/Gemfile.lock +8 -2
  5. data/README.md +21 -1
  6. data/Rakefile +46 -2
  7. data/SECURITY.md +31 -0
  8. data/doc/agent-tools.md +1 -0
  9. data/doc/api.md +4 -0
  10. data/doc/composer.md +2 -2
  11. data/doc/configuration.md +82 -20
  12. data/doc/editor.md +47 -22
  13. data/doc/files.md +12 -7
  14. data/doc/getting-started.md +3 -0
  15. data/doc/pan.md +19 -15
  16. data/doc/permissions.md +1 -0
  17. data/doc/platform-support.md +48 -0
  18. data/doc/releasing.md +12 -4
  19. data/doc/rpc.md +2 -2
  20. data/doc/sandboxing.md +5 -1
  21. data/doc/security.md +4 -3
  22. data/doc/shell.md +101 -60
  23. data/doc/tabs.md +3 -0
  24. data/doc/troubleshooting.md +12 -2
  25. data/doc/usage.md +7 -6
  26. data/kward.gemspec +5 -4
  27. data/lib/kward/agent.rb +7 -3
  28. data/lib/kward/ansi.rb +110 -10
  29. data/lib/kward/auth/anthropic_oauth.rb +7 -7
  30. data/lib/kward/cli/auth_commands.rb +34 -13
  31. data/lib/kward/cli/commands.rb +83 -62
  32. data/lib/kward/cli/doctor.rb +39 -17
  33. data/lib/kward/cli/hook_commands.rb +22 -12
  34. data/lib/kward/cli/interactive_turn.rb +144 -21
  35. data/lib/kward/cli/project_skills_commands.rb +8 -4
  36. data/lib/kward/cli/prompt_interface.rb +27 -0
  37. data/lib/kward/cli/rendering.rb +15 -9
  38. data/lib/kward/cli/runtime_helpers.rb +260 -55
  39. data/lib/kward/cli/slash_commands.rb +21 -12
  40. data/lib/kward/cli/tabs.rb +91 -13
  41. data/lib/kward/cli/tool_summaries.rb +14 -0
  42. data/lib/kward/cli.rb +55 -7
  43. data/lib/kward/cli_transcript_formatter.rb +11 -4
  44. data/lib/kward/compaction/token_estimator.rb +12 -6
  45. data/lib/kward/config_files.rb +94 -63
  46. data/lib/kward/detached_run.rb +44 -0
  47. data/lib/kward/editor_prompt.rb +46 -0
  48. data/lib/kward/editor_prompt_session.rb +28 -0
  49. data/lib/kward/interactive_pty_runner.rb +102 -28
  50. data/lib/kward/{ekwsh.rb → kwsh.rb} +103 -24
  51. data/lib/kward/kwshrc.rb +233 -0
  52. data/lib/kward/markdown_code_block.rb +136 -0
  53. data/lib/kward/model/client.rb +35 -39
  54. data/lib/kward/model/model_info.rb +3 -2
  55. data/lib/kward/model/payloads.rb +0 -2
  56. data/lib/kward/model/provider_catalog.rb +5 -0
  57. data/lib/kward/model/stream_parser.rb +20 -4
  58. data/lib/kward/pan/index.html.erb +3 -3
  59. data/lib/kward/pan/server.rb +23 -3
  60. data/lib/kward/persistent_shell_session.rb +843 -0
  61. data/lib/kward/project_files.rb +19 -6
  62. data/lib/kward/prompt_interface/composer_renderer.rb +45 -41
  63. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  64. data/lib/kward/prompt_interface/editor/auto_indent.rb +27 -22
  65. data/lib/kward/prompt_interface/editor/controller.rb +99 -33
  66. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  67. data/lib/kward/prompt_interface/editor/modes/modern.rb +4 -0
  68. data/lib/kward/prompt_interface/editor/modes/vibe.rb +298 -47
  69. data/lib/kward/prompt_interface/editor/renderer.rb +109 -6
  70. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  71. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  72. data/lib/kward/prompt_interface/editor/state.rb +21 -10
  73. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  74. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  75. data/lib/kward/prompt_interface/editor/word_completion.rb +124 -0
  76. data/lib/kward/prompt_interface/file_overlay.rb +92 -22
  77. data/lib/kward/prompt_interface/key_handler.rb +75 -0
  78. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  79. data/lib/kward/prompt_interface/project_browser.rb +443 -17
  80. data/lib/kward/prompt_interface/runtime_state.rb +55 -2
  81. data/lib/kward/prompt_interface/screen.rb +2 -2
  82. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  83. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  84. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  85. data/lib/kward/prompt_interface.rb +169 -18
  86. data/lib/kward/prompts/commands.rb +2 -1
  87. data/lib/kward/prompts.rb +1 -1
  88. data/lib/kward/pty_output_sink.rb +47 -0
  89. data/lib/kward/rpc/auth_manager.rb +1 -1
  90. data/lib/kward/rpc/server.rb +2 -1
  91. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  92. data/lib/kward/scratchpad_languages.rb +74 -0
  93. data/lib/kward/scratchpad_runner.rb +155 -29
  94. data/lib/kward/shell_prompt.rb +52 -0
  95. data/lib/kward/shell_prompt_session.rb +58 -0
  96. data/lib/kward/terminal_keys.rb +13 -0
  97. data/lib/kward/terminal_text.rb +121 -0
  98. data/lib/kward/text_matcher.rb +18 -0
  99. data/lib/kward/tools/open_editor.rb +41 -0
  100. data/lib/kward/tools/prepare_shell_command.rb +28 -0
  101. data/lib/kward/tools/registry.rb +75 -8
  102. data/lib/kward/tools/replace_editor_buffer.rb +30 -0
  103. data/lib/kward/tools/run_shell_command.rb +24 -6
  104. data/lib/kward/tools/tool_call.rb +2 -1
  105. data/lib/kward/version.rb +1 -1
  106. data/templates/default/fulldoc/html/css/kward.css +0 -125
  107. data/templates/default/fulldoc/html/images/kward_workflow.svg +52 -0
  108. data/templates/default/fulldoc/html/setup.rb +1 -1
  109. data/templates/default/kward_navigation.rb +1 -0
  110. data/templates/default/layout/html/footer.erb +10 -0
  111. data/templates/default/layout/html/headers.erb +23 -0
  112. data/templates/default/layout/html/layout.erb +6 -18
  113. data/templates/default/layout/html/setup.rb +41 -2
  114. metadata +44 -8
  115. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
@@ -0,0 +1,46 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Builds the model-facing request for an in-memory editor prompt.
4
+ module EditorPrompt
5
+ module_function
6
+
7
+ def system_message
8
+ {
9
+ role: "system",
10
+ content: <<~PROMPT.strip
11
+ You are Kward's in-memory editor assistant.
12
+
13
+ Act only on the user's explicit request about the active editor buffer. Do not volunteer changes, explanations, or unrelated work. For an actionable edit request, call replace_editor_buffer with the complete replacement buffer. For a request that is only a question or needs clarification, answer briefly without changing the buffer. Never write files to disk and never claim that the buffer was saved.
14
+ PROMPT
15
+ }
16
+ end
17
+
18
+ def input(instruction, context)
19
+ document = context[:display_path].to_s
20
+ language = context[:language].to_s
21
+ language = "text" if language.empty?
22
+ content = context[:content].to_s
23
+
24
+ <<~PROMPT
25
+ You are operating inside an interactive editor.
26
+
27
+ The user requested:
28
+ #{instruction}
29
+
30
+ Active document: #{document.empty? ? "(untitled)" : document}
31
+ Language: #{language}
32
+ Cursor: line #{context.dig(:cursor, 0).to_i + 1}, column #{context.dig(:cursor, 1).to_i + 1}
33
+ Selected text: #{context[:selection].to_s.empty? ? "(none)" : "present; full-buffer replacement is required"}
34
+
35
+ Current buffer:
36
+ <editor_buffer>
37
+ #{content}
38
+ </editor_buffer>
39
+
40
+ Satisfy the user's request by calling replace_editor_buffer with the complete desired buffer contents.
41
+ The operation changes only the in-memory editor buffer. Do not write files to disk.
42
+ If the request is ambiguous, explain the ambiguity instead of changing the buffer.
43
+ PROMPT
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,28 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Transactional draft used while an agent works on an active editor buffer.
4
+ #
5
+ # The live terminal editor remains owned by PromptInterface. Agent tool calls
6
+ # update this draft and the caller commits it only after a successful turn.
7
+ class EditorPromptSession
8
+ attr_reader :context, :content
9
+
10
+ def initialize(context)
11
+ @context = context.dup
12
+ @initial_content = @context.fetch(:content).to_s.dup
13
+ @content = @initial_content.dup
14
+ end
15
+
16
+ def replace(content)
17
+ @content = content.to_s
18
+ end
19
+
20
+ def changed?
21
+ @content != @initial_content
22
+ end
23
+
24
+ def initial_content
25
+ @initial_content.dup
26
+ end
27
+ end
28
+ end
@@ -3,6 +3,7 @@ require "pty"
3
3
  require "timeout"
4
4
  require_relative "local_pty_command_runner"
5
5
  require_relative "pty_output_sink"
6
+ require_relative "detached_run"
6
7
 
7
8
  # Namespace for the Kward CLI agent runtime.
8
9
  module Kward
@@ -10,7 +11,7 @@ module Kward
10
11
  # This is intentionally low level: UI orchestration decides when terminal
11
12
  # ownership is handed to the child process and how the result is presented.
12
13
  class InteractivePtyRunner
13
- Result = Struct.new(:exit_status, :input_forwarded, keyword_init: true)
14
+ Result = Struct.new(:exit_status, :input_forwarded, :tab_action, :background, keyword_init: true)
14
15
 
15
16
  READ_SIZE = 4096
16
17
 
@@ -19,28 +20,43 @@ module Kward
19
20
  @window_size = nil
20
21
  end
21
22
 
22
- def run(*command, env: {}, cwd: Dir.pwd, input: $stdin, sink:)
23
- pid = nil
23
+ def run(*command, env: {}, cwd: Dir.pwd, input: $stdin, sink:, tab_action_handler: nil, on_detach: nil)
24
+ reader, writer, pid = PTY.spawn(env.to_h, *command, chdir: cwd.to_s)
24
25
  status = nil
25
- writer = nil
26
26
  input_forwarded = false
27
-
28
- PTY.spawn(env.to_h, *command, chdir: cwd.to_s) do |reader, child_writer, child_pid|
29
- pid = child_pid
30
- writer = child_writer
31
- update_window_size(reader, pid)
32
- with_raw_input(input) do
33
- input_forwarded = drain_initial_input(input, writer, sink)
34
- status, forwarded = forward_io(reader, writer, pid, input, sink)
27
+ tab_action = nil
28
+ forwarded = false
29
+ detached = nil
30
+
31
+ update_window_size(reader, pid)
32
+ with_raw_input(input) do
33
+ input_forwarded, tab_action = drain_initial_input(input, writer, sink, tab_action_handler: tab_action_handler)
34
+ if tab_action && on_detach
35
+ detached_sink = on_detach.call
36
+ sink.finish if sink.respond_to?(:finish)
37
+ detached = detach_process(reader, writer, pid, detached_sink, input_forwarded)
38
+ reader = writer = pid = nil
39
+ elsif tab_action
40
+ status = terminate_and_reap(pid)
41
+ else
42
+ status, forwarded, tab_action = forward_io(reader, writer, pid, input, sink, tab_action_handler: tab_action_handler, on_detach: on_detach)
35
43
  input_forwarded ||= forwarded
44
+ if tab_action && on_detach
45
+ detached_sink = on_detach.call
46
+ sink.finish if sink.respond_to?(:finish)
47
+ detached = detach_process(reader, writer, pid, detached_sink, input_forwarded)
48
+ reader = writer = pid = nil
49
+ end
36
50
  end
37
- sink.finish if sink.respond_to?(:finish)
38
- status ||= wait_for_status(pid)
51
+ input_forwarded ||= forwarded
39
52
  end
53
+ sink.finish if sink.respond_to?(:finish) && !detached
54
+ status ||= wait_for_status(pid) if pid
40
55
 
41
- Result.new(exit_status: exit_status(status), input_forwarded: input_forwarded)
56
+ Result.new(exit_status: exit_status(status), input_forwarded: input_forwarded, tab_action: tab_action, background: detached)
42
57
  ensure
43
58
  writer&.close unless writer&.closed?
59
+ reader&.close unless reader&.closed?
44
60
  terminate_and_reap(pid) if pid && status.nil?
45
61
  end
46
62
 
@@ -60,23 +76,39 @@ module Kward
60
76
  yield
61
77
  end
62
78
 
63
- def drain_initial_input(input, writer, sink)
79
+ def drain_initial_input(input, writer, sink, tab_action_handler: nil)
64
80
  forwarded = false
65
81
  loop do
66
82
  chunk = input.read_nonblock(READ_SIZE, exception: false)
67
83
  break if chunk.nil? || chunk == :wait_readable
68
84
 
85
+ filtered = tab_action_handler&.call(chunk)
86
+ if filtered
87
+ tab_action = filtered[:tab_action]
88
+ chunk = filtered[:input].to_s
89
+ if tab_action
90
+ unless chunk.empty?
91
+ sink.input_forwarded if sink.respond_to?(:input_forwarded)
92
+ writer.write(chunk)
93
+ writer.flush
94
+ forwarded = true
95
+ end
96
+ return [forwarded, tab_action]
97
+ end
98
+ end
99
+ next if chunk.empty?
100
+
69
101
  sink.input_forwarded if sink.respond_to?(:input_forwarded)
70
102
  writer.write(chunk)
71
103
  forwarded = true
72
104
  end
73
105
  writer.flush
74
- forwarded
106
+ [forwarded, nil]
75
107
  rescue Errno::EIO, Errno::EPIPE, IOError
76
- forwarded
108
+ [forwarded, nil]
77
109
  end
78
110
 
79
- def forward_io(reader, writer, pid, input, sink)
111
+ def forward_io(reader, writer, pid, input, sink, tab_action_handler: nil, on_detach: nil)
80
112
  input_open = true
81
113
  input_forwarded = false
82
114
  loop do
@@ -86,19 +118,45 @@ module Kward
86
118
  readable = IO.select(readers, nil, nil, 0.02)&.first || []
87
119
  forward_pty_output(reader, sink) if readable.include?(reader)
88
120
  if input_open && readable.include?(input)
89
- input_open, forwarded = forward_input(input, writer, sink)
121
+ input_open, forwarded, tab_action = forward_input(input, writer, sink, tab_action_handler: tab_action_handler)
90
122
  input_forwarded ||= forwarded
123
+ return [nil, input_forwarded, tab_action] if tab_action && on_detach
124
+ return [terminate_and_reap(pid), input_forwarded, tab_action] if tab_action
91
125
  end
92
126
 
93
127
  status = finished_status(pid)
94
128
  next unless status
95
129
 
96
- return [finish_stopped_process(pid), input_forwarded] if status.stopped?
130
+ return [finish_stopped_process(pid), input_forwarded, nil] if status.stopped?
97
131
 
98
132
  drain_pty_output(reader, sink)
99
- return [status, input_forwarded]
133
+ return [status, input_forwarded, nil]
100
134
  rescue Errno::EIO
101
- return [wait_for_status(pid), input_forwarded]
135
+ return [wait_for_status(pid), input_forwarded, nil]
136
+ end
137
+ end
138
+
139
+ def detach_process(reader, writer, pid, sink, input_forwarded)
140
+ DetachedRun.new(sink: sink, canceler: -> { terminate_process_group(pid) }) do
141
+ status = nil
142
+ begin
143
+ loop do
144
+ readable, = IO.select([reader], nil, nil, 0.02)&.first
145
+ forward_pty_output(reader, sink) if readable
146
+ status = finished_status(pid)
147
+ next unless status
148
+
149
+ drain_pty_output(reader, sink)
150
+ break
151
+ end
152
+ rescue Errno::EIO
153
+ status = wait_for_status(pid)
154
+ ensure
155
+ sink.finish if sink.respond_to?(:finish)
156
+ writer.close unless writer.closed?
157
+ reader.close unless reader.closed?
158
+ end
159
+ Result.new(exit_status: exit_status(status), input_forwarded: input_forwarded)
102
160
  end
103
161
  end
104
162
 
@@ -125,17 +183,33 @@ module Kward
125
183
  nil
126
184
  end
127
185
 
128
- def forward_input(input, writer, sink)
186
+ def forward_input(input, writer, sink, tab_action_handler: nil)
129
187
  chunk = input.read_nonblock(READ_SIZE, exception: false)
130
- return [false, false] if chunk.nil?
131
- return [true, false] if chunk == :wait_readable
188
+ return [false, false, nil] if chunk.nil?
189
+ return [true, false, nil] if chunk == :wait_readable
190
+
191
+ filtered = tab_action_handler&.call(chunk)
192
+ if filtered
193
+ tab_action = filtered[:tab_action]
194
+ chunk = filtered[:input].to_s
195
+ if tab_action
196
+ unless chunk.empty?
197
+ sink.input_forwarded if sink.respond_to?(:input_forwarded)
198
+ writer.write(chunk)
199
+ writer.flush
200
+ return [true, true, tab_action]
201
+ end
202
+ return [true, false, tab_action]
203
+ end
204
+ end
205
+ return [true, false, nil] if chunk.empty?
132
206
 
133
207
  sink.input_forwarded if sink.respond_to?(:input_forwarded)
134
208
  writer.write(chunk)
135
209
  writer.flush
136
- [true, true]
210
+ [true, true, nil]
137
211
  rescue Errno::EIO, Errno::EPIPE, IOError
138
- [false, false]
212
+ [false, false, nil]
139
213
  end
140
214
 
141
215
  def update_window_size(reader, pid)
@@ -10,25 +10,24 @@ end
10
10
  # Namespace for the Kward CLI agent runtime.
11
11
  module Kward
12
12
  # Kward-native embedded shell command runner.
13
- class Ekwsh
13
+ class Kwsh
14
14
  Result = Struct.new(:output, :exit_status, :exit_shell, :clear, :open_editor_path, :interactive_command, :streamed, keyword_init: true)
15
15
  Completion = Struct.new(:range, :replacement, :candidates, keyword_init: true)
16
- BUILTINS = %w[alias capture cd pwd export unset unalias clear exit logout pty].freeze
16
+ BUILTINS = %w[alias capture cd pwd export source unset unalias clear exit logout pty].freeze
17
17
  DEFAULT_SHELL = "/bin/sh"
18
18
  DEFAULT_TIMEOUT_SECONDS = 300
19
19
  DEFAULT_MAX_OUTPUT_BYTES = 1_048_576
20
20
  DEFAULT_HISTORY_LIMIT = 1_000
21
+ CONTEXT_OUTPUT_BYTES = 32_000
21
22
 
22
- attr_reader :cwd
23
+ attr_reader :cwd, :last_command, :timeout_seconds
23
24
 
24
25
  def command_shell
25
26
  @shell
26
27
  end
27
28
 
28
29
  def child_env(interactive: false)
29
- env = @env.dup
30
- env.delete("GIT_PAGER") if interactive && @defaulted_git_pager
31
- env
30
+ @env.dup
32
31
  end
33
32
 
34
33
  def initialize(cwd: Dir.pwd, env: ENV.to_h, shell: DEFAULT_SHELL, configured_env: {}, aliases: {}, timeout_seconds: DEFAULT_TIMEOUT_SECONDS, max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES)
@@ -43,6 +42,8 @@ module Kward
43
42
  @shell = shell.to_s.empty? ? DEFAULT_SHELL : shell.to_s
44
43
  @timeout_seconds = timeout_seconds.to_i.positive? ? timeout_seconds.to_i : DEFAULT_TIMEOUT_SECONDS
45
44
  @max_output_bytes = max_output_bytes.to_i.positive? ? max_output_bytes.to_i : DEFAULT_MAX_OUTPUT_BYTES
45
+ @last_command = nil
46
+ @last_result = nil
46
47
  end
47
48
 
48
49
  def prompt_label
@@ -53,7 +54,45 @@ module Kward
53
54
  command = input.to_s.strip
54
55
  return Result.new(output: "", exit_status: 0) if command.empty?
55
56
 
56
- run_expanded_command(command, cancellation: cancellation, &block)
57
+ result = run_expanded_command(command, cancellation: cancellation, &block)
58
+ remember_result(command, result)
59
+ result
60
+ end
61
+
62
+ # Runs a command for the shell assistant without handing it the terminal.
63
+ # Built-ins still execute in this Kwsh instance so their state persists.
64
+ def run_for_agent(input, timeout_seconds: nil, cancellation: nil)
65
+ command = input.to_s.strip
66
+ return run(command, cancellation: cancellation) if command.empty?
67
+
68
+ result = run(command, cancellation: cancellation)
69
+ return result unless result.interactive_command
70
+
71
+ result = execute(
72
+ result.interactive_command,
73
+ display_command: command,
74
+ timeout_seconds: timeout_seconds,
75
+ cancellation: cancellation
76
+ )
77
+ remember_result(command, result)
78
+ result
79
+ end
80
+
81
+ # Records the output of an interactive command after terminal handoff.
82
+ def record_interactive_result(output:, exit_status:)
83
+ return unless @last_command
84
+
85
+ @last_result = Result.new(output: ANSI.sanitize_transcript(output.to_s), exit_status: exit_status)
86
+ end
87
+
88
+ # Returns bounded shell state for an explicit shell-agent prompt.
89
+ def context_snapshot(max_output_bytes: CONTEXT_OUTPUT_BYTES)
90
+ {
91
+ cwd: @cwd,
92
+ last_command: @last_command,
93
+ exit_status: @last_result&.exit_status,
94
+ last_output: context_output(@last_result&.output, max_output_bytes)
95
+ }
57
96
  end
58
97
 
59
98
  def complete(input, cursor)
@@ -112,8 +151,6 @@ module Kward
112
151
  def configure_color_environment
113
152
  @env["CLICOLOR"] ||= "1"
114
153
  @env["COLORTERM"] ||= "truecolor"
115
- @defaulted_git_pager = !@env.key?("GIT_PAGER")
116
- @env["GIT_PAGER"] ||= "cat"
117
154
  @env["TERM"] = "xterm-256color" if @env["TERM"].to_s.empty? || @env["TERM"] == "dumb"
118
155
  end
119
156
 
@@ -285,12 +322,12 @@ module Kward
285
322
  return nil unless %w[exit logout].include?(words.first)
286
323
 
287
324
  if words.length > 2 || (words[1] && !words[1].match?(/\A\d+\z/))
288
- return Result.new(output: "#{command_echo(display_command)}ekwsh: #{words.first}: numeric status expected\n", exit_status: 2)
325
+ return Result.new(output: "#{command_echo(display_command)}kwsh: #{words.first}: numeric status expected\n", exit_status: 2)
289
326
  end
290
327
 
291
328
  Result.new(output: command_echo(display_command), exit_status: words[1].to_i, exit_shell: true)
292
329
  rescue ArgumentError => e
293
- Result.new(output: "#{command_echo(display_command)}ekwsh: #{e.message}\n", exit_status: 2)
330
+ Result.new(output: "#{command_echo(display_command)}kwsh: #{e.message}\n", exit_status: 2)
294
331
  end
295
332
 
296
333
  def builtin_result(command, display_command: command, cancellation: nil, &block)
@@ -310,6 +347,8 @@ module Kward
310
347
  print_working_directory(display_command, words)
311
348
  when "export"
312
349
  export_variables(display_command, words)
350
+ when "source", "."
351
+ source_file(display_command, words)
313
352
  when "unset"
314
353
  unset_variables(display_command, words)
315
354
  when "clear"
@@ -322,7 +361,7 @@ module Kward
322
361
  nil
323
362
  end
324
363
  rescue ArgumentError => e
325
- Result.new(output: "#{command_echo(display_command)}ekwsh: #{e.message}\n", exit_status: 2)
364
+ Result.new(output: "#{command_echo(display_command)}kwsh: #{e.message}\n", exit_status: 2)
326
365
  end
327
366
 
328
367
  def captured_command_result(command, display_command:, cancellation: nil, &block)
@@ -362,7 +401,7 @@ module Kward
362
401
  invalid << name
363
402
  end
364
403
  end
365
- return Result.new(output: "#{command_echo(command)}ekwsh: alias: invalid name: #{invalid.join(" ")}\n", exit_status: 2) unless invalid.empty?
404
+ return Result.new(output: "#{command_echo(command)}kwsh: alias: invalid name: #{invalid.join(" ")}\n", exit_status: 2) unless invalid.empty?
366
405
 
367
406
  names = @aliases.keys.sort if names.empty? && assignments.empty?
368
407
  lines = names.filter_map { |name| @aliases[name] ? "alias #{name}=#{Shellwords.escape(@aliases.fetch(name))}" : nil }
@@ -380,7 +419,7 @@ module Kward
380
419
  end
381
420
 
382
421
  missing = words.drop(1).reject { |name| @aliases.delete(name) }
383
- return Result.new(output: "#{command_echo(command)}ekwsh: unalias: not found: #{missing.join(" ")}\n", exit_status: 1) unless missing.empty?
422
+ return Result.new(output: "#{command_echo(command)}kwsh: unalias: not found: #{missing.join(" ")}\n", exit_status: 1) unless missing.empty?
384
423
 
385
424
  Result.new(output: command_echo(command), exit_status: 0)
386
425
  end
@@ -418,7 +457,7 @@ module Kward
418
457
  path = File.expand_path(words[2], @cwd)
419
458
  Result.new(output: command_echo(display_command), exit_status: 0, open_editor_path: path)
420
459
  rescue ArgumentError => e
421
- Result.new(output: "#{command_echo(display_command)}ekwsh: #{e.message}\n", exit_status: 2)
460
+ Result.new(output: "#{command_echo(display_command)}kwsh: #{e.message}\n", exit_status: 2)
422
461
  end
423
462
 
424
463
  def kward_edit_command?(words)
@@ -452,7 +491,7 @@ module Kward
452
491
 
453
492
  def change_directory(command, words)
454
493
  if words.length > 2
455
- return Result.new(output: "#{command_echo(command)}ekwsh: cd: too many arguments\n", exit_status: 2)
494
+ return Result.new(output: "#{command_echo(command)}kwsh: cd: too many arguments\n", exit_status: 2)
456
495
  end
457
496
 
458
497
  target = words[1]
@@ -460,7 +499,7 @@ module Kward
460
499
  target = @previous_cwd || @cwd if target == "-"
461
500
  path = File.expand_path(target, @cwd)
462
501
  unless File.directory?(path)
463
- return Result.new(output: "#{command_echo(command)}ekwsh: cd: no such directory: #{target}\n", exit_status: 1)
502
+ return Result.new(output: "#{command_echo(command)}kwsh: cd: no such directory: #{target}\n", exit_status: 1)
464
503
  end
465
504
 
466
505
  @previous_cwd = @cwd
@@ -491,8 +530,25 @@ module Kward
491
530
  if invalid.empty?
492
531
  Result.new(output: command_echo(command), exit_status: 0)
493
532
  else
494
- Result.new(output: "#{command_echo(command)}ekwsh: export: invalid assignment: #{invalid.join(" ")}\n", exit_status: 2)
533
+ Result.new(output: "#{command_echo(command)}kwsh: export: invalid assignment: #{invalid.join(" ")}\n", exit_status: 2)
534
+ end
535
+ end
536
+
537
+ def source_file(command, words)
538
+ unless words.length == 2
539
+ return Result.new(output: "#{command_echo(command)}Usage: source <file>\n", exit_status: 2)
495
540
  end
541
+
542
+ require_relative "kwshrc"
543
+ path = Kwshrc.resolve_path(words[1], cwd: @cwd, env: @env)
544
+ config = Kwshrc.read_file(path, env: @env)
545
+ path_changed = config[:env].key?("PATH")
546
+ @env.merge!(config[:env])
547
+ @aliases.merge!(config[:aliases])
548
+ invalidate_path_executables_cache if path_changed
549
+ Result.new(output: command_echo(command), exit_status: 0)
550
+ rescue Kwshrc::ParseError => e
551
+ Result.new(output: "#{command_echo(command)}kwsh: source: #{e.message}\n", exit_status: 1)
496
552
  end
497
553
 
498
554
  def unset_variables(command, words)
@@ -504,7 +560,7 @@ module Kward
504
560
  if invalid.empty?
505
561
  Result.new(output: command_echo(command), exit_status: 0)
506
562
  else
507
- Result.new(output: "#{command_echo(command)}ekwsh: unset: invalid name: #{invalid.join(" ")}\n", exit_status: 2)
563
+ Result.new(output: "#{command_echo(command)}kwsh: unset: invalid name: #{invalid.join(" ")}\n", exit_status: 2)
508
564
  end
509
565
  end
510
566
 
@@ -522,12 +578,13 @@ module Kward
522
578
  key.to_s.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
523
579
  end
524
580
 
525
- def execute(command, display_command: command, cancellation: nil)
581
+ def execute(command, display_command: command, timeout_seconds: nil, cancellation: nil)
526
582
  output = command_echo(display_command)
527
583
  streamed = block_given?
528
584
  yield output.dup if streamed
585
+ timeout = effective_timeout(timeout_seconds)
529
586
  result = external_command_runner.new(
530
- timeout_seconds: @timeout_seconds,
587
+ timeout_seconds: timeout,
531
588
  max_output_bytes: @max_output_bytes,
532
589
  terminate_on_output_limit: true
533
590
  ).run(@shell, "-c", command, env: @env, cwd: @cwd, cancellation: cancellation) do |_stream, chunk|
@@ -537,8 +594,8 @@ module Kward
537
594
  end
538
595
  append_output_newline(output) { |text| yield text if streamed }
539
596
  exit_status = result.timed_out || result.truncated ? 1 : (result.exit_status || 1)
540
- append_streamed(output, "ekwsh: command timed out after #{@timeout_seconds} seconds\n", streamed) { |text| yield text } if result.timed_out
541
- append_streamed(output, "ekwsh: output exceeded #{@max_output_bytes} bytes; command terminated\n", streamed) { |text| yield text } if result.truncated
597
+ append_streamed(output, "kwsh: command timed out after #{timeout} seconds\n", streamed) { |text| yield text } if result.timed_out
598
+ append_streamed(output, "kwsh: output exceeded #{@max_output_bytes} bytes; command terminated\n", streamed) { |text| yield text } if result.truncated
542
599
  append_streamed(output, "Exit status: #{exit_status}\n", streamed) { |text| yield text } unless exit_status.zero?
543
600
  Result.new(output: output, exit_status: exit_status, streamed: streamed)
544
601
  rescue Cancellation::CancelledError
@@ -546,7 +603,29 @@ module Kward
546
603
  append_streamed(output, "^C\nExit status: 130\n", streamed) { |text| yield text }
547
604
  Result.new(output: output, exit_status: 130, streamed: streamed)
548
605
  rescue Errno::ENOENT => e
549
- Result.new(output: "#{command_echo(display_command)}ekwsh: #{e.message}\n", exit_status: 127)
606
+ Result.new(output: "#{command_echo(display_command)}kwsh: #{e.message}\n", exit_status: 127)
607
+ end
608
+
609
+ def effective_timeout(timeout_seconds)
610
+ timeout = timeout_seconds.to_i
611
+ timeout.positive? ? timeout : @timeout_seconds
612
+ end
613
+
614
+ def remember_result(command, result)
615
+ @last_command = command.to_s
616
+ @last_result = result
617
+ end
618
+
619
+ def context_output(output, max_output_bytes)
620
+ text = ANSI.sanitize_transcript(output.to_s)
621
+ limit = max_output_bytes.to_i
622
+ return text if limit <= 0 || text.bytesize <= limit
623
+
624
+ prefix = "[Earlier output omitted; showing the end of the command output.]\n"
625
+ tail_limit = [limit - prefix.bytesize, 0].max
626
+ tail = text.byteslice(-tail_limit, tail_limit).to_s if tail_limit.positive?
627
+ value = "#{prefix}#{tail}"
628
+ ANSI.normalize_transcript_encoding(value.byteslice(-limit, limit).to_s)
550
629
  end
551
630
 
552
631
  def external_command_runner