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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/docs/rich_ui_guide.md +277 -0
  4. data/docs/rich_ui_refactor_plan.md +396 -0
  5. data/lib/clacky/agent/llm_caller.rb +10 -4
  6. data/lib/clacky/agent/session_serializer.rb +3 -2
  7. data/lib/clacky/agent.rb +3 -2
  8. data/lib/clacky/agent_config.rb +2 -14
  9. data/lib/clacky/api_extension.rb +262 -0
  10. data/lib/clacky/api_extension_loader.rb +156 -0
  11. data/lib/clacky/cli.rb +93 -3
  12. data/lib/clacky/client.rb +38 -13
  13. data/lib/clacky/default_agents/_panels/git/panel.js +1 -1
  14. data/lib/clacky/default_agents/_panels/time_machine/panel.js +1 -1
  15. data/lib/clacky/default_skills/media-gen/SKILL.md +9 -6
  16. data/lib/clacky/idle_compression_timer.rb +3 -1
  17. data/lib/clacky/locales/en.rb +26 -0
  18. data/lib/clacky/locales/i18n.rb +26 -0
  19. data/lib/clacky/locales/zh.rb +26 -0
  20. data/lib/clacky/rich_ui/components/base_component.rb +50 -0
  21. data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
  22. data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
  23. data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
  24. data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
  25. data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
  26. data/lib/clacky/rich_ui/components/status_view.rb +58 -0
  27. data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
  28. data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
  29. data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
  30. data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
  31. data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
  32. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
  33. data/lib/clacky/rich_ui/view_renderer.rb +291 -0
  34. data/lib/clacky/rich_ui.rb +57 -0
  35. data/lib/clacky/rich_ui_controller.rb +3 -1549
  36. data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
  37. data/lib/clacky/server/http_server.rb +150 -103
  38. data/lib/clacky/server/session_registry.rb +1 -1
  39. data/lib/clacky/shell_hook_loader.rb +1 -1
  40. data/lib/clacky/tools/edit.rb +14 -2
  41. data/lib/clacky/ui2/ui_controller.rb +7 -0
  42. data/lib/clacky/version.rb +1 -1
  43. data/lib/clacky/web/app.css +56 -59
  44. data/lib/clacky/web/app.js +65 -7
  45. data/lib/clacky/web/components/onboard.js +18 -2
  46. data/lib/clacky/web/core/aside.js +8 -3
  47. data/lib/clacky/web/core/ext.js +1 -1
  48. data/lib/clacky/web/features/skills/store.js +30 -2
  49. data/lib/clacky/web/features/skills/view.js +32 -1
  50. data/lib/clacky/web/features/workspace/view.js +1 -1
  51. data/lib/clacky/web/i18n.js +32 -20
  52. data/lib/clacky/web/index.html +9 -17
  53. data/lib/clacky/web/sessions.js +286 -28
  54. data/lib/clacky/web/settings.js +109 -111
  55. data/lib/clacky/web/ws-dispatcher.js +7 -3
  56. data/lib/clacky.rb +17 -2
  57. metadata +38 -2
  58. 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 "rich_ui_controller"
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
- handle_agent_exception(ui_controller, agent, session_manager, e)
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: extract_error_message(error_body, response.body),
605
- error_code: extract_error_code(error_body)
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] Insufficient credit: #{error_message}",
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
- hint = error_message.match?(/quota/i) ? " (possibly out of credits)" : ""
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, "[LLM] Client request error: #{error_message}"
642
- when 401 then raise AgentError, "[LLM] Invalid API key"
643
- when 403 then raise AgentError, "[LLM] Access denied: #{error_message}"
644
- when 404 then raise AgentError, "[LLM] API endpoint not found: #{error_message}"
645
- when 429 then raise RetryableError, "[LLM] Rate limit exceeded, please wait a moment"
646
- when 500..599 then raise RetryableError, "[LLM] Service temporarily unavailable (#{response.status}), retrying..."
647
- else raise AgentError, "[LLM] Unexpected error (#{response.status}): #{error_message}"
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] Service temporarily unavailable (received HTML error page), retrying..."
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: (typeof I18n !== "undefined" ? (I18n.t("changes.tab") !== "changes.tab" ? I18n.t("changes.tab") : "Git 管理") : "Git 管理") },
199
+ tab: { id: "changes", label: () => t("changes.tab") },
200
200
  });
201
201
  })();
@@ -635,6 +635,6 @@
635
635
  }, {
636
636
  panel: "time_machine",
637
637
  order: 20,
638
- tab: { id: "tm", label: t("tm.tab", "时光机") },
638
+ tab: { id: "tm", label: () => t("tm.tab") },
639
639
  });
640
640
  })();