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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +139 -0
- data/README.md +1216 -0
- data/TODO.md +2 -0
- data/lib/cline/cli.rb +373 -0
- data/lib/cline/config.rb +100 -0
- data/lib/cline/configuration.rb +23 -0
- data/lib/cline/data.rb +119 -0
- data/lib/cline/file_content.rb +33 -0
- data/lib/cline/global_settings.rb +17 -0
- data/lib/cline/global_state/api_providers.rb +48 -0
- data/lib/cline/global_state/auto_approval.rb +73 -0
- data/lib/cline/global_state/browser.rb +52 -0
- data/lib/cline/global_state/features.rb +56 -0
- data/lib/cline/global_state/general.rb +77 -0
- data/lib/cline/global_state/models.rb +127 -0
- data/lib/cline/global_state/toggles.rb +33 -0
- data/lib/cline/global_state/workspace.rb +41 -0
- data/lib/cline/global_state.rb +16 -0
- data/lib/cline/log.rb +288 -0
- data/lib/cline/logs.rb +136 -0
- data/lib/cline/mcp_settings.rb +30 -0
- data/lib/cline/model.rb +47 -0
- data/lib/cline/models.rb +11 -0
- data/lib/cline/overlay_hash.rb +125 -0
- data/lib/cline/providers.rb +59 -0
- data/lib/cline/schema.rb +144 -0
- data/lib/cline/secret_string.rb +83 -0
- data/lib/cline/secrets.rb +119 -0
- data/lib/cline/serializable/cline_data.rb +131 -0
- data/lib/cline/serializable/dir.rb +81 -0
- data/lib/cline/serializable/file.rb +106 -0
- data/lib/cline/session.rb +87 -0
- data/lib/cline/session_data.rb +154 -0
- data/lib/cline/session_message.rb +178 -0
- data/lib/cline/session_messages.rb +61 -0
- data/lib/cline/sessions.rb +30 -0
- data/lib/cline/skill.rb +148 -0
- data/lib/cline/skills.rb +8 -0
- data/lib/cline/task.rb +75 -0
- data/lib/cline/task_message.rb +247 -0
- data/lib/cline/task_messages.rb +11 -0
- data/lib/cline/tasks.rb +30 -0
- data/lib/cline/usage.rb +37 -0
- data/lib/cline/utils/enumerable_dir_objects.rb +103 -0
- data/lib/cline/utils/file.rb +71 -0
- data/lib/cline/utils/file_monitor.rb +56 -0
- data/lib/cline/utils/logger.rb +37 -0
- data/lib/cline/utils/os/linux.rb +43 -0
- data/lib/cline/utils/os/mingw32.rb +46 -0
- data/lib/cline/utils/os.rb +31 -0
- data/lib/cline/utils/schema.rb +290 -0
- data/lib/cline/version.rb +6 -0
- data/lib/cline/workspace.rb +25 -0
- data/lib/cline/workspace_settings.rb +29 -0
- data/lib/cline/workspaces.rb +8 -0
- data/lib/cline.rb +22 -0
- 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
|
data/lib/cline/tasks.rb
ADDED
|
@@ -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
|
data/lib/cline/usage.rb
ADDED
|
@@ -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
|