openclacky 1.0.0 → 1.0.2

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/README.md +87 -53
  4. data/lib/clacky/agent/cost_tracker.rb +19 -2
  5. data/lib/clacky/agent/llm_caller.rb +218 -0
  6. data/lib/clacky/agent/message_compressor_helper.rb +32 -2
  7. data/lib/clacky/agent.rb +54 -22
  8. data/lib/clacky/client.rb +44 -5
  9. data/lib/clacky/default_parsers/pdf_parser.rb +58 -17
  10. data/lib/clacky/default_parsers/pdf_parser_ocr.py +103 -0
  11. data/lib/clacky/default_parsers/pdf_parser_plumber.py +62 -0
  12. data/lib/clacky/default_skills/deploy/SKILL.md +201 -77
  13. data/lib/clacky/default_skills/new/SKILL.md +3 -114
  14. data/lib/clacky/default_skills/onboard/SKILL.md +349 -133
  15. data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +371 -0
  16. data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +175 -0
  17. data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +59 -26
  18. data/lib/clacky/message_format/anthropic.rb +72 -8
  19. data/lib/clacky/message_format/bedrock.rb +6 -3
  20. data/lib/clacky/providers.rb +146 -3
  21. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +14 -0
  22. data/lib/clacky/server/channel/adapters/feishu/bot.rb +10 -0
  23. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +1 -0
  24. data/lib/clacky/server/channel/channel_manager.rb +12 -4
  25. data/lib/clacky/server/channel/channel_ui_controller.rb +8 -2
  26. data/lib/clacky/server/http_server.rb +746 -13
  27. data/lib/clacky/server/session_registry.rb +55 -24
  28. data/lib/clacky/skill.rb +10 -9
  29. data/lib/clacky/skill_loader.rb +23 -11
  30. data/lib/clacky/tools/file_reader.rb +232 -127
  31. data/lib/clacky/tools/security.rb +42 -64
  32. data/lib/clacky/tools/terminal/persistent_session.rb +15 -4
  33. data/lib/clacky/tools/terminal/safe_rm.sh +106 -0
  34. data/lib/clacky/tools/terminal/session_manager.rb +8 -3
  35. data/lib/clacky/tools/terminal.rb +263 -16
  36. data/lib/clacky/ui2/layout_manager.rb +8 -1
  37. data/lib/clacky/ui2/output_buffer.rb +83 -23
  38. data/lib/clacky/ui2/ui_controller.rb +74 -7
  39. data/lib/clacky/utils/file_processor.rb +14 -40
  40. data/lib/clacky/utils/model_pricing.rb +215 -0
  41. data/lib/clacky/utils/parser_manager.rb +70 -6
  42. data/lib/clacky/utils/string_matcher.rb +23 -1
  43. data/lib/clacky/version.rb +1 -1
  44. data/lib/clacky/web/app.css +673 -9
  45. data/lib/clacky/web/app.js +40 -1608
  46. data/lib/clacky/web/i18n.js +209 -0
  47. data/lib/clacky/web/index.html +166 -2
  48. data/lib/clacky/web/onboard.js +77 -1
  49. data/lib/clacky/web/profile.js +442 -0
  50. data/lib/clacky/web/sessions.js +1034 -2
  51. data/lib/clacky/web/settings.js +127 -6
  52. data/lib/clacky/web/sidebar.js +39 -0
  53. data/lib/clacky/web/skills.js +460 -0
  54. data/lib/clacky/web/trash.js +343 -0
  55. data/lib/clacky/web/ws-dispatcher.js +255 -0
  56. data/lib/clacky.rb +5 -3
  57. metadata +16 -17
  58. data/lib/clacky/clacky_auth_client.rb +0 -152
  59. data/lib/clacky/clacky_cloud_config.rb +0 -123
  60. data/lib/clacky/cloud_project_client.rb +0 -169
  61. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +0 -1377
  62. data/lib/clacky/default_skills/deploy/tools/check_health.rb +0 -116
  63. data/lib/clacky/default_skills/deploy/tools/create_database_service.rb +0 -341
  64. data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +0 -99
  65. data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +0 -77
  66. data/lib/clacky/default_skills/deploy/tools/list_services.rb +0 -67
  67. data/lib/clacky/default_skills/deploy/tools/report_deploy_status.rb +0 -67
  68. data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +0 -189
  69. data/lib/clacky/default_skills/new/scripts/cloud_project_init.sh +0 -74
  70. data/lib/clacky/deploy_api_client.rb +0 -484
@@ -1,484 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "faraday"
4
- require "json"
5
-
6
- module Clacky
7
- # DeployApiClient - Encapsulates all Deploy API calls for the Railway deployment flow.
8
- #
9
- # All endpoints use Workspace API Key (clacky_ak_*) authentication, as the backend
10
- # supports both clacky_ak_* and clacky_dk_* for all deploy endpoints.
11
- #
12
- # Usage:
13
- # client = DeployApiClient.new("clacky_ak_xxx", base_url: "https://api.clacky.ai")
14
- #
15
- # # Check payment status
16
- # result = client.payment_status(project_id: "proj_abc")
17
- # # => { success: true, is_paid: true }
18
- #
19
- # # Create a deploy task
20
- # result = client.create_task(project_id: "proj_abc")
21
- # # => { success: true, deploy_task_id: "...", platform_token: "...", ... }
22
- #
23
- # # Poll services until DB is ready
24
- # result = client.services(deploy_task_id: "task_abc")
25
- # # => { success: true, services: [...], domain_name: "..." }
26
- #
27
- # # Poll deploy status
28
- # result = client.deploy_status(deploy_task_id: "task_abc")
29
- # # => { success: true, status: "SUCCESS", url: "https://..." }
30
- #
31
- # # Bind domain
32
- # result = client.bind_domain(deploy_task_id: "task_abc")
33
- # # => { success: true, domain: "my-app.example.com" }
34
- #
35
- # # Notify backend of deploy outcome
36
- # client.notify(project_id: "...", deploy_task_id: "...", status: "success")
37
- class DeployApiClient
38
- BASE_PATH = "/openclacky/v1"
39
- REQUEST_TIMEOUT = 30 # seconds for normal requests
40
- OPEN_TIMEOUT = 10 # seconds for connection
41
-
42
- def initialize(workspace_key, base_url:)
43
- @workspace_key = workspace_key.to_s.strip
44
- @base_url = base_url.to_s.strip.sub(%r{/+$}, "")
45
- end
46
-
47
- # -------------------------------------------------------------------------
48
- # Payment
49
- # -------------------------------------------------------------------------
50
-
51
- # Query whether the project has an active paid subscription.
52
- #
53
- # @param project_id [String]
54
- # @return [Hash] { success: true, is_paid: Boolean } or { success: false, error: String }
55
- def payment_status(project_id:)
56
- response = connection.get("#{BASE_PATH}/deploy/payment") do |req|
57
- req.params["project_id"] = project_id
58
- end
59
-
60
- return http_error(response) unless response.status == 200
61
-
62
- body = parse_body(response)
63
- return body_error(body) unless success_code?(body)
64
-
65
- data = body["data"] || {}
66
- { success: true, is_paid: data["is_paid"] == true }
67
- rescue Faraday::Error => e
68
- { success: false, error: "Network error: #{e.message}" }
69
- rescue => e
70
- { success: false, error: "Unexpected error: #{e.message}" }
71
- end
72
-
73
- # -------------------------------------------------------------------------
74
- # Regions
75
- # -------------------------------------------------------------------------
76
-
77
- # Fetch the list of supported deployment regions.
78
- #
79
- # @param project_id [String] Required by the backend to scope region availability.
80
- # @return [Hash] {
81
- # success: true,
82
- # regions: Array<Hash> # e.g. [{ "id" => "us-west", "name" => "US West", "label" => "US West (Oregon)" }, ...]
83
- # } or { success: false, error: String }
84
- def regions(project_id:)
85
- response = connection.get("#{BASE_PATH}/deploy/regions") do |req|
86
- req.params["project_id"] = project_id
87
- end
88
-
89
- return http_error(response) unless response.status == 200
90
-
91
- body = parse_body(response)
92
- return body_error(body) unless success_code?(body)
93
-
94
- data = body["data"] || {}
95
- list = data["regions"] || data || []
96
- list = list.values if list.is_a?(Hash)
97
- { success: true, regions: Array(list) }
98
- rescue Faraday::Error => e
99
- { success: false, error: "Network error: #{e.message}" }
100
- rescue => e
101
- { success: false, error: "Unexpected error: #{e.message}" }
102
- end
103
-
104
- # -------------------------------------------------------------------------
105
- # Create Deploy Task
106
- # -------------------------------------------------------------------------
107
-
108
- # Create a new deployment task on the backend. Returns Railway credentials.
109
- #
110
- # @param project_id [String]
111
- # @param backup_db [Boolean] default false
112
- # @param env_vars [Hash] extra env vars to pass at task creation time
113
- # @param region [String] optional Railway region slug
114
- # @return [Hash] {
115
- # success: true,
116
- # deploy_task_id: String,
117
- # deploy_service_id: String,
118
- # platform_token: String, # RAILWAY_TOKEN
119
- # platform_project_id: String,
120
- # platform_environment_id: String
121
- # }
122
- def create_task(project_id:, backup_db: false, env_vars: {}, region: nil)
123
- body_params = { project_id: project_id, backup_db: backup_db }
124
- body_params[:env_vars] = env_vars unless env_vars.empty?
125
- body_params[:region] = region if region
126
-
127
- response = connection.post("#{BASE_PATH}/deploy/create-task") do |req|
128
- req.headers["Content-Type"] = "application/json"
129
- req.body = JSON.generate(body_params)
130
- end
131
-
132
- return http_error(response) unless response.status == 200
133
-
134
- body = parse_body(response)
135
- return body_error(body) unless success_code?(body)
136
-
137
- data = body["data"] || {}
138
- {
139
- success: true,
140
- deploy_task_id: data["deploy_task_id"],
141
- deploy_service_id: data["deploy_service_id"],
142
- platform_token: data["platform_token"],
143
- platform_project_id: data["platform_project_id"],
144
- platform_environment_id: data["platform_environment_id"]
145
- }
146
- rescue Faraday::Error => e
147
- { success: false, error: "Network error: #{e.message}" }
148
- rescue => e
149
- { success: false, error: "Unexpected error: #{e.message}" }
150
- end
151
-
152
- # -------------------------------------------------------------------------
153
- # Services (poll for middleware readiness)
154
- # -------------------------------------------------------------------------
155
-
156
- # Query all services under a deploy task.
157
- # Used to wait for the PostgreSQL middleware to reach status SUCCESS
158
- # before injecting the DATABASE_URL reference.
159
- #
160
- # @param deploy_task_id [String]
161
- # @return [Hash] {
162
- # success: true,
163
- # services: Array<Hash>, # full service objects from API
164
- # domain_name: String, # assigned domain (may be empty on first call)
165
- # db_service: Hash | nil # first middleware service with status SUCCESS
166
- # }
167
- def services(deploy_task_id:)
168
- url = "#{BASE_PATH}/deploy/services?deploy_task_id=#{deploy_task_id}"
169
- puts " [DEBUG API] GET #{@base_url}#{url}"
170
-
171
- response = connection.get("#{BASE_PATH}/deploy/services") do |req|
172
- req.params["deploy_task_id"] = deploy_task_id
173
- end
174
-
175
- puts " [DEBUG API] Response status: #{response.status}"
176
-
177
- return http_error(response) unless response.status == 200
178
-
179
- body = parse_body(response)
180
- puts " [DEBUG API] Response body: #{body.inspect[0..500]}..." if body
181
-
182
- return body_error(body) unless success_code?(body)
183
-
184
- data = body["data"] || {}
185
- svcs = data["services"] || []
186
- domain = data["domain_name"].to_s
187
-
188
- # Debug: print detailed service info
189
- puts " [DEBUG] Total services returned: #{svcs.size}"
190
- svcs.each_with_index do |s, idx|
191
- puts " [DEBUG] Service[#{idx}]: name=#{s['service_name']}, type=#{s['type']}, status=#{s['status']}"
192
- if s["type"] == "middleware"
193
- env_vars = s["env_vars"] || {}
194
- puts " [DEBUG] - env_vars keys: #{env_vars.keys.join(', ')}"
195
- puts " [DEBUG] - has DATABASE_URL: #{env_vars.key?('DATABASE_URL')}"
196
- puts " [DEBUG] - has DATABASE_PUBLIC_URL: #{env_vars.key?('DATABASE_PUBLIC_URL')}"
197
- end
198
- end
199
-
200
- # Find first middleware (DB) that is fully provisioned
201
- db_svc = svcs.find do |s|
202
- s["type"] == "middleware" && s["status"]&.upcase == "SUCCESS"
203
- end
204
-
205
- puts " [DEBUG] db_svc found: #{!db_svc.nil?}"
206
- if db_svc
207
- puts " [DEBUG] - db_svc name: #{db_svc['service_name']}"
208
- puts " [DEBUG] - db_svc status: #{db_svc['status']}"
209
- end
210
-
211
- # middleware_support: { supported: Boolean, supported_types: Array }
212
- # When supported == false, no DB middleware will be provisioned by Clacky.
213
- # The deploy script uses this to skip the DB polling loop entirely.
214
- middleware_support = data["middleware_support"] || {}
215
- puts " [DEBUG] middleware_support: #{middleware_support.inspect}"
216
-
217
- # platform_bucket_credentials contains S3-compatible storage credentials.
218
- # Passed through so the deploy script can inject STORAGE_BUCKET_* env vars.
219
- bucket_credentials = data["platform_bucket_credentials"]
220
- bucket_name = data["platform_bucket_name"].to_s
221
-
222
- {
223
- success: true,
224
- services: svcs,
225
- domain_name: domain,
226
- db_service: db_svc,
227
- middleware_support: middleware_support,
228
- bucket_credentials: bucket_credentials,
229
- bucket_name: bucket_name
230
- }
231
- rescue Faraday::Error => e
232
- { success: false, error: "Network error: #{e.message}" }
233
- rescue => e
234
- { success: false, error: "Unexpected error: #{e.message}" }
235
- end
236
-
237
- # -------------------------------------------------------------------------
238
- # Deploy Status
239
- # -------------------------------------------------------------------------
240
-
241
- # Query the real-time deployment status for a task.
242
- #
243
- # @param deploy_task_id [String]
244
- # @return [Hash] {
245
- # success: true,
246
- # status: String, # SUCCESS / FAILED / CRASHED / DEPLOYING / WAITING
247
- # url: String
248
- # }
249
- def deploy_status(deploy_task_id:)
250
- response = connection.get("#{BASE_PATH}/deploy/status") do |req|
251
- req.params["deploy_task_id"] = deploy_task_id
252
- end
253
-
254
- return http_error(response) unless response.status == 200
255
-
256
- body = parse_body(response)
257
- return body_error(body) unless success_code?(body)
258
-
259
- data = body["data"] || {}
260
- {
261
- success: true,
262
- status: data["status"].to_s.upcase,
263
- url: data["url"].to_s,
264
- deploy_service_id: data["deploy_service_id"].to_s
265
- }
266
- rescue Faraday::Error => e
267
- { success: false, error: "Network error: #{e.message}" }
268
- rescue => e
269
- { success: false, error: "Unexpected error: #{e.message}" }
270
- end
271
-
272
- # -------------------------------------------------------------------------
273
- # Bind Domain
274
- # -------------------------------------------------------------------------
275
-
276
- # Bind a custom domain to the deploy task.
277
- #
278
- # @param deploy_task_id [String]
279
- # @return [Hash] { success: true, domain: String } or { success: false, error: String }
280
- def bind_domain(deploy_task_id:)
281
- response = connection.post("#{BASE_PATH}/deploy/bind-domain") do |req|
282
- req.headers["Content-Type"] = "application/json"
283
- req.body = JSON.generate({ deploy_task_id: deploy_task_id })
284
- end
285
-
286
- return http_error(response) unless response.status == 200
287
-
288
- body = parse_body(response)
289
- return body_error(body) unless success_code?(body)
290
-
291
- data = body["data"] || {}
292
- { success: true, domain: data["domain"].to_s }
293
- rescue Faraday::Error => e
294
- { success: false, error: "Network error: #{e.message}" }
295
- rescue => e
296
- { success: false, error: "Unexpected error: #{e.message}" }
297
- end
298
-
299
- # -------------------------------------------------------------------------
300
- # Build Logs
301
- # -------------------------------------------------------------------------
302
-
303
- # Fetch build logs for a deploy task (synchronous, not SSE).
304
- #
305
- # @param deploy_task_id [String]
306
- # @param service_id [String, nil] optional service ID filter
307
- # @param level [String] log level filter ("INFO", "ERROR", "WARN", etc.)
308
- # @param lines [Integer] maximum number of lines to return (default: 100)
309
- # @return [Hash] {
310
- # success: true,
311
- # logs: Array<Hash> # [{ "timestamp" => ..., "level" => "INFO", "message" => "..." }, ...]
312
- # } or { success: false, error: String }
313
- def build_logs(deploy_task_id:, service_id: nil, level: "INFO", lines: 100)
314
- body_params = {
315
- deploy_task_id: deploy_task_id,
316
- level: level,
317
- lines: lines
318
- }
319
- body_params[:service_id] = service_id if service_id
320
-
321
- response = connection.post("#{BASE_PATH}/tasks/logs") do |req|
322
- req.headers["Content-Type"] = "application/json"
323
- req.body = JSON.generate(body_params)
324
- end
325
-
326
- return http_error(response) unless response.status == 200
327
-
328
- body = parse_body(response)
329
- return body_error(body) unless success_code?(body)
330
-
331
- data = body["data"] || {}
332
- logs = data["logs"] || []
333
- { success: true, logs: logs }
334
- rescue Faraday::Error => e
335
- { success: false, error: "Network error: #{e.message}" }
336
- rescue => e
337
- { success: false, error: "Unexpected error: #{e.message}" }
338
- end
339
-
340
- # Stream build logs using SSE (Server-Sent Events).
341
- # This method yields each log line as it arrives.
342
- #
343
- # @param deploy_task_id [String]
344
- # @param service_id [String, nil] optional service ID filter
345
- # @param level [String] log level filter ("INFO", "ERROR", "WARN", etc.)
346
- # @yield [Hash] each log event: { "type" => "log", "timestamp" => ..., "message" => "..." }
347
- # @return [Hash] { success: true } or { success: false, error: String }
348
- def stream_build_logs(deploy_task_id:, service_id: nil, level: "INFO", &block)
349
- require "net/http"
350
- require "openssl"
351
-
352
- body_params = {
353
- deploy_task_id: deploy_task_id,
354
- level: level
355
- }
356
- body_params[:service_id] = service_id if service_id
357
-
358
- url = "#{@base_url}#{BASE_PATH}/tasks/stream/build-logs"
359
- uri = URI.parse(url)
360
-
361
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", verify_mode: OpenSSL::SSL::VERIFY_NONE) do |http|
362
- request = Net::HTTP::Post.new(uri.path)
363
- request["Authorization"] = "Bearer #{@workspace_key}"
364
- request["Accept"] = "text/event-stream"
365
- request["Content-Type"] = "application/json"
366
- request.body = JSON.generate(body_params)
367
-
368
- http.request(request) do |response|
369
- return { success: false, error: "HTTP #{response.code}: #{response.message}" } unless response.code.to_i == 200
370
-
371
- buffer = ""
372
- response.read_body do |chunk|
373
- buffer << chunk
374
- while (line_end = buffer.index("\n"))
375
- line = buffer.slice!(0..line_end).strip
376
- next if line.empty? || !line.start_with?("data:")
377
-
378
- json_str = line.sub(/^data:\s*/, "")
379
- begin
380
- event = JSON.parse(json_str)
381
- block.call(event) if block
382
- rescue JSON::ParserError
383
- # Ignore malformed JSON
384
- end
385
- end
386
- end
387
- end
388
- end
389
-
390
- { success: true }
391
- rescue => e
392
- { success: false, error: "Stream error: #{e.message}" }
393
- end
394
-
395
- # -------------------------------------------------------------------------
396
- # Notify
397
- # -------------------------------------------------------------------------
398
-
399
- # Notify the backend of the current deployment outcome.
400
- # Fire-and-forget — failures are logged but do not raise.
401
- #
402
- # @param project_id [String]
403
- # @param deploy_task_id [String]
404
- # @param deploy_service_id [String] optional
405
- # @param status [String] "deploying" | "success" | "failed"
406
- # @param message [String] optional description
407
- # @param target_port [Integer] default 3000
408
- # @return [Hash] { success: true } or { success: false, error: String }
409
- def notify(project_id:, deploy_task_id:, status:,
410
- deploy_service_id: nil, message: nil, target_port: nil)
411
- payload = {
412
- project_id: project_id,
413
- deploy_task_id: deploy_task_id,
414
- status: status
415
- }
416
- payload[:deploy_service_id] = deploy_service_id if deploy_service_id
417
- payload[:message] = message if message
418
- payload[:target_port] = target_port if target_port
419
-
420
- response = connection.post("#{BASE_PATH}/deploy/notify") do |req|
421
- req.headers["Content-Type"] = "application/json"
422
- req.body = JSON.generate(payload)
423
- end
424
-
425
- return http_error(response) unless response.status == 200
426
-
427
- body = parse_body(response)
428
- return body_error(body) unless success_code?(body)
429
-
430
- { success: true }
431
- rescue => e
432
- # Notify failures are non-fatal — log and move on
433
- warn "[deploy_api] notify failed: #{e.message}"
434
- { success: false, error: e.message }
435
- end
436
-
437
- # -------------------------------------------------------------------------
438
- # Private helpers
439
- # -------------------------------------------------------------------------
440
-
441
- private def connection
442
- @connection ||= Faraday.new(url: @base_url) do |f|
443
- f.options.timeout = REQUEST_TIMEOUT
444
- f.options.open_timeout = OPEN_TIMEOUT
445
- f.headers["Authorization"] = "Bearer #{@workspace_key}"
446
- f.headers["Accept"] = "application/json"
447
- # Disable SSL verification to avoid OpenSSL certificate path issues
448
- # on some macOS environments with system Ruby
449
- f.ssl.verify = false
450
- f.adapter Faraday.default_adapter
451
- end
452
- end
453
-
454
- private def parse_body(response)
455
- JSON.parse(response.body)
456
- rescue JSON::ParserError
457
- nil
458
- end
459
-
460
- private def success_code?(body)
461
- return false if body.nil?
462
-
463
- code = body["code"].to_i
464
- code == 0 || code == 200
465
- end
466
-
467
- private def http_error(response)
468
- msg = begin
469
- parsed = JSON.parse(response.body)
470
- parsed["message"] || parsed["msg"] || response.body.to_s[0, 200]
471
- rescue
472
- response.body.to_s[0, 200]
473
- end
474
- { success: false, error: "HTTP #{response.status}: #{msg}" }
475
- end
476
-
477
- private def body_error(body)
478
- return { success: false, error: "Invalid JSON response from API" } if body.nil?
479
-
480
- msg = body["message"] || body["msg"] || "Unknown API error (code: #{body["code"]})"
481
- { success: false, error: msg }
482
- end
483
- end
484
- end