openclacky 1.3.3 → 1.3.4
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 +4 -4
- data/CHANGELOG.md +26 -0
- data/docs/rich_ui_guide.md +277 -0
- data/docs/rich_ui_refactor_plan.md +396 -0
- data/lib/clacky/agent/llm_caller.rb +10 -4
- data/lib/clacky/agent/session_serializer.rb +3 -2
- data/lib/clacky/agent.rb +3 -2
- data/lib/clacky/agent_config.rb +2 -14
- data/lib/clacky/api_extension.rb +262 -0
- data/lib/clacky/api_extension_loader.rb +156 -0
- data/lib/clacky/cli.rb +93 -3
- data/lib/clacky/client.rb +38 -13
- data/lib/clacky/default_agents/_panels/git/panel.js +1 -1
- data/lib/clacky/default_agents/_panels/time_machine/panel.js +1 -1
- data/lib/clacky/default_skills/media-gen/SKILL.md +9 -6
- data/lib/clacky/idle_compression_timer.rb +3 -1
- data/lib/clacky/locales/en.rb +26 -0
- data/lib/clacky/locales/i18n.rb +26 -0
- data/lib/clacky/locales/zh.rb +26 -0
- data/lib/clacky/rich_ui/components/base_component.rb +50 -0
- data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
- data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
- data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
- data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
- data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
- data/lib/clacky/rich_ui/components/status_view.rb +58 -0
- data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
- data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
- data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
- data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
- data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
- data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
- data/lib/clacky/rich_ui/view_renderer.rb +291 -0
- data/lib/clacky/rich_ui.rb +57 -0
- data/lib/clacky/rich_ui_controller.rb +3 -1549
- data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
- data/lib/clacky/server/http_server.rb +150 -103
- data/lib/clacky/server/session_registry.rb +1 -1
- data/lib/clacky/shell_hook_loader.rb +1 -1
- data/lib/clacky/tools/edit.rb +14 -2
- data/lib/clacky/ui2/ui_controller.rb +7 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +56 -59
- data/lib/clacky/web/app.js +65 -7
- data/lib/clacky/web/components/onboard.js +18 -2
- data/lib/clacky/web/core/aside.js +8 -3
- data/lib/clacky/web/core/ext.js +1 -1
- data/lib/clacky/web/features/skills/store.js +30 -2
- data/lib/clacky/web/features/skills/view.js +32 -1
- data/lib/clacky/web/features/workspace/view.js +1 -1
- data/lib/clacky/web/i18n.js +32 -20
- data/lib/clacky/web/index.html +9 -17
- data/lib/clacky/web/sessions.js +286 -28
- data/lib/clacky/web/settings.js +109 -111
- data/lib/clacky/web/ws-dispatcher.js +7 -3
- data/lib/clacky.rb +17 -2
- metadata +38 -2
- data/lib/clacky/media/output_dir.rb +0 -43
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Clacky
|
|
7
|
+
# Base class for user-defined HTTP API extensions loaded from
|
|
8
|
+
# ~/.clacky/api_ext/<name>/handler.rb. Subclasses use a tiny route DSL
|
|
9
|
+
# (get/post/put/patch/delete) to expose endpoints under
|
|
10
|
+
# /api/ext/<name>/<sub-path>
|
|
11
|
+
#
|
|
12
|
+
# The framework wires up access-key auth, timeouts, JSON error envelopes,
|
|
13
|
+
# path-parameter parsing, and a curated handler context — extension authors
|
|
14
|
+
# only fill in business logic.
|
|
15
|
+
#
|
|
16
|
+
# Minimal example (~/.clacky/api_ext/my-dashboard/handler.rb):
|
|
17
|
+
#
|
|
18
|
+
# class MyDashboardExt < Clacky::ApiExtension
|
|
19
|
+
# get "/summary" do
|
|
20
|
+
# json(sessions: session_manager.list.size)
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# Mounted automatically at: GET /api/ext/my-dashboard/summary
|
|
25
|
+
class ApiExtension
|
|
26
|
+
HTTP_METHODS = %i[get post put patch delete].freeze
|
|
27
|
+
MAX_TIMEOUT = 600
|
|
28
|
+
DEFAULT_TIMEOUT = 10
|
|
29
|
+
|
|
30
|
+
Route = Struct.new(:method, :pattern, :regex, :param_names, :block, :options, keyword_init: true)
|
|
31
|
+
|
|
32
|
+
class Halt < StandardError
|
|
33
|
+
attr_reader :status, :payload, :content_type
|
|
34
|
+
|
|
35
|
+
def initialize(status, payload, content_type)
|
|
36
|
+
super("api_ext halt #{status}")
|
|
37
|
+
@status = status
|
|
38
|
+
@payload = payload
|
|
39
|
+
@content_type = content_type
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
# Registry of all loaded ApiExtension subclasses, keyed by extension id
|
|
45
|
+
# (== directory name == mount prefix segment).
|
|
46
|
+
def registry
|
|
47
|
+
@registry ||= {}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def register(ext_id, klass)
|
|
51
|
+
registry[ext_id] = klass
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def reset_registry!
|
|
55
|
+
@registry = {}
|
|
56
|
+
@pending_subclasses = []
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Captures every subclass at the moment its `class` body finishes being
|
|
60
|
+
# required — the loader pops the most recent one off this list to bind
|
|
61
|
+
# an ext_id/dir without relying on ObjectSpace scans.
|
|
62
|
+
def pending_subclasses
|
|
63
|
+
@pending_subclasses ||= []
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def inherited(subclass)
|
|
67
|
+
super
|
|
68
|
+
Clacky::ApiExtension.pending_subclasses << subclass
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Per-subclass state — inherited classes carry their own routes/options.
|
|
72
|
+
def routes
|
|
73
|
+
@routes ||= []
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def class_timeout
|
|
77
|
+
@class_timeout
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def public_paths
|
|
81
|
+
@public_paths ||= []
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def ext_id
|
|
85
|
+
@ext_id
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def ext_id=(value)
|
|
89
|
+
@ext_id = value
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def ext_dir
|
|
93
|
+
@ext_dir
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def ext_dir=(value)
|
|
97
|
+
@ext_dir = value
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def meta
|
|
101
|
+
@meta ||= {}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def meta=(value)
|
|
105
|
+
@meta = value || {}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Set a default timeout (seconds) for every handler in this class.
|
|
109
|
+
# Per-route override available via `get "/x", timeout: 30 do ... end`.
|
|
110
|
+
def timeout(seconds)
|
|
111
|
+
raise ArgumentError, "timeout must be > 0" unless seconds.is_a?(Numeric) && seconds > 0
|
|
112
|
+
raise ArgumentError, "timeout exceeds MAX_TIMEOUT (#{MAX_TIMEOUT}s)" if seconds > MAX_TIMEOUT
|
|
113
|
+
|
|
114
|
+
@class_timeout = seconds.to_f
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Mark a route as not requiring access-key auth. Caller must also
|
|
118
|
+
# declare `public: true` in meta.yml for the framework to honor this.
|
|
119
|
+
def public_endpoint(pattern)
|
|
120
|
+
public_paths << normalize_pattern(pattern)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
HTTP_METHODS.each do |verb|
|
|
124
|
+
define_method(verb) do |pattern, **opts, &block|
|
|
125
|
+
raise ArgumentError, "missing handler block for #{verb.upcase} #{pattern}" unless block
|
|
126
|
+
|
|
127
|
+
per_route_timeout = opts[:timeout]
|
|
128
|
+
if per_route_timeout
|
|
129
|
+
raise ArgumentError, "timeout must be > 0" unless per_route_timeout.is_a?(Numeric) && per_route_timeout > 0
|
|
130
|
+
raise ArgumentError, "timeout exceeds MAX_TIMEOUT (#{MAX_TIMEOUT}s)" if per_route_timeout > MAX_TIMEOUT
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
normalized = normalize_pattern(pattern)
|
|
134
|
+
regex, param_names = compile_pattern(normalized)
|
|
135
|
+
routes << Route.new(
|
|
136
|
+
method: verb,
|
|
137
|
+
pattern: normalized,
|
|
138
|
+
regex: regex,
|
|
139
|
+
param_names: param_names,
|
|
140
|
+
block: block,
|
|
141
|
+
options: opts.dup
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def normalize_pattern(pattern)
|
|
147
|
+
pattern = pattern.to_s
|
|
148
|
+
pattern = "/#{pattern}" unless pattern.start_with?("/")
|
|
149
|
+
pattern = pattern.chomp("/")
|
|
150
|
+
pattern.empty? ? "/" : pattern
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def compile_pattern(pattern)
|
|
154
|
+
param_names = []
|
|
155
|
+
regex_str = pattern.gsub(%r{:([a-zA-Z_][a-zA-Z0-9_]*)}) do |_match|
|
|
156
|
+
param_names << Regexp.last_match(1).to_sym
|
|
157
|
+
"([^/]+)"
|
|
158
|
+
end
|
|
159
|
+
[Regexp.new("\\A#{regex_str}\\z"), param_names]
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
attr_reader :req, :res, :route, :params
|
|
164
|
+
|
|
165
|
+
def initialize(req:, res:, route:, params:, http_server:)
|
|
166
|
+
@req = req
|
|
167
|
+
@res = res
|
|
168
|
+
@route = route
|
|
169
|
+
@params = params
|
|
170
|
+
@http_server = http_server
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def invoke
|
|
174
|
+
instance_exec(&route.block)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# ---- handler context (white-listed access to host process) ----
|
|
178
|
+
|
|
179
|
+
def json(*args, **kwargs)
|
|
180
|
+
if args.empty?
|
|
181
|
+
# Treat kwargs as the body: json(foo: 1, bar: 2)
|
|
182
|
+
# For non-200 status, pass an explicit hash: json({foo: 1}, status: 422)
|
|
183
|
+
raise Halt.new(200, JSON.generate(kwargs), "application/json; charset=utf-8")
|
|
184
|
+
elsif args.size == 1
|
|
185
|
+
status = kwargs[:status] || 200
|
|
186
|
+
raise Halt.new(status, JSON.generate(args[0]), "application/json; charset=utf-8")
|
|
187
|
+
else
|
|
188
|
+
raise ArgumentError, "json: expected (hash) or (key: value, ...)"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def text(str, status: 200)
|
|
193
|
+
raise Halt.new(status, str.to_s, "text/plain; charset=utf-8")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def error!(message, status: 400, **extra)
|
|
197
|
+
payload = { error: message.to_s }
|
|
198
|
+
payload.merge!(extra) unless extra.empty?
|
|
199
|
+
raise Halt.new(status, JSON.generate(payload), "application/json; charset=utf-8")
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def json_body
|
|
203
|
+
@json_body ||= begin
|
|
204
|
+
return {} if req.body.nil? || req.body.empty?
|
|
205
|
+
JSON.parse(req.body)
|
|
206
|
+
rescue JSON::ParserError
|
|
207
|
+
{}
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def query
|
|
212
|
+
@query ||= req.query || {}
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def data_path(*parts)
|
|
216
|
+
base = File.join(self.class.ext_dir, "data")
|
|
217
|
+
FileUtils.mkdir_p(base)
|
|
218
|
+
File.join(base, *parts.map(&:to_s))
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def ext_dir
|
|
222
|
+
self.class.ext_dir
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def ext_id
|
|
226
|
+
self.class.ext_id
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def config
|
|
230
|
+
self.class.meta["config"] || {}
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def session_manager
|
|
234
|
+
@http_server&.instance_variable_get(:@session_manager)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def agent_config
|
|
238
|
+
@http_server&.instance_variable_get(:@agent_config)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def server_start_time
|
|
242
|
+
@http_server&.instance_variable_get(:@start_time)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def logger
|
|
246
|
+
@logger ||= ScopedLogger.new(self.class.ext_id)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Lightweight wrapper that prefixes log lines with the extension id.
|
|
250
|
+
class ScopedLogger
|
|
251
|
+
def initialize(ext_id)
|
|
252
|
+
@prefix = "[api_ext:#{ext_id}]"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
%i[debug info warn error].each do |level|
|
|
256
|
+
define_method(level) do |msg|
|
|
257
|
+
Clacky::Logger.public_send(level, "#{@prefix} #{msg}")
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Clacky
|
|
6
|
+
# Discovers and loads user-defined HTTP API extensions from
|
|
7
|
+
# ~/.clacky/api_ext/<name>/handler.rb. Each handler is expected to define a
|
|
8
|
+
# subclass of Clacky::ApiExtension; the subclass is auto-registered with the
|
|
9
|
+
# framework and its routes become available under /api/ext/<name>/.
|
|
10
|
+
#
|
|
11
|
+
# A broken extension (syntax error, missing base class, route conflict) is
|
|
12
|
+
# isolated: skipped with a logged warning, never aborts the load of others.
|
|
13
|
+
module ApiExtensionLoader
|
|
14
|
+
DEFAULT_DIR = File.expand_path("~/.clacky/api_ext")
|
|
15
|
+
DISABLED_DIR = "_disabled"
|
|
16
|
+
|
|
17
|
+
Result = Struct.new(:loaded, :skipped, keyword_init: true)
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def load_all(dir: DEFAULT_DIR)
|
|
21
|
+
result = Result.new(loaded: [], skipped: [])
|
|
22
|
+
Clacky::ApiExtension.reset_registry!
|
|
23
|
+
|
|
24
|
+
if Dir.exist?(dir)
|
|
25
|
+
Dir.glob(File.join(dir, "*", "handler.rb")).sort.each do |handler_path|
|
|
26
|
+
ext_dir = File.dirname(handler_path)
|
|
27
|
+
ext_id = File.basename(ext_dir)
|
|
28
|
+
next if ext_id == DISABLED_DIR || ext_id.start_with?("_")
|
|
29
|
+
load_one(ext_id, ext_dir, handler_path, result)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@last_result = result
|
|
34
|
+
log_summary(result)
|
|
35
|
+
result
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def last_result
|
|
39
|
+
@last_result || load_all
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def load_one(ext_id, ext_dir, handler_path, result)
|
|
43
|
+
meta = read_meta(ext_dir)
|
|
44
|
+
before = Clacky::ApiExtension.pending_subclasses.size
|
|
45
|
+
|
|
46
|
+
require handler_path
|
|
47
|
+
|
|
48
|
+
new_subclasses = Clacky::ApiExtension.pending_subclasses[before..] || []
|
|
49
|
+
klass = new_subclasses.last
|
|
50
|
+
|
|
51
|
+
unless klass
|
|
52
|
+
result.skipped << [ext_id, "no Clacky::ApiExtension subclass defined in handler.rb"]
|
|
53
|
+
log_skip(ext_id, result.skipped.last[1])
|
|
54
|
+
return
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
klass.ext_id = ext_id
|
|
58
|
+
klass.ext_dir = ext_dir
|
|
59
|
+
klass.meta = meta
|
|
60
|
+
|
|
61
|
+
if klass.routes.empty?
|
|
62
|
+
result.skipped << [ext_id, "no routes declared (use get/post/... DSL)"]
|
|
63
|
+
log_skip(ext_id, result.skipped.last[1])
|
|
64
|
+
Clacky::ApiExtension.registry.delete(ext_id)
|
|
65
|
+
return
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
if (gap = validate_public_endpoints(klass, meta))
|
|
69
|
+
result.skipped << [ext_id, gap]
|
|
70
|
+
log_skip(ext_id, gap)
|
|
71
|
+
return
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
Clacky::ApiExtension.register(ext_id, klass)
|
|
75
|
+
result.loaded << ext_id
|
|
76
|
+
public_count = klass.public_paths.size
|
|
77
|
+
suffix = public_count > 0 ? " (#{public_count} public)" : ""
|
|
78
|
+
Clacky::Logger.info("[ApiExtensionLoader] Loaded '#{ext_id}' — #{klass.routes.size} route(s)#{suffix}")
|
|
79
|
+
rescue StandardError, ScriptError => e
|
|
80
|
+
result.skipped << [ext_id, e.message]
|
|
81
|
+
log_skip(ext_id, e.message)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private def read_meta(ext_dir)
|
|
85
|
+
path = File.join(ext_dir, "meta.yml")
|
|
86
|
+
return {} unless File.exist?(path)
|
|
87
|
+
|
|
88
|
+
YAMLCompat.load_file(path) || {}
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
Clacky::Logger.warn("[ApiExtensionLoader] Failed to read meta.yml in #{ext_dir}: #{e.message}")
|
|
91
|
+
{}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private def validate_public_endpoints(klass, meta)
|
|
95
|
+
return nil if klass.public_paths.empty?
|
|
96
|
+
return nil if meta["public"] == true
|
|
97
|
+
|
|
98
|
+
"uses public_endpoint but meta.yml is missing 'public: true'"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private def log_skip(ext_id, reason)
|
|
102
|
+
Clacky::Logger.warn("[ApiExtensionLoader] Skipped '#{ext_id}': #{reason}")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private def log_summary(result)
|
|
106
|
+
return if result.loaded.empty? && result.skipped.empty?
|
|
107
|
+
|
|
108
|
+
total_routes = result.loaded.sum { |id| Clacky::ApiExtension.registry[id]&.routes&.size || 0 }
|
|
109
|
+
Clacky::Logger.info("[ApiExtensionLoader] #{result.loaded.size} extension(s), #{total_routes} route(s); #{result.skipped.size} skipped")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Generate a starter handler.rb at ~/.clacky/api_ext/<name>/handler.rb.
|
|
113
|
+
# Returns the path to the generated file.
|
|
114
|
+
def scaffold(name, dir: DEFAULT_DIR)
|
|
115
|
+
slug = name.to_s.strip.downcase.gsub(/[^a-z0-9_-]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
116
|
+
raise ArgumentError, "invalid api_ext name: #{name.inspect}" if slug.empty?
|
|
117
|
+
|
|
118
|
+
target_dir = File.join(dir, slug)
|
|
119
|
+
path = File.join(target_dir, "handler.rb")
|
|
120
|
+
raise ArgumentError, "api_ext already exists: #{path}" if File.exist?(path)
|
|
121
|
+
|
|
122
|
+
FileUtils.mkdir_p(target_dir)
|
|
123
|
+
File.write(path, skeleton(slug))
|
|
124
|
+
path
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private def skeleton(slug)
|
|
128
|
+
const = slug.split(/[-_]/).map(&:capitalize).join + "Ext"
|
|
129
|
+
<<~RUBY
|
|
130
|
+
# frozen_string_literal: true
|
|
131
|
+
|
|
132
|
+
# Custom HTTP API extension mounted at /api/ext/#{slug}/
|
|
133
|
+
# Scaffolded by `clacky api_ext_new #{slug}` — fill in the routes you need.
|
|
134
|
+
class #{const} < Clacky::ApiExtension
|
|
135
|
+
get "/hello" do
|
|
136
|
+
json(message: "hello from #{slug}")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Examples — uncomment and adapt:
|
|
140
|
+
#
|
|
141
|
+
# post "/items" do
|
|
142
|
+
# body = json_body
|
|
143
|
+
# error!("name required", status: 422) unless body["name"]
|
|
144
|
+
# File.write(data_path("items.json"), body.to_json)
|
|
145
|
+
# json(ok: true)
|
|
146
|
+
# end
|
|
147
|
+
#
|
|
148
|
+
# get "/items/:id" do
|
|
149
|
+
# json(id: params[:id])
|
|
150
|
+
# end
|
|
151
|
+
end
|
|
152
|
+
RUBY
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
data/lib/clacky/cli.rb
CHANGED
|
@@ -268,8 +268,9 @@ module Clacky
|
|
|
268
268
|
config.save
|
|
269
269
|
end
|
|
270
270
|
|
|
271
|
-
# Refresh UI bar
|
|
271
|
+
# Refresh UI bar and model list
|
|
272
272
|
ui_controller.config[:model] = config.model_name
|
|
273
|
+
ui_controller.available_models = config.model_names
|
|
273
274
|
ui_controller.update_sessionbar(
|
|
274
275
|
tasks: agent.total_tasks,
|
|
275
276
|
cost: agent.total_cost
|
|
@@ -836,11 +837,12 @@ module Clacky
|
|
|
836
837
|
say "Error: Rich UI requires Ruby >= 2.6. Use --ui ui2 on Ruby #{RUBY_VERSION}.", :red
|
|
837
838
|
exit 1
|
|
838
839
|
end
|
|
839
|
-
require_relative "
|
|
840
|
+
require_relative "rich_ui"
|
|
840
841
|
RichUIController.new(
|
|
841
842
|
working_dir: working_dir,
|
|
842
843
|
mode: agent_config.permission_mode.to_s,
|
|
843
844
|
model: agent_config.model_name,
|
|
845
|
+
model_names: agent_config.model_names,
|
|
844
846
|
theme: options[:theme]
|
|
845
847
|
)
|
|
846
848
|
else
|
|
@@ -901,6 +903,21 @@ module Clacky
|
|
|
901
903
|
agent_config.permission_mode = new_mode.to_sym
|
|
902
904
|
end
|
|
903
905
|
|
|
906
|
+
# Set up model switch handler (from /model slash command)
|
|
907
|
+
ui_controller.on_model_switch do |model, persist|
|
|
908
|
+
next unless agent_config.switch_model_by_name(model)
|
|
909
|
+
|
|
910
|
+
id = agent_config.current_model_id
|
|
911
|
+
agent.switch_model_by_id(id)
|
|
912
|
+
if persist
|
|
913
|
+
agent_config.set_default_model_by_id(id)
|
|
914
|
+
agent_config.save
|
|
915
|
+
ui_controller.show_success("Model switched to #{model} (saved)")
|
|
916
|
+
else
|
|
917
|
+
ui_controller.show_success("Model switched to #{model} (session only)")
|
|
918
|
+
end
|
|
919
|
+
end
|
|
920
|
+
|
|
904
921
|
# Set up time machine handler (ESC key)
|
|
905
922
|
ui_controller.on_time_machine do
|
|
906
923
|
handle_time_machine_command(ui_controller, agent, session_manager)
|
|
@@ -921,6 +938,16 @@ module Clacky
|
|
|
921
938
|
end
|
|
922
939
|
|
|
923
940
|
if (not current_task_thread&.alive?) && input_was_empty
|
|
941
|
+
# Rich UI: require double-tap Ctrl+C to exit. When the user
|
|
942
|
+
# just copied terminal-native text selection, the viewport
|
|
943
|
+
# has no knowledge of the selection, yet Ctrl+C must not exit.
|
|
944
|
+
# First press only sets the warning; second press exits.
|
|
945
|
+
if ui_controller.respond_to?(:ctrl_c_warning) && !ui_controller.ctrl_c_warning
|
|
946
|
+
ui_controller.instance_variable_set(:@ctrl_c_warning, "Press Ctrl+C again to exit")
|
|
947
|
+
ui_controller.set_input_tips("Press Ctrl+C again to exit.", type: :info)
|
|
948
|
+
next
|
|
949
|
+
end
|
|
950
|
+
|
|
924
951
|
# Save final session state before exit
|
|
925
952
|
if session_manager && agent.total_tasks > 0
|
|
926
953
|
session_data = agent.to_session_data(status: :exited)
|
|
@@ -1038,7 +1065,13 @@ module Clacky
|
|
|
1038
1065
|
# Update session bar with agent's cumulative stats
|
|
1039
1066
|
ui_controller.update_sessionbar(tasks: agent.total_tasks, cost: agent.total_cost)
|
|
1040
1067
|
rescue Clacky::AgentInterrupted, StandardError => e
|
|
1041
|
-
|
|
1068
|
+
begin
|
|
1069
|
+
handle_agent_exception(ui_controller, agent, session_manager, e)
|
|
1070
|
+
rescue StandardError => ex
|
|
1071
|
+
# If handle_agent_exception itself raises (e.g. UI in bad state),
|
|
1072
|
+
# prevent the thread from dying with an unhandled exception.
|
|
1073
|
+
$stderr.puts "[cli] handle_agent_exception failed: #{ex.class}: #{ex.message}"
|
|
1074
|
+
end
|
|
1042
1075
|
ensure
|
|
1043
1076
|
current_task_thread = nil
|
|
1044
1077
|
# Start idle timer after agent completes
|
|
@@ -1181,6 +1214,63 @@ module Clacky
|
|
|
1181
1214
|
exit 1 if result.skipped.any?
|
|
1182
1215
|
end
|
|
1183
1216
|
|
|
1217
|
+
desc "api_ext_new NAME", "Scaffold a custom HTTP API extension at ~/.clacky/api_ext/NAME/"
|
|
1218
|
+
long_desc <<-LONGDESC
|
|
1219
|
+
Generate a ready-to-edit HTTP API extension skeleton. The skeleton mounts
|
|
1220
|
+
a sample route under /api/ext/NAME/ — fill in your routes, then verify with
|
|
1221
|
+
`clacky api_ext_verify`.
|
|
1222
|
+
|
|
1223
|
+
Examples:
|
|
1224
|
+
$ clacky api_ext_new my-dashboard
|
|
1225
|
+
LONGDESC
|
|
1226
|
+
def api_ext_new(name)
|
|
1227
|
+
path = Clacky::ApiExtensionLoader.scaffold(name)
|
|
1228
|
+
puts "Created api extension: #{path}"
|
|
1229
|
+
puts "Edit the routes, then run: clacky api_ext_verify"
|
|
1230
|
+
rescue ArgumentError => e
|
|
1231
|
+
warn "Error: #{e.message}"
|
|
1232
|
+
exit 1
|
|
1233
|
+
end
|
|
1234
|
+
|
|
1235
|
+
desc "api_ext_verify", "Load user API extensions and report which are valid"
|
|
1236
|
+
def api_ext_verify
|
|
1237
|
+
result = Clacky::ApiExtensionLoader.load_all
|
|
1238
|
+
|
|
1239
|
+
if result.loaded.empty? && result.skipped.empty?
|
|
1240
|
+
puts "No api extensions found in ~/.clacky/api_ext/"
|
|
1241
|
+
return
|
|
1242
|
+
end
|
|
1243
|
+
|
|
1244
|
+
result.loaded.each do |id|
|
|
1245
|
+
klass = Clacky::ApiExtension.registry[id]
|
|
1246
|
+
public_count = klass.public_paths.size
|
|
1247
|
+
suffix = public_count > 0 ? " (#{public_count} public)" : ""
|
|
1248
|
+
puts "[OK] #{id} — #{klass.routes.size} route(s)#{suffix}"
|
|
1249
|
+
end
|
|
1250
|
+
result.skipped.each { |(n, reason)| puts "[SKIP] #{n} — #{reason}" }
|
|
1251
|
+
exit 1 if result.skipped.any?
|
|
1252
|
+
end
|
|
1253
|
+
|
|
1254
|
+
desc "api_ext_list", "List loaded API extensions and their routes"
|
|
1255
|
+
def api_ext_list
|
|
1256
|
+
Clacky::ApiExtensionLoader.load_all if Clacky::ApiExtension.registry.empty?
|
|
1257
|
+
|
|
1258
|
+
if Clacky::ApiExtension.registry.empty?
|
|
1259
|
+
puts "No api extensions loaded."
|
|
1260
|
+
return
|
|
1261
|
+
end
|
|
1262
|
+
|
|
1263
|
+
Clacky::ApiExtension.registry.each do |id, klass|
|
|
1264
|
+
public_tag = klass.public_paths.any? ? " (public)" : ""
|
|
1265
|
+
puts "#{id}#{public_tag}"
|
|
1266
|
+
klass.routes.each do |route|
|
|
1267
|
+
full_path = "/api/ext/#{id}#{route.pattern}".chomp("/")
|
|
1268
|
+
full_path = "/api/ext/#{id}/" if full_path == "/api/ext/#{id}"
|
|
1269
|
+
puts " #{route.method.to_s.upcase.ljust(6)} #{full_path}"
|
|
1270
|
+
end
|
|
1271
|
+
end
|
|
1272
|
+
end
|
|
1273
|
+
|
|
1184
1274
|
desc "billing", "Show billing summary and usage statistics"
|
|
1185
1275
|
long_desc <<-LONGDESC
|
|
1186
1276
|
Display billing summary with token usage and cost breakdown.
|
data/lib/clacky/client.rb
CHANGED
|
@@ -515,6 +515,12 @@ module Clacky
|
|
|
515
515
|
end
|
|
516
516
|
end
|
|
517
517
|
|
|
518
|
+
def reset_connections!
|
|
519
|
+
@bedrock_connection = nil
|
|
520
|
+
@openai_connection = nil
|
|
521
|
+
@anthropic_connection = nil
|
|
522
|
+
end
|
|
523
|
+
|
|
518
524
|
def bedrock_connection
|
|
519
525
|
current_epoch = Clacky::ProxyConfig.epoch
|
|
520
526
|
if @bedrock_connection.nil? ||
|
|
@@ -598,11 +604,24 @@ module Clacky
|
|
|
598
604
|
return { success: true, status: response.status } if response.status == 200
|
|
599
605
|
|
|
600
606
|
error_body = JSON.parse(response.body) rescue nil
|
|
607
|
+
error_code = extract_error_code(error_body)
|
|
608
|
+
|
|
609
|
+
translated = case response.status
|
|
610
|
+
when 402 then I18n.t("llm.error.insufficient_credit")
|
|
611
|
+
when 400 then I18n.t("llm.error.rate_limit_400")
|
|
612
|
+
when 401 then I18n.t("llm.error.invalid_api_key")
|
|
613
|
+
when 403 then I18n.t("llm.error.403.#{error_code || "default"}")
|
|
614
|
+
when 404 then I18n.t("llm.error.endpoint_not_found")
|
|
615
|
+
when 429 then I18n.t("llm.error.rate_limit_429")
|
|
616
|
+
when 500..599 then I18n.t("llm.error.server_error", status: response.status)
|
|
617
|
+
else extract_error_message(error_body, response.body)
|
|
618
|
+
end
|
|
619
|
+
|
|
601
620
|
{
|
|
602
621
|
success: false,
|
|
603
622
|
status: response.status,
|
|
604
|
-
error:
|
|
605
|
-
error_code:
|
|
623
|
+
error: translated,
|
|
624
|
+
error_code: error_code
|
|
606
625
|
}
|
|
607
626
|
end
|
|
608
627
|
|
|
@@ -620,7 +639,7 @@ module Clacky
|
|
|
620
639
|
|
|
621
640
|
if error_code == "insufficient_credit" || response.status == 402
|
|
622
641
|
raise InsufficientCreditError.new(
|
|
623
|
-
"[LLM]
|
|
642
|
+
"[LLM] #{I18n.t("llm.error.insufficient_credit")}",
|
|
624
643
|
error_code: "insufficient_credit",
|
|
625
644
|
provider_id: @provider_id
|
|
626
645
|
)
|
|
@@ -632,19 +651,25 @@ module Clacky
|
|
|
632
651
|
# However, some proxy/relay providers do — so we inspect the message first.
|
|
633
652
|
# Also, Bedrock returns ThrottlingException as 400 instead of 429.
|
|
634
653
|
if error_message.match?(/ThrottlingException|unavailable|quota/i)
|
|
635
|
-
|
|
636
|
-
raise RetryableError, "[LLM] Rate limit or service issue: #{error_message}#{hint}"
|
|
654
|
+
raise RetryableError, "[LLM] #{I18n.t("llm.error.rate_limit_400")}"
|
|
637
655
|
end
|
|
638
656
|
|
|
639
657
|
# True bad request — our message was malformed. Roll back history so the
|
|
640
658
|
# broken message is not replayed on the next user turn.
|
|
641
|
-
raise BadRequestError
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
when
|
|
646
|
-
when
|
|
647
|
-
|
|
659
|
+
raise BadRequestError.new(
|
|
660
|
+
"[LLM] Client request error: #{error_message}",
|
|
661
|
+
display_message: "[LLM] #{I18n.t("llm.error.bad_request")}"
|
|
662
|
+
)
|
|
663
|
+
when 401 then raise AgentError, "[LLM] #{I18n.t("llm.error.invalid_api_key")}"
|
|
664
|
+
when 403
|
|
665
|
+
i18n_key = "llm.error.403.#{error_code}"
|
|
666
|
+
translated = I18n.t(i18n_key)
|
|
667
|
+
translated = I18n.t("llm.error.403.default") if translated == i18n_key
|
|
668
|
+
raise AgentError, "[LLM] #{translated}"
|
|
669
|
+
when 404 then raise AgentError, "[LLM] #{I18n.t("llm.error.endpoint_not_found")}"
|
|
670
|
+
when 429 then raise RetryableError, "[LLM] #{I18n.t("llm.error.rate_limit_429")}"
|
|
671
|
+
when 500..599 then raise RetryableError, "[LLM] #{I18n.t("llm.error.server_error", status: response.status)}"
|
|
672
|
+
else raise AgentError, "[LLM] #{I18n.t("llm.error.unexpected", status: response.status)}"
|
|
648
673
|
end
|
|
649
674
|
end
|
|
650
675
|
|
|
@@ -652,7 +677,7 @@ module Clacky
|
|
|
652
677
|
def check_html_response(response)
|
|
653
678
|
body = response.body.to_s.lstrip
|
|
654
679
|
if body.start_with?("<!DOCTYPE", "<!doctype", "<html", "<HTML")
|
|
655
|
-
raise RetryableError, "[LLM]
|
|
680
|
+
raise RetryableError, "[LLM] #{I18n.t("llm.error.html_response")}"
|
|
656
681
|
end
|
|
657
682
|
end
|
|
658
683
|
|
|
@@ -196,6 +196,6 @@
|
|
|
196
196
|
}, {
|
|
197
197
|
panel: "git",
|
|
198
198
|
order: 10,
|
|
199
|
-
tab: { id: "changes", label: (
|
|
199
|
+
tab: { id: "changes", label: () => t("changes.tab") },
|
|
200
200
|
});
|
|
201
201
|
})();
|