cline-rb 1.0.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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +139 -0
  3. data/README.md +1216 -0
  4. data/TODO.md +2 -0
  5. data/lib/cline/cli.rb +373 -0
  6. data/lib/cline/config.rb +100 -0
  7. data/lib/cline/configuration.rb +23 -0
  8. data/lib/cline/data.rb +119 -0
  9. data/lib/cline/file_content.rb +33 -0
  10. data/lib/cline/global_settings.rb +17 -0
  11. data/lib/cline/global_state/api_providers.rb +48 -0
  12. data/lib/cline/global_state/auto_approval.rb +73 -0
  13. data/lib/cline/global_state/browser.rb +52 -0
  14. data/lib/cline/global_state/features.rb +56 -0
  15. data/lib/cline/global_state/general.rb +77 -0
  16. data/lib/cline/global_state/models.rb +127 -0
  17. data/lib/cline/global_state/toggles.rb +33 -0
  18. data/lib/cline/global_state/workspace.rb +41 -0
  19. data/lib/cline/global_state.rb +16 -0
  20. data/lib/cline/log.rb +288 -0
  21. data/lib/cline/logs.rb +136 -0
  22. data/lib/cline/mcp_settings.rb +30 -0
  23. data/lib/cline/model.rb +47 -0
  24. data/lib/cline/models.rb +11 -0
  25. data/lib/cline/overlay_hash.rb +125 -0
  26. data/lib/cline/providers.rb +59 -0
  27. data/lib/cline/schema.rb +144 -0
  28. data/lib/cline/secret_string.rb +83 -0
  29. data/lib/cline/secrets.rb +119 -0
  30. data/lib/cline/serializable/cline_data.rb +131 -0
  31. data/lib/cline/serializable/dir.rb +81 -0
  32. data/lib/cline/serializable/file.rb +106 -0
  33. data/lib/cline/session.rb +87 -0
  34. data/lib/cline/session_data.rb +154 -0
  35. data/lib/cline/session_message.rb +178 -0
  36. data/lib/cline/session_messages.rb +61 -0
  37. data/lib/cline/sessions.rb +30 -0
  38. data/lib/cline/skill.rb +148 -0
  39. data/lib/cline/skills.rb +8 -0
  40. data/lib/cline/task.rb +75 -0
  41. data/lib/cline/task_message.rb +247 -0
  42. data/lib/cline/task_messages.rb +11 -0
  43. data/lib/cline/tasks.rb +30 -0
  44. data/lib/cline/usage.rb +37 -0
  45. data/lib/cline/utils/enumerable_dir_objects.rb +103 -0
  46. data/lib/cline/utils/file.rb +71 -0
  47. data/lib/cline/utils/file_monitor.rb +56 -0
  48. data/lib/cline/utils/logger.rb +37 -0
  49. data/lib/cline/utils/os/linux.rb +43 -0
  50. data/lib/cline/utils/os/mingw32.rb +46 -0
  51. data/lib/cline/utils/os.rb +31 -0
  52. data/lib/cline/utils/schema.rb +290 -0
  53. data/lib/cline/version.rb +6 -0
  54. data/lib/cline/workspace.rb +25 -0
  55. data/lib/cline/workspace_settings.rb +29 -0
  56. data/lib/cline/workspaces.rb +8 -0
  57. data/lib/cline.rb +22 -0
  58. metadata +249 -0
data/lib/cline/task.rb ADDED
@@ -0,0 +1,75 @@
1
+ module Cline
2
+ # A task defined in a directory
3
+ class Task
4
+ # @!group Public API
5
+
6
+ include Serializable::Dir
7
+
8
+ # Get the task's messages
9
+ #
10
+ # @param create [Boolean] Should the data be created if it does not exist?
11
+ # @return [TaskMessages, nil] The task's messages, or nil if none
12
+ def messages(create: self.create)
13
+ @messages ||= TaskMessages.from_cline_data(dir, cline_models: @cline_models, create:)
14
+ end
15
+
16
+ # Equality check
17
+ #
18
+ # @param other [Object] The other to check equality with
19
+ # @return [Boolean] True if objects are equal
20
+ def ==(other)
21
+ other.is_a?(Task) &&
22
+ other.messages == messages
23
+ end
24
+
25
+ # Monitor messages with a callback called when new or updated messages arrive
26
+ #
27
+ # @param on_message [#call] Block called each time there is a new or updated message.
28
+ # * Param message [TaskMessage] Message that has happened
29
+ # * Param last [Boolean] Is this the last message fetched from the list of messages?
30
+ # * Param previous_version [TaskMessage or nil] Previous version of this message if it got updated, or nil if it is a new one
31
+ # @param ignore_partials [Boolean] Should we ignore partial messages?
32
+ # If true, then on_message will only be called for messages that have been fully received.
33
+ # @param monitoring_interval_secs [Float] The monitoring interval in seconds
34
+ # @yield Optional code called while monitoring is in place.
35
+ # If used then monitoring is stopped at the end of the block's execution.
36
+ # @return [FileMonitor, nil] If no block has been given, return the monitor that needs to be
37
+ # stopped by the caller when monitoring should end.
38
+ def monitor_messages(on_message:, ignore_partials: false, monitoring_interval_secs: 1, &)
39
+ # Keep messages per timestamp to detect updates
40
+ messages = {}
41
+ TaskMessages.monitor_cline_data_changes(
42
+ dir,
43
+ cline_models: @cline_models,
44
+ on_change: proc do |new_messages|
45
+ # Update the messages we have
46
+ @messages = new_messages
47
+ if new_messages && !new_messages.empty?
48
+ # Filter unwanted messages
49
+ new_messages = new_messages.reject(&:partial) if ignore_partials
50
+ # Check for updates in all messages
51
+ last_idx = new_messages.size - 1
52
+ new_messages.each.with_index do |message, idx|
53
+ ts = message.ts
54
+ if message != messages[ts]
55
+ on_message.call(message, idx == last_idx, messages[ts])
56
+ messages[ts] = message
57
+ end
58
+ end
59
+ end
60
+ end,
61
+ monitoring_interval_secs:,
62
+ &
63
+ )
64
+ end
65
+
66
+ # @!group Internal
67
+
68
+ # Constructor
69
+ #
70
+ # @param cline_models [Models] The Cline models used to interpret the tasks' messages
71
+ def initialize(cline_models:)
72
+ @cline_models = cline_models
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,247 @@
1
+ require 'ellipsized'
2
+ require 'time'
3
+
4
+ module Cline
5
+ # Task's message
6
+ class TaskMessage < Schema
7
+ # @!group Public API
8
+
9
+ # Model info in messages
10
+ class ModelInfo < Schema
11
+ # @!group Public API
12
+
13
+ # @return [String] Provider
14
+ attribute :provider_id, :string
15
+
16
+ # @return [String] Model
17
+ attribute :model_id, :string
18
+
19
+ # @return [String] Mode (plan or act)
20
+ attribute :mode, :string
21
+ end
22
+
23
+ # @return [Integer] Message timestamp
24
+ attribute :ts, :integer
25
+
26
+ # @return [String] Message type identifier
27
+ attribute :type, :string
28
+
29
+ # @return [String] Say message identifier
30
+ attribute :say, :string
31
+
32
+ # @return [String] Ask message identifier
33
+ attribute :ask, :string
34
+
35
+ # @return [String] Raw text content of the message
36
+ attribute :text, :string
37
+
38
+ # @return [ModelInfo] Model metadata
39
+ attribute :model_info, ModelInfo
40
+
41
+ # @return [Integer] Position index within the conversation history sequence
42
+ attribute :conversation_history_index, :integer
43
+
44
+ # @return [Boolean] Flag indicating this is an incomplete streaming message
45
+ attribute :partial, :boolean
46
+
47
+ # Get the message timestamp as a Ruby time
48
+ #
49
+ # @return [Time] The message timestamp
50
+ def timestamp
51
+ @timestamp ||= Time.at(ts / 1000.0)
52
+ end
53
+
54
+ # Get the usage statistics of this message, if any
55
+ #
56
+ # @return [Usage, nil] The usage statistics, or nil if none
57
+ def usage
58
+ return unless type == 'say' && say == 'api_req_started'
59
+
60
+ api_details = JSON.parse(text, symbolize_names: true)
61
+ Usage.new(
62
+ **{
63
+ cost: api_details[:cost],
64
+ input_tokens: api_details[:tokensIn],
65
+ output_tokens: api_details[:tokensOut],
66
+ cache_read_tokens: api_details[:cacheReads],
67
+ cache_write_tokens: api_details[:cacheWrites],
68
+ cline_model: cline_models[model_info.model_id]
69
+ }.compact
70
+ )
71
+ end
72
+
73
+ # Return a human-friendly version of a message.
74
+ # Useful for stdout or logging.
75
+ #
76
+ # @param limit [Integer] Number of characters the message should be limited to
77
+ # @return [String] The human translation
78
+ def to_human(limit: 128)
79
+ case type
80
+ when 'say'
81
+ case say
82
+ when 'text', 'task'
83
+ one_lining(text)
84
+ when 'api_req_started'
85
+ sections = parse_sections(JSON.parse(text, symbolize_names: true)[:request])
86
+ section_delimiter = '|'
87
+ # Ignore some sections
88
+ sections.reject! { |section| section[:name] == 'environment_details' }
89
+ section_size = (limit / sections.size) - section_delimiter.size
90
+ sections.map do |section|
91
+ "#{"#{section[:name]}: " if section[:name]}#{one_lining(section[:content])}".ellipsized(section_size)
92
+ end.join('|')
93
+ when 'tool'
94
+ tool_details = JSON.parse(text, symbolize_names: true)
95
+ tool_header =
96
+ case tool_details[:tool]
97
+ when 'readFile'
98
+ "[readFile] - #{tool_details[:path]}"
99
+ when 'listFilesRecursive'
100
+ "[listFilesRecursive] - #{tool_details[:path]}"
101
+ when 'listFilesTopLevel'
102
+ "[listFilesTopLevel] - #{tool_details[:path]}"
103
+ when 'newFileCreated'
104
+ "[newFileCreated] - #{tool_details[:path]}"
105
+ when 'editedExistingFile'
106
+ "[editedExistingFile] - #{tool_details[:path]}"
107
+ when 'searchFiles'
108
+ "[searchFiles] - #{tool_details[:path]} (regex: #{tool_details[:regex]})"
109
+ when 'useSkill'
110
+ "[useSkill] - #{tool_details[:skill_name]}"
111
+ else
112
+ raise NotImplementedError, "Unknown tool @ts #{ts}: #{self}"
113
+ end
114
+ "#{tool_header}#{": #{one_lining(tool_details[:content])}".ellipsized(limit - tool_header.size) if tool_details.key?(:content)}"
115
+ when 'api_req_retried'
116
+ 'API request retried'
117
+ when 'command'
118
+ "Command: #{one_lining(text)}"
119
+ when 'command_output'
120
+ "Command output: #{one_lining(text)}"
121
+ when 'diff_error'
122
+ "Diff error: #{one_lining(text)}"
123
+ when 'error_retry'
124
+ "Error retry: #{one_lining(text)}"
125
+ when 'reasoning'
126
+ "Reasoning: #{one_lining(text)}"
127
+ when 'user_feedback'
128
+ "User feedback: #{one_lining(text)}"
129
+ when 'task_progress'
130
+ # Count completed vs total tasks
131
+ completed_tasks = text.scan('- [x]').size
132
+ total_tasks = text.scan(/- \[[ x]\]/).size
133
+ "Task progress: #{completed_tasks}/#{total_tasks} tasks"
134
+ when 'completion_result'
135
+ "Task completed: #{one_lining(text)}"
136
+ when 'error'
137
+ "Error: #{one_lining(text)}"
138
+ else
139
+ raise NotImplementedError, "Unknown say @ts #{ts}: #{self}"
140
+ end
141
+ when 'ask'
142
+ "Ask user: #{
143
+ case ask
144
+ when 'resume_task'
145
+ 'Resume task'
146
+ when 'resume_completed_task'
147
+ 'Resume completed task'
148
+ when 'api_req_failed'
149
+ details = JSON.parse(text, symbolize_names: true)
150
+ "API request failed - #{details[:code]} - #{one_lining(details[:message])}"
151
+ when 'command_output'
152
+ "Command output - #{one_lining(text)}"
153
+ when 'completion_result'
154
+ 'Completion result'
155
+ when 'followup'
156
+ details = JSON.parse(text, symbolize_names: true)
157
+ "Follow-up - #{details[:question]}#{" - Options: #{details[:options].join(', ')}" unless details[:options].nil? || details[:options].empty?}"
158
+ when 'plan_mode_respond'
159
+ details = JSON.parse(text, symbolize_names: true)
160
+ "Plan mode respond - #{one_lining(details[:response])}}"
161
+ when 'tool'
162
+ details = JSON.parse(text, symbolize_names: true)
163
+ tool_name = details.delete(:tool)
164
+ "Use tool - #{tool_name} - #{JSON.dump(details)}}"
165
+ when 'mistake_limit_reached'
166
+ "Mistake limit reached - #{one_lining(text)}}"
167
+ when 'new_task'
168
+ "New task - #{one_lining(text)}"
169
+ else
170
+ raise NotImplementedError, "Unknown ask @ts #{ts}: #{self}"
171
+ end
172
+ }"
173
+ else
174
+ raise NotImplementedError, "Unknown type @ts #{ts}: #{self}"
175
+ end.ellipsized(limit)
176
+ end
177
+
178
+ # @!group Internal
179
+
180
+ # Parse a Hash object and instantiate the proper instance from it.
181
+ #
182
+ # @param hash [Hash] Data
183
+ # @param args [Array] Remaining arguments to be transferred to Shale
184
+ # @param cline_models [Models] The Clines models used to interpret the message
185
+ # @param kwargs [Hash] Remaining kwargs to be transferred to Shale
186
+ # @return [Schema] Corresponding instance
187
+ def self.of_hash(hash, *args, cline_models:, **kwargs)
188
+ instance = super(hash, *args, **kwargs)
189
+ instance.cline_models = cline_models
190
+ instance
191
+ end
192
+
193
+ # @return [Models] The Clines models used to interpret the message
194
+ attr_accessor :cline_models
195
+
196
+ private
197
+
198
+ # Convert a string to a single line by replacing newlines with spaces and removing carriage returns
199
+ #
200
+ # @param text [String] The text to convert to one line
201
+ # @return [String] The text converted to a single line
202
+ def one_lining(text)
203
+ text.strip.gsub("\n", ' ').gsub("\r", '')
204
+ end
205
+
206
+ # Use a single regex to match complete tag pairs
207
+ # This regex matches <tag>content</tag> patterns
208
+ COMPLETE_TAG_PATTERN = %r{<([a-zA-Z0-9_:]*)>(.*?)</\1>}m
209
+ private_constant :COMPLETE_TAG_PATTERN
210
+
211
+ # Parse sections from a string with HTML-like tags.
212
+ # Sections are delimited by html-like tags, for example `<toto>...</toto>` is the section named `toto`.
213
+ # Sections without tags should still be part of the result, with a nil section name.
214
+ # Only considers top-level tags (no recursion).
215
+ #
216
+ # @param content [String] The input string to parse
217
+ # @return [Array<Hash{Symbol => Object}>] List of sections found from the content. Each section can have the following properties:
218
+ # * name [String, nil] Section name, or nil if no name was given to this section (in-between named sections).
219
+ # * content [String] Section content.
220
+ def parse_sections(content)
221
+ sections = []
222
+ current_pos = 0
223
+ content_length = content.length
224
+ # Use cursor progression to maintain order
225
+ while current_pos < content_length
226
+ # Find the next complete tag pair starting from current position
227
+ match = content.match(COMPLETE_TAG_PATTERN, current_pos)
228
+ if match
229
+ # Add any untagged content before this tag
230
+ if match.begin(0) > current_pos
231
+ untagged_content = content[current_pos...match.begin(0)]
232
+ sections << { name: nil, content: untagged_content } unless untagged_content.strip.empty?
233
+ end
234
+ # Add the tagged section
235
+ sections << { name: match[1], content: match[2] }
236
+ current_pos = match.end(0)
237
+ else
238
+ # No more tags found, add remaining content
239
+ remaining_content = content[current_pos..]
240
+ sections << { name: nil, content: remaining_content } unless remaining_content.strip.empty?
241
+ current_pos = content_length
242
+ end
243
+ end
244
+ sections
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,11 @@
1
+ module Cline
2
+ # Base class, dynamically defined
3
+ TaskMessageCollection = Utils::Schema.collection(TaskMessage)
4
+
5
+ # Access all messages associated to a Cline task
6
+ class TaskMessages < TaskMessageCollection
7
+ # @!group Public API
8
+
9
+ Serializable::ClineData.include_for(self, 'ui_messages.json')
10
+ end
11
+ end
@@ -0,0 +1,30 @@
1
+ module Cline
2
+ # Provide a set of tasks from a directory
3
+ class Tasks
4
+ # @!group Public API
5
+
6
+ include Utils::EnumerableDirObjects
7
+
8
+ # @!group Internal
9
+
10
+ # Constructor
11
+ #
12
+ # @param cline_models [Models] The Cline models used to interpret the tasks' messages
13
+ def initialize(cline_models:)
14
+ @cline_models = cline_models
15
+ end
16
+
17
+ private
18
+
19
+ # Get an object and its name from a sub-directory
20
+ #
21
+ # @param dir [String] The directory containing the object
22
+ # @param create [Boolean] Should the instance be created if it does not exist?
23
+ # @return [Array(String, Object)] Return 2 values:
24
+ # 0. [String] The object name
25
+ # 1. [Object] The object itself
26
+ def object_from(dir, create:)
27
+ [File.basename(dir), Task.open(dir, cline_models: @cline_models, create:)]
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,37 @@
1
+ module Cline
2
+ # Track usage statistics associated to a message
3
+ Usage = Struct.new(
4
+ # @!group Public API
5
+
6
+ # [Float] Monetary cost incurred for this API request
7
+ :cost,
8
+
9
+ # [Integer] Total input tokens sent in the request
10
+ :input_tokens,
11
+
12
+ # [Integer] Total output tokens generated in the response
13
+ :output_tokens,
14
+
15
+ # [Integer] Number of tokens retrieved from cache
16
+ :cache_read_tokens,
17
+
18
+ # [Integer] Number of tokens stored into cache
19
+ :cache_write_tokens,
20
+
21
+ # [Model. nil] Model used for this request, or nil if none
22
+ :cline_model,
23
+ keyword_init: true
24
+ ) do
25
+ # @!group Public API
26
+
27
+ # @return [Integer] Total context tokens consumed
28
+ def context_tokens
29
+ input_tokens + output_tokens + cache_read_tokens + cache_write_tokens
30
+ end
31
+
32
+ # @return [Integer, nil] Maximum context window limit for the used model
33
+ def context_tokens_limit
34
+ cline_model&.context_window
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,103 @@
1
+ require 'forwardable'
2
+
3
+ module Cline
4
+ module Utils
5
+ # Simple mixin providing an Enumerable and Set interfaces on objects initialized from
6
+ # a list of sub-directories.
7
+ # Classes including this mixin should call Class.open(dir) to instantiate a new instance initialized from a directory,
8
+ # and implement the method object_from(dir) to return the corresponding name and object parsed from a sub-directory.
9
+ module EnumerableDirObjects
10
+ extend Forwardable
11
+
12
+ # @!group Public API
13
+
14
+ include Enumerable
15
+
16
+ # Give a Hash interface
17
+ def_delegators :objects_set, *%i[[] each empty? key? keys size values]
18
+
19
+ # Equality check
20
+ #
21
+ # @param other [Object] The other to check equality with
22
+ # @return [Boolean] True if objects are equal
23
+ def ==(other)
24
+ other.is_a?(EnumerableDirObjects) &&
25
+ other.size == size &&
26
+ other.each.to_a.to_h == each.to_a.to_h
27
+ end
28
+
29
+ # Create a new object from this sub-directories set.
30
+ # This also creates a new sub-directory.
31
+ #
32
+ # @param name [String] The object name to create (also serves as sub-directory name)
33
+ # @return [Object] The corresponding instance that has been setup from this new sub-directory
34
+ def new(name)
35
+ _name, instance = object_from(::File.join(dir, name), create: true)
36
+ objects_set[name] = instance
37
+ instance
38
+ end
39
+
40
+ # @!group Internal
41
+
42
+ # Hook used when this mixin is included in a base class
43
+ #
44
+ # @param base [Class] The base class
45
+ def self.included(base)
46
+ base.include(Serializable::Dir)
47
+ end
48
+
49
+ # Include the mixin and configure it for a specific object class
50
+ # This method automatically implements the required object_from method
51
+ #
52
+ # @param calling_class [Class] The class that is calling this method
53
+ # @param object_class [Class] The class to instantiate for each directory
54
+ def self.include_for(calling_class, object_class)
55
+ calling_class.class_eval do
56
+ include EnumerableDirObjects
57
+
58
+ private
59
+
60
+ # Get an object and its name from a sub-directory
61
+ #
62
+ # @param dir [String] The directory containing the object
63
+ # @param create [Boolean] Should the instance be created if it does not exist?
64
+ # @return [Array(String, Object)] Return 2 values:
65
+ # 0. [String] The object name
66
+ # 1. [Object] The object itself
67
+ define_method(:object_from) do |dir, create:|
68
+ [::File.basename(dir), object_class.open(dir, create:)]
69
+ end
70
+ end
71
+ end
72
+
73
+ # Remove caches.
74
+ def refresh!
75
+ @objects_set = nil
76
+ end
77
+
78
+ private
79
+
80
+ # Read all objects from the dir.
81
+ # Memoize it.
82
+ #
83
+ # @return [Hash{String => Object}] The objects, per object name
84
+ def objects_set
85
+ @objects_set ||= Dir
86
+ .glob(::File.join(dir, '*'))
87
+ .select { |path| ::File.directory?(path) }
88
+ .to_h { |subdir| object_from(subdir, create: create) }
89
+ end
90
+
91
+ # Get an object and its name from a sub-directory
92
+ #
93
+ # @param dir [String] The directory containing the object
94
+ # @param create [Boolean] Should the instance be created if it does not exist?
95
+ # @return [Array(String, Object)] Return 2 values:
96
+ # 0. [String] The object name
97
+ # 1. [Object] The object itself
98
+ def object_from(dir, create:)
99
+ raise NotImplementedError, 'This method should be implemented by the class including this mixin.'
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,71 @@
1
+ require 'fileutils'
2
+ require 'tmpdir'
3
+
4
+ module Cline
5
+ module Utils
6
+ # Some file helpers
7
+ module File
8
+ # Try to read a file with retries in case other processes are using it.
9
+ #
10
+ # Parameters::
11
+ # * *file* (String): Path to read
12
+ # * *max_retries* (Integer): Number of retries in case of concurrent access [default: 3]
13
+ # Result::
14
+ # * String: The file content
15
+ def self.safe_read(file, max_retries: 3)
16
+ retries = 0
17
+ file_content = nil
18
+ begin
19
+ file_content = ::File.read(file)
20
+ rescue Errno::EACCES, Errno::EAGAIN
21
+ # Could be that the file is being written at the same time.
22
+ # Just try again.
23
+ retries += 1
24
+ raise if retries > max_retries
25
+
26
+ sleep(0.05 * retries)
27
+ retry
28
+ end
29
+ file_content
30
+ end
31
+
32
+ # Try to read a file and parse its JSON content with retries.
33
+ # Uses safe_read internally, and also retries on JSON parse errors
34
+ # (e.g. if the file was half-written when read).
35
+ #
36
+ # Parameters::
37
+ # * *file* (String): Path to read
38
+ # * *max_retries* (Integer): Number of retries for both file access and JSON parsing [default: 3]
39
+ # Result::
40
+ # * Object: The parsed JSON content
41
+ def self.safe_json_read(file, max_retries: 3)
42
+ retries = 0
43
+ begin
44
+ content = safe_read(file, max_retries: max_retries)
45
+ JSON.parse(content)
46
+ rescue JSON::ParserError
47
+ retries += 1
48
+ raise if retries > max_retries
49
+
50
+ sleep(0.05 * retries)
51
+ retry
52
+ end
53
+ end
54
+
55
+ # Provide a temporary directory.
56
+ # Will clean up the directory after code execution unless debug mode is on.
57
+ #
58
+ # @yield [temp_dir] Block called with the temp directory ready
59
+ # @yieldparam temp_dir [String] The temp directory
60
+ def self.with_temp_dir(&)
61
+ if Cline.config.debug
62
+ temp_dir = "#{Cline.config.temp_dir_root}/#{Time.now.utc.strftime('%Y-%m-%d-%H-%M-%S-%N')}"
63
+ FileUtils.mkdir_p temp_dir
64
+ yield temp_dir
65
+ else
66
+ Dir.mktmpdir(&)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,56 @@
1
+ module Cline
2
+ module Utils
3
+ # Provide a file changes monitor.
4
+ # Calls a callback as a separate thread for each change that happens on a file
5
+ class FileMonitor
6
+ # Constructor
7
+ #
8
+ # @param file [String] The file to monitor
9
+ # @param on_change [#call] Block called each time there is an update.
10
+ # * Param mtime [Time, nil] The new file's modification time, or nil if the file is missing
11
+ # @param monitoring_interval_secs [Float] The monitoring interval in seconds
12
+ def initialize(file, on_change:, monitoring_interval_secs: 1)
13
+ @file = file
14
+ @on_change = on_change
15
+ @monitoring_interval_secs = monitoring_interval_secs
16
+ @monitoring = false
17
+ @monitoring_thread = nil
18
+ end
19
+
20
+ # Start monitoring
21
+ #
22
+ # @yield Optional block that is called while monitoring has started.
23
+ # If this block is given, then #stop will be called automatically at the end of the block execution.
24
+ def start
25
+ @monitoring = true
26
+ @monitoring_thread = Thread.new do
27
+ file_mtime = nil
28
+ loop do
29
+ new_file_mtime = ::File.exist?(@file) ? ::File.mtime(@file) : nil
30
+ if new_file_mtime != file_mtime
31
+ # There is an update
32
+ @on_change.call(new_file_mtime)
33
+ file_mtime = new_file_mtime
34
+ end
35
+ break unless @monitoring
36
+
37
+ sleep @monitoring_interval_secs
38
+ end
39
+ end
40
+ return unless block_given?
41
+
42
+ begin
43
+ yield
44
+ ensure
45
+ stop
46
+ end
47
+ end
48
+
49
+ # Stop monitoring
50
+ def stop
51
+ @monitoring = false
52
+ @monitoring_thread.join
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,37 @@
1
+ require 'strings/ansi'
2
+
3
+ module Cline
4
+ # Various internal utilities and helpers.
5
+ module Utils
6
+ # Mixin adding some debug logging capabilities
7
+ module Logger
8
+ class << self
9
+ # Sanitize some PTY output:
10
+ # - Remove ANSI escape codes.
11
+ # - Remove CSI escape codes.
12
+ # - Remove OSC escape codes.
13
+ #
14
+ # @param pty_output [String] PTY output string
15
+ # @param colored [Boolean] Do we keep colored output?
16
+ # @return [String] Resulting sanitized string
17
+ def sanitize_pty_output(pty_output, colored: false)
18
+ pty_output = Strings::ANSI.sanitize(pty_output) unless colored
19
+ pty_output.gsub(/\e\][^\a]*\a/, '')
20
+ end
21
+ end
22
+
23
+ # Log a message if debug was activated
24
+ #
25
+ # Parameters::
26
+ # @param msg [String, nil] Message to be displayed, or nil if the message is given lazily through a code block
27
+ # @yield Code returning a String for lazy evaluation
28
+ # * Return [String] Debug message
29
+ def log_debug(msg = nil)
30
+ return unless Cline.config.debug
31
+
32
+ msg = yield if block_given?
33
+ puts "[CLINE DEBUG] - #{msg}"
34
+ end
35
+ end
36
+ end
37
+ end