kward 0.83.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -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/api.md +4 -0
  9. data/doc/composer.md +1 -1
  10. data/doc/configuration.md +68 -21
  11. data/doc/editor.md +28 -13
  12. data/doc/files.md +8 -4
  13. data/doc/getting-started.md +3 -0
  14. data/doc/pan.md +19 -15
  15. data/doc/platform-support.md +48 -0
  16. data/doc/security.md +3 -2
  17. data/doc/shell.md +62 -45
  18. data/doc/troubleshooting.md +12 -2
  19. data/doc/usage.md +5 -5
  20. data/kward.gemspec +5 -4
  21. data/lib/kward/agent.rb +6 -3
  22. data/lib/kward/ansi.rb +110 -10
  23. data/lib/kward/cli/auth_commands.rb +34 -13
  24. data/lib/kward/cli/commands.rb +83 -62
  25. data/lib/kward/cli/doctor.rb +39 -17
  26. data/lib/kward/cli/hook_commands.rb +22 -12
  27. data/lib/kward/cli/interactive_turn.rb +48 -7
  28. data/lib/kward/cli/project_skills_commands.rb +8 -4
  29. data/lib/kward/cli/prompt_interface.rb +27 -0
  30. data/lib/kward/cli/rendering.rb +15 -9
  31. data/lib/kward/cli/runtime_helpers.rb +118 -55
  32. data/lib/kward/cli/slash_commands.rb +12 -14
  33. data/lib/kward/cli/tabs.rb +83 -12
  34. data/lib/kward/cli/tool_summaries.rb +14 -0
  35. data/lib/kward/cli.rb +45 -7
  36. data/lib/kward/cli_transcript_formatter.rb +11 -4
  37. data/lib/kward/config_files.rb +82 -68
  38. data/lib/kward/detached_run.rb +44 -0
  39. data/lib/kward/interactive_pty_runner.rb +102 -28
  40. data/lib/kward/{ekwsh.rb → kwsh.rb} +35 -16
  41. data/lib/kward/kwshrc.rb +233 -0
  42. data/lib/kward/markdown_code_block.rb +136 -0
  43. data/lib/kward/model/client.rb +34 -22
  44. data/lib/kward/model/provider_catalog.rb +5 -0
  45. data/lib/kward/model/stream_parser.rb +20 -4
  46. data/lib/kward/pan/index.html.erb +3 -3
  47. data/lib/kward/pan/server.rb +23 -3
  48. data/lib/kward/persistent_shell_session.rb +119 -26
  49. data/lib/kward/project_files.rb +2 -2
  50. data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
  51. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  52. data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
  53. data/lib/kward/prompt_interface/editor/controller.rb +30 -33
  54. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  55. data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
  56. data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
  57. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  58. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  59. data/lib/kward/prompt_interface/editor/state.rb +10 -10
  60. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  61. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  62. data/lib/kward/prompt_interface/file_overlay.rb +71 -15
  63. data/lib/kward/prompt_interface/key_handler.rb +67 -0
  64. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  65. data/lib/kward/prompt_interface/project_browser.rb +415 -14
  66. data/lib/kward/prompt_interface/runtime_state.rb +50 -1
  67. data/lib/kward/prompt_interface/screen.rb +2 -2
  68. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  69. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  70. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  71. data/lib/kward/prompt_interface.rb +93 -18
  72. data/lib/kward/prompts/commands.rb +1 -1
  73. data/lib/kward/prompts.rb +1 -1
  74. data/lib/kward/pty_output_sink.rb +47 -0
  75. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  76. data/lib/kward/scratchpad_languages.rb +74 -0
  77. data/lib/kward/scratchpad_runner.rb +155 -29
  78. data/lib/kward/shell_prompt.rb +2 -0
  79. data/lib/kward/terminal_keys.rb +12 -0
  80. data/lib/kward/terminal_text.rb +121 -0
  81. data/lib/kward/text_matcher.rb +18 -0
  82. data/lib/kward/tools/open_editor.rb +41 -0
  83. data/lib/kward/tools/registry.rb +13 -4
  84. data/lib/kward/tools/tool_call.rb +2 -1
  85. data/lib/kward/version.rb +1 -1
  86. data/templates/default/fulldoc/html/css/kward.css +0 -125
  87. data/templates/default/fulldoc/html/images/kward_workflow.svg +52 -0
  88. data/templates/default/fulldoc/html/setup.rb +1 -1
  89. data/templates/default/kward_navigation.rb +1 -0
  90. data/templates/default/layout/html/footer.erb +10 -0
  91. data/templates/default/layout/html/headers.erb +23 -0
  92. data/templates/default/layout/html/layout.erb +6 -18
  93. data/templates/default/layout/html/setup.rb +41 -2
  94. metadata +36 -8
  95. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
@@ -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,10 +10,10 @@ 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
@@ -60,7 +60,7 @@ module Kward
60
60
  end
61
61
 
62
62
  # Runs a command for the shell assistant without handing it the terminal.
63
- # Built-ins still execute in this Ekwsh instance so their state persists.
63
+ # Built-ins still execute in this Kwsh instance so their state persists.
64
64
  def run_for_agent(input, timeout_seconds: nil, cancellation: nil)
65
65
  command = input.to_s.strip
66
66
  return run(command, cancellation: cancellation) if command.empty?
@@ -322,12 +322,12 @@ module Kward
322
322
  return nil unless %w[exit logout].include?(words.first)
323
323
 
324
324
  if words.length > 2 || (words[1] && !words[1].match?(/\A\d+\z/))
325
- 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)
326
326
  end
327
327
 
328
328
  Result.new(output: command_echo(display_command), exit_status: words[1].to_i, exit_shell: true)
329
329
  rescue ArgumentError => e
330
- 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)
331
331
  end
332
332
 
333
333
  def builtin_result(command, display_command: command, cancellation: nil, &block)
@@ -347,6 +347,8 @@ module Kward
347
347
  print_working_directory(display_command, words)
348
348
  when "export"
349
349
  export_variables(display_command, words)
350
+ when "source", "."
351
+ source_file(display_command, words)
350
352
  when "unset"
351
353
  unset_variables(display_command, words)
352
354
  when "clear"
@@ -359,7 +361,7 @@ module Kward
359
361
  nil
360
362
  end
361
363
  rescue ArgumentError => e
362
- 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)
363
365
  end
364
366
 
365
367
  def captured_command_result(command, display_command:, cancellation: nil, &block)
@@ -399,7 +401,7 @@ module Kward
399
401
  invalid << name
400
402
  end
401
403
  end
402
- 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?
403
405
 
404
406
  names = @aliases.keys.sort if names.empty? && assignments.empty?
405
407
  lines = names.filter_map { |name| @aliases[name] ? "alias #{name}=#{Shellwords.escape(@aliases.fetch(name))}" : nil }
@@ -417,7 +419,7 @@ module Kward
417
419
  end
418
420
 
419
421
  missing = words.drop(1).reject { |name| @aliases.delete(name) }
420
- 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?
421
423
 
422
424
  Result.new(output: command_echo(command), exit_status: 0)
423
425
  end
@@ -455,7 +457,7 @@ module Kward
455
457
  path = File.expand_path(words[2], @cwd)
456
458
  Result.new(output: command_echo(display_command), exit_status: 0, open_editor_path: path)
457
459
  rescue ArgumentError => e
458
- 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)
459
461
  end
460
462
 
461
463
  def kward_edit_command?(words)
@@ -489,7 +491,7 @@ module Kward
489
491
 
490
492
  def change_directory(command, words)
491
493
  if words.length > 2
492
- 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)
493
495
  end
494
496
 
495
497
  target = words[1]
@@ -497,7 +499,7 @@ module Kward
497
499
  target = @previous_cwd || @cwd if target == "-"
498
500
  path = File.expand_path(target, @cwd)
499
501
  unless File.directory?(path)
500
- 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)
501
503
  end
502
504
 
503
505
  @previous_cwd = @cwd
@@ -528,10 +530,27 @@ module Kward
528
530
  if invalid.empty?
529
531
  Result.new(output: command_echo(command), exit_status: 0)
530
532
  else
531
- 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)
532
534
  end
533
535
  end
534
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)
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)
552
+ end
553
+
535
554
  def unset_variables(command, words)
536
555
  names = words.drop(1)
537
556
  names.shift if names.first == "--"
@@ -541,7 +560,7 @@ module Kward
541
560
  if invalid.empty?
542
561
  Result.new(output: command_echo(command), exit_status: 0)
543
562
  else
544
- 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)
545
564
  end
546
565
  end
547
566
 
@@ -575,8 +594,8 @@ module Kward
575
594
  end
576
595
  append_output_newline(output) { |text| yield text if streamed }
577
596
  exit_status = result.timed_out || result.truncated ? 1 : (result.exit_status || 1)
578
- append_streamed(output, "ekwsh: command timed out after #{timeout} seconds\n", streamed) { |text| yield text } if result.timed_out
579
- 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
580
599
  append_streamed(output, "Exit status: #{exit_status}\n", streamed) { |text| yield text } unless exit_status.zero?
581
600
  Result.new(output: output, exit_status: exit_status, streamed: streamed)
582
601
  rescue Cancellation::CancelledError
@@ -584,7 +603,7 @@ module Kward
584
603
  append_streamed(output, "^C\nExit status: 130\n", streamed) { |text| yield text }
585
604
  Result.new(output: output, exit_status: 130, streamed: streamed)
586
605
  rescue Errno::ENOENT => e
587
- 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)
588
607
  end
589
608
 
590
609
  def effective_timeout(timeout_seconds)
@@ -0,0 +1,233 @@
1
+ require "shellwords"
2
+ require_relative "kwsh"
3
+
4
+ # Namespace for the Kward CLI agent runtime.
5
+ module Kward
6
+ # Parses the declarative subset of a shell startup file used by kwsh.
7
+ #
8
+ # Only aliases, exports, and source directives are handled. Other shell
9
+ # syntax is intentionally ignored until kwsh supports scripting.
10
+ class Kwshrc
11
+ VARIABLE_NAME = /\A[A-Za-z_][A-Za-z0-9_]*\z/
12
+ VARIABLE_PATTERN = /[A-Za-z_][A-Za-z0-9_]*/
13
+
14
+ class ParseError < StandardError
15
+ end
16
+
17
+ def self.read(paths, env: ENV.to_h)
18
+ new(env: env).read(paths)
19
+ end
20
+
21
+ def self.read_file(path, env: ENV.to_h)
22
+ parser = new(env: env)
23
+ parser.read_path(path)
24
+ end
25
+
26
+ def self.resolve_path(path, cwd:, env: ENV.to_h)
27
+ parser = new(env: env)
28
+ expanded = parser.send(:expand_variables, path.to_s)
29
+ expanded = expanded.sub(/\A~(?=\/|\z)/, Dir.home)
30
+ File.expand_path(expanded, cwd)
31
+ end
32
+
33
+ def initialize(env: ENV.to_h)
34
+ @environment = env.to_h.transform_keys(&:to_s).transform_values(&:to_s)
35
+ @exports = {}
36
+ @aliases = {}
37
+ @loading = []
38
+ end
39
+
40
+ def read(paths)
41
+ Array(paths).each { |path| read_file(path, required: false) }
42
+ configuration
43
+ end
44
+
45
+ def read_path(path)
46
+ read_file(path, required: true)
47
+ configuration
48
+ end
49
+
50
+ private
51
+
52
+ def configuration
53
+ { env: @exports, aliases: @aliases }
54
+ end
55
+
56
+ def read_file(path, required:)
57
+ path = File.expand_path(path.to_s)
58
+ unless File.file?(path)
59
+ raise ParseError, "#{path}: file not found" if required
60
+ return
61
+ end
62
+ raise ParseError, "#{path}: recursive source" if @loading.include?(path)
63
+
64
+ @loading << path
65
+ File.foreach(path).with_index(1) do |line, line_number|
66
+ parse_line(strip_comment(line).strip, path, line_number)
67
+ end
68
+ rescue Errno::EACCES, Errno::EISDIR, Errno::ENOENT => e
69
+ raise ParseError, "#{path}: #{e.message}"
70
+ ensure
71
+ @loading.delete(path)
72
+ end
73
+
74
+ def parse_line(line, path, line_number)
75
+ return if line.empty?
76
+
77
+ words = Shellwords.split(line)
78
+ return if words.empty?
79
+
80
+ case words.shift
81
+ when "alias"
82
+ parse_aliases(words)
83
+ when "export"
84
+ parse_exports(expand_variables(line), path, line_number)
85
+ when "source", "."
86
+ parse_source(expand_variables(line), path, line_number)
87
+ end
88
+ rescue ArgumentError => e
89
+ raise ParseError, "#{path}:#{line_number}: #{e.message}"
90
+ end
91
+
92
+ def parse_aliases(words)
93
+ words.each do |assignment|
94
+ name, command = assignment.split("=", 2)
95
+ next unless command && Kwsh.valid_alias_name?(name)
96
+ next if command.empty?
97
+
98
+ @aliases[name] = command
99
+ end
100
+ end
101
+
102
+ def parse_exports(line, path, line_number)
103
+ words = Shellwords.split(line)
104
+ assignments = words.drop(1)
105
+ raise ParseError, "#{path}:#{line_number}: export requires a variable" if assignments.empty?
106
+
107
+ assignments.each do |assignment|
108
+ name, value = assignment.split("=", 2)
109
+ next unless name.match?(VARIABLE_NAME)
110
+
111
+ value = @environment[name] if value.nil? && @environment.key?(name)
112
+ next if value.nil?
113
+
114
+ @environment[name] = value
115
+ @exports[name] = value
116
+ end
117
+ rescue ArgumentError => e
118
+ raise ParseError, "#{path}:#{line_number}: #{e.message}"
119
+ end
120
+
121
+ def parse_source(line, path, line_number)
122
+ words = Shellwords.split(line)
123
+ source_path = words[1]
124
+ if words.length != 2 || source_path.to_s.empty?
125
+ raise ParseError, "#{path}:#{line_number}: source requires one file"
126
+ end
127
+
128
+ source_path = File.expand_path(source_path, File.dirname(path))
129
+ read_file(source_path, required: true)
130
+ rescue ArgumentError => e
131
+ raise ParseError, "#{path}:#{line_number}: #{e.message}"
132
+ end
133
+
134
+ def strip_comment(line)
135
+ result = +""
136
+ quote = nil
137
+ escaped = false
138
+
139
+ line.each_char do |character|
140
+ if escaped
141
+ result << character
142
+ escaped = false
143
+ elsif character == "\\" && quote != "'"
144
+ result << character
145
+ escaped = true
146
+ elsif quote
147
+ result << character
148
+ quote = nil if character == quote
149
+ elsif character == "'" || character == '"'
150
+ result << character
151
+ quote = character
152
+ elsif character == "#"
153
+ break
154
+ else
155
+ result << character
156
+ end
157
+ end
158
+ result
159
+ end
160
+
161
+ def expand_variables(text)
162
+ result = +""
163
+ quote = nil
164
+ escaped = false
165
+ index = 0
166
+
167
+ while index < text.length
168
+ character = text[index]
169
+ if escaped
170
+ result << character
171
+ escaped = false
172
+ index += 1
173
+ next
174
+ end
175
+ if character == "\\" && quote != "'"
176
+ result << character
177
+ escaped = true
178
+ index += 1
179
+ next
180
+ end
181
+ if character == "'"
182
+ quote = if quote == "'"
183
+ nil
184
+ elsif quote.nil?
185
+ character
186
+ else
187
+ quote
188
+ end
189
+ result << character
190
+ index += 1
191
+ next
192
+ end
193
+ if character == '"'
194
+ quote = if quote == '"'
195
+ nil
196
+ elsif quote.nil?
197
+ character
198
+ else
199
+ quote
200
+ end
201
+ result << character
202
+ index += 1
203
+ next
204
+ end
205
+
206
+ if character == "$" && quote != "'"
207
+ name, length = variable_at(text, index)
208
+ if name
209
+ result << @environment.fetch(name, "")
210
+ index += length
211
+ next
212
+ end
213
+ end
214
+
215
+ result << character
216
+ index += 1
217
+ end
218
+ result
219
+ end
220
+
221
+ def variable_at(text, index)
222
+ if text[index + 1] == "{"
223
+ closing = text.index("}", index + 2)
224
+ name = text[(index + 2)...closing] if closing
225
+ return [name, closing - index + 1] if name&.match?(VARIABLE_NAME)
226
+ else
227
+ match = text[(index + 1)..].match(/\A#{VARIABLE_PATTERN}/)
228
+ return [match[0], match[0].length + 1] if match
229
+ end
230
+ nil
231
+ end
232
+ end
233
+ end