openclacky 1.2.8 → 1.2.9

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.
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require_relative "base"
6
+
7
+ module Clacky
8
+ module Media
9
+ # OpenAI-compatible image generation provider.
10
+ #
11
+ # Talks to POST <base_url>/images/generations with the standard OpenAI
12
+ # request shape. Handles three providers under one class because they
13
+ # all expose the same endpoint: OpenAI, OpenRouter, and the openclacky
14
+ # platform gateway. Provider-specific quirks (model id naming, billing)
15
+ # live in PRESETS, not here.
16
+ class OpenAICompat < Base
17
+ ASPECT_TO_SIZE = {
18
+ "landscape" => "1536x1024",
19
+ "square" => "1024x1024",
20
+ "portrait" => "1024x1536"
21
+ }.freeze
22
+
23
+ DEFAULT_ASPECT = "landscape"
24
+
25
+ def generate_image(prompt:, aspect_ratio: DEFAULT_ASPECT, output_dir: nil, n: 1, **_kwargs)
26
+ provider_id = Clacky::Providers.find_by_base_url(@base_url) || "custom"
27
+ aspect = ASPECT_TO_SIZE.key?(aspect_ratio) ? aspect_ratio : DEFAULT_ASPECT
28
+ size = ASPECT_TO_SIZE[aspect]
29
+
30
+ if prompt.to_s.strip.empty?
31
+ return error_response(
32
+ error: "Prompt is required and must be a non-empty string",
33
+ error_type: "invalid_argument",
34
+ provider: provider_id,
35
+ aspect_ratio: aspect
36
+ )
37
+ end
38
+
39
+ if @api_key.to_s.empty?
40
+ return error_response(
41
+ error: "api_key not configured for image model '#{@model}'",
42
+ error_type: "auth_required",
43
+ provider: provider_id,
44
+ prompt: prompt,
45
+ aspect_ratio: aspect
46
+ )
47
+ end
48
+
49
+ payload = { model: @model, n: n }
50
+ if gemini_family?(@model)
51
+ # Gemini image models (routed via openclacky / openrouter gateway)
52
+ # don't accept the OpenAI `size` parameter — they infer aspect from
53
+ # the prompt text. Embedding a hint keeps the user's aspect choice
54
+ # honoured without breaking the gateway request validator.
55
+ payload[:prompt] = "#{prompt}\n\n[aspect: #{aspect}]"
56
+ else
57
+ payload[:prompt] = prompt
58
+ payload[:size] = size
59
+ end
60
+
61
+ begin
62
+ response = connection.post("images/generations") do |req|
63
+ req.headers["Content-Type"] = "application/json"
64
+ req.headers["Authorization"] = "Bearer #{@api_key}"
65
+ req.body = JSON.generate(payload)
66
+ end
67
+ rescue Faraday::Error => e
68
+ return error_response(
69
+ error: "HTTP request failed: #{e.message}",
70
+ error_type: "network_error",
71
+ provider: provider_id,
72
+ prompt: prompt,
73
+ aspect_ratio: aspect
74
+ )
75
+ end
76
+
77
+ unless response.success?
78
+ return error_response(
79
+ error: "Upstream #{response.status}: #{truncate(response.body, 500)}",
80
+ error_type: "api_error",
81
+ provider: provider_id,
82
+ prompt: prompt,
83
+ aspect_ratio: aspect
84
+ )
85
+ end
86
+
87
+ body = parse_json(response.body)
88
+ return error_response(
89
+ error: "Invalid JSON response from upstream",
90
+ error_type: "invalid_response",
91
+ provider: provider_id,
92
+ prompt: prompt,
93
+ aspect_ratio: aspect
94
+ ) unless body.is_a?(Hash)
95
+
96
+ data = body["data"] || []
97
+ first = data.first
98
+ if first.nil?
99
+ return error_response(
100
+ error: "Upstream returned no image data",
101
+ error_type: "empty_response",
102
+ provider: provider_id,
103
+ prompt: prompt,
104
+ aspect_ratio: aspect
105
+ )
106
+ end
107
+
108
+ image_ref =
109
+ if first["b64_json"]
110
+ save_b64_image(first["b64_json"], output_dir: output_dir || Dir.pwd, prefix: "img")
111
+ elsif first["url"]
112
+ first["url"]
113
+ end
114
+
115
+ if image_ref.nil?
116
+ return error_response(
117
+ error: "Response contained neither b64_json nor url",
118
+ error_type: "empty_response",
119
+ provider: provider_id,
120
+ prompt: prompt,
121
+ aspect_ratio: aspect
122
+ )
123
+ end
124
+
125
+ success_response(
126
+ image: image_ref,
127
+ prompt: prompt,
128
+ aspect_ratio: aspect,
129
+ provider: provider_id,
130
+ extra: {
131
+ "size" => size,
132
+ "usage" => body["usage"],
133
+ "cost_usd" => body["cost_usd"]
134
+ }.compact
135
+ )
136
+ end
137
+
138
+ private def connection
139
+ Faraday.new(url: normalized_base_url) do |f|
140
+ f.options.timeout = 240
141
+ f.options.open_timeout = 10
142
+ end
143
+ end
144
+
145
+ private def gemini_family?(model_name)
146
+ model_name.to_s.match?(/gemini|imagen/i)
147
+ end
148
+
149
+ # base_url is taken verbatim from PRESETS (each provider already
150
+ # includes the API version segment when needed). We only ensure a
151
+ # trailing slash so Faraday's relative-path join behaves.
152
+ private def normalized_base_url
153
+ "#{@base_url.to_s.chomp("/")}/"
154
+ end
155
+
156
+ private def parse_json(body)
157
+ JSON.parse(body)
158
+ rescue JSON::ParserError
159
+ nil
160
+ end
161
+
162
+ private def truncate(str, max)
163
+ s = str.to_s
164
+ s.length > max ? "#{s[0, max]}..." : s
165
+ end
166
+ end
167
+ end
168
+ end
@@ -41,6 +41,17 @@ module Clacky
41
41
  "dsk-deepseek-v4-flash",
42
42
  "or-gemini-3-1-pro"
43
43
  ],
44
+ # Image generation models served by the openclacky platform
45
+ # gateway. The gateway exposes a standard OpenAI-compatible
46
+ # /v1/images/generations endpoint, so the same OpenAICompat
47
+ # provider class handles them. `or-` prefix mirrors the chat
48
+ # model naming — these are routed through the OpenRouter
49
+ # backend by the platform.
50
+ "image_models" => [
51
+ "or-gemini-3-pro-image",
52
+ "or-gpt-image-2"
53
+ ],
54
+ "default_image_model" => "or-gpt-image-2",
44
55
  # Provider-level default: the Claude family served here is vision-capable.
45
56
  "capabilities" => { "vision" => true }.freeze,
46
57
  # Model-level overrides: DeepSeek models routed through this provider
@@ -123,6 +134,13 @@ module Clacky
123
134
  /\Aanthropic\// => "anthropic-messages",
124
135
  /\Aclaude[-.]/ => "anthropic-messages"
125
136
  }.freeze,
137
+ # Image generation via OpenRouter is currently routed through the
138
+ # openclacky platform gateway (see "openclacky" provider above) which
139
+ # handles the OpenRouter chat-completions + modalities translation.
140
+ # Direct OpenRouter image config is not exposed here — leave empty
141
+ # until we ship a dedicated client-side adapter for that protocol.
142
+ "image_models" => [],
143
+ "default_image_model" => nil,
126
144
  "website_url" => "https://openrouter.ai/keys"
127
145
  }.freeze,
128
146
 
@@ -305,6 +323,12 @@ module Clacky
305
323
  "gpt-5.5" => "gpt-5.4-mini",
306
324
  "gpt-5.4" => "gpt-5.4-mini"
307
325
  },
326
+ # OpenAI's image generation model — same /v1/images/generations
327
+ # endpoint, so the OpenAICompat image provider handles it.
328
+ "image_models" => [
329
+ "gpt-image-2"
330
+ ],
331
+ "default_image_model" => "gpt-image-2",
308
332
  "website_url" => "https://platform.openai.com/api-keys"
309
333
  }.freeze,
310
334
 
@@ -342,6 +366,8 @@ module Clacky
342
366
 
343
367
  }.freeze
344
368
 
369
+ MEDIA_KINDS = %w[image video audio].freeze
370
+
345
371
  class << self
346
372
  # Check if a provider preset exists
347
373
  # @param provider_id [String] The provider identifier (e.g., "anthropic", "openrouter")
@@ -446,6 +472,62 @@ module Clacky
446
472
  preset&.dig("models") || []
447
473
  end
448
474
 
475
+ # Get available image generation models for a provider.
476
+ # Returns an empty array when the provider doesn't declare any —
477
+ # callers should treat that as "image generation not supported by this provider".
478
+ # @param provider_id [String] The provider identifier
479
+ # @return [Array<String>] List of image model names
480
+ def image_models(provider_id)
481
+ preset = PRESETS[provider_id]
482
+ preset&.dig("image_models") || []
483
+ end
484
+
485
+ # Video generation models — placeholder. No provider supports video
486
+ # via Clacky yet; once they do, declare "video_models" alongside
487
+ # "image_models" in the relevant PRESETS entry and this returns it.
488
+ def video_models(provider_id)
489
+ preset = PRESETS[provider_id]
490
+ preset&.dig("video_models") || []
491
+ end
492
+
493
+ # Audio generation models — same placeholder pattern as video_models.
494
+ def audio_models(provider_id)
495
+ preset = PRESETS[provider_id]
496
+ preset&.dig("audio_models") || []
497
+ end
498
+
499
+ # Unified entry for media model lookup by kind.
500
+ # @param provider_id [String]
501
+ # @param kind [String] one of "image" / "video" / "audio"
502
+ # @return [Array<String>]
503
+ def media_models(provider_id, kind)
504
+ case kind.to_s
505
+ when "image" then image_models(provider_id)
506
+ when "video" then video_models(provider_id)
507
+ when "audio" then audio_models(provider_id)
508
+ else []
509
+ end
510
+ end
511
+
512
+ # Default media model for a kind under a provider. Falls back to the
513
+ # first declared model when no explicit default is set in the preset.
514
+ # Used by AgentConfig#derive_media_models! to pick which model to
515
+ # surface when the user is on "auto" mode.
516
+ def default_media_model(provider_id, kind)
517
+ preset = PRESETS[provider_id]
518
+ return nil unless preset
519
+ explicit = preset["default_#{kind}_model"]
520
+ return explicit if explicit
521
+ media_models(provider_id, kind).first
522
+ end
523
+
524
+ # The set of media kinds Clacky knows about. Drives UI rendering and
525
+ # derivation loops — adding a new modality means listing it here plus
526
+ # adding the corresponding generator class.
527
+ def media_kinds
528
+ MEDIA_KINDS
529
+ end
530
+
449
531
  # Get the lite model for a provider.
450
532
  # @param provider_id [String] The provider identifier
451
533
  # @param primary_model [String, nil] The currently-selected primary model name.
@@ -370,6 +370,11 @@ module Clacky
370
370
  30
371
371
  elsif path.end_with?("/benchmark")
372
372
  20
373
+ elsif path == "/api/media/image"
374
+ # Image generation routes through OpenRouter (chat completions
375
+ # with modalities:["image"]); end-to-end latency is commonly
376
+ # 20-60s and can exceed 2 minutes for or-gpt-image-2 under load.
377
+ 300
373
378
  else
374
379
  10
375
380
  end
@@ -399,6 +404,7 @@ module Clacky
399
404
  when ["PATCH", "/api/config/settings"] then api_update_settings(req, res)
400
405
  when ["POST", "/api/config/models"] then api_add_model(req, res)
401
406
  when ["POST", "/api/config/test"] then api_test_config(req, res)
407
+ when ["GET", "/api/config/media"] then api_get_media_config(res)
402
408
  when ["GET", "/api/providers"] then api_list_providers(res)
403
409
  when ["GET", "/api/onboard/status"] then api_onboard_status(res)
404
410
  when ["GET", "/api/browser/status"] then api_browser_status(res)
@@ -430,6 +436,8 @@ module Clacky
430
436
  when ["POST", "/api/upload"] then api_upload_file(req, res)
431
437
  when ["POST", "/api/file-action"] then api_file_action(req, res)
432
438
  when ["GET", "/api/local-image"] then api_serve_local_image(req, res)
439
+ when ["POST", "/api/media/image"] then api_media_image(req, res)
440
+ when ["GET", "/api/media/types"] then api_media_types(res)
433
441
  when ["GET", "/api/version"] then api_get_version(res)
434
442
  when ["POST", "/api/version/upgrade"] then api_upgrade_version(req, res)
435
443
  when ["POST", "/api/restart"] then api_restart(req, res)
@@ -523,6 +531,9 @@ module Clacky
523
531
  elsif method == "DELETE" && path.match?(%r{^/api/config/models/[^/]+$})
524
532
  id = path.sub("/api/config/models/", "")
525
533
  api_delete_model(id, res)
534
+ elsif method == "PATCH" && path.match?(%r{^/api/config/media/(image|video|audio)$})
535
+ kind = path.sub("/api/config/media/", "")
536
+ api_update_media_config(kind, req, res)
526
537
  elsif method == "POST" && path.match?(%r{^/api/cron-tasks/[^/]+/run$})
527
538
  name = URI.decode_www_form_component(path.sub("/api/cron-tasks/", "").sub("/run", ""))
528
539
  api_run_cron_task(name, res)
@@ -697,6 +708,158 @@ module Clacky
697
708
  json_response(res, 500, { ok: false, error: e.message })
698
709
  end
699
710
 
711
+ # POST /api/media/image
712
+ # Body: { "prompt": "...", "aspect_ratio": "landscape|square|portrait",
713
+ # "output_dir": "<absolute path, optional>" }
714
+ # Routes to the model configured with type=image in agent_config.
715
+ def api_media_image(req, res)
716
+ body = parse_json_body(req)
717
+ return json_response(res, 400, { error: "Invalid JSON" }) unless body
718
+
719
+ prompt = body["prompt"].to_s
720
+ if prompt.strip.empty?
721
+ return json_response(res, 422, { error: "prompt is required" })
722
+ end
723
+
724
+ aspect_ratio = body["aspect_ratio"].to_s
725
+ aspect_ratio = "landscape" if aspect_ratio.empty?
726
+ output_dir = body["output_dir"].to_s
727
+ output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
728
+
729
+ result = Clacky::Media::Generator.new(@agent_config).generate_image(
730
+ prompt: prompt,
731
+ aspect_ratio: aspect_ratio,
732
+ output_dir: output_dir
733
+ )
734
+ if result["success"]
735
+ log_media_usage(result, prompt: prompt)
736
+ end
737
+ status = result["success"] ? 200 : 422
738
+ json_response(res, status, result)
739
+ rescue StandardError => e
740
+ json_response(res, 500, { error: e.message })
741
+ end
742
+
743
+ private def log_media_usage(result, prompt:)
744
+ usage = result["usage"]
745
+ cost = result["cost_usd"]
746
+ return if usage.nil? && cost.nil?
747
+
748
+ parts = []
749
+ parts << "model=#{result["model"]}"
750
+ parts << "provider=#{result["provider"]}"
751
+ if usage.is_a?(Hash)
752
+ parts << "prompt_tokens=#{usage["prompt_tokens"]}"
753
+ parts << "completion_tokens=#{usage["completion_tokens"]}"
754
+ parts << "cache_read=#{usage["cache_read_tokens"]}" if usage["cache_read_tokens"].to_i > 0
755
+ parts << "cache_write=#{usage["cache_write_tokens"]}" if usage["cache_write_tokens"].to_i > 0
756
+ end
757
+ parts << format("cost_usd=%.6f", cost.to_f) if cost
758
+ parts << "prompt=#{prompt[0, 60].inspect}"
759
+ Clacky::Logger.info("[Media] image generated #{parts.join(" ")}")
760
+ end
761
+
762
+ # GET /api/media/types
763
+ # Returns which media types are configured in agent_config.models.
764
+ # Used by the media-gen skill to decide whether to surface generation
765
+ # capabilities to the user.
766
+ def api_media_types(res)
767
+ out = {}
768
+ Clacky::Providers::MEDIA_KINDS.each do |t|
769
+ state = @agent_config.media_state(t)
770
+ out[t] =
771
+ if state["configured"]
772
+ {
773
+ configured: true,
774
+ model: state["model"],
775
+ base_url: state["base_url"],
776
+ source: state["source"]
777
+ }
778
+ else
779
+ { configured: false, source: "off" }
780
+ end
781
+ end
782
+ json_response(res, 200, out)
783
+ end
784
+
785
+ # GET /api/config/media
786
+ # Used by the Settings UI to render the tri-state media controls.
787
+ # Per-kind payload mirrors AgentConfig#media_state.
788
+ def api_get_media_config(res)
789
+ out = {}
790
+ Clacky::Providers::MEDIA_KINDS.each do |t|
791
+ state = @agent_config.media_state(t)
792
+ entry = @agent_config.find_model_by_type(t)
793
+ out[t] = {
794
+ source: state["source"],
795
+ model: state["model"],
796
+ base_url: state["base_url"],
797
+ api_key_masked: entry ? mask_api_key(entry["api_key"]) : nil,
798
+ provider: state["provider"],
799
+ available: state["available"],
800
+ configured: state["configured"]
801
+ }
802
+ end
803
+
804
+ # Surface what the current default model can offer, even when the
805
+ # user is currently in "off" — the UI uses this to render the
806
+ # auto-mode preview ("Auto would use X").
807
+ default = @agent_config.find_model_by_type("default")
808
+ provider_id = default && Clacky::Providers.resolve_provider(
809
+ base_url: default["base_url"],
810
+ api_key: default["api_key"]
811
+ )
812
+ defaults = {}
813
+ Clacky::Providers::MEDIA_KINDS.each do |t|
814
+ defaults[t] = {
815
+ provider: provider_id,
816
+ model: provider_id ? Clacky::Providers.default_media_model(provider_id, t) : nil,
817
+ available: provider_id ? Clacky::Providers.media_models(provider_id, t) : []
818
+ }
819
+ end
820
+
821
+ json_response(res, 200, { media: out, default_provider: defaults })
822
+ end
823
+
824
+ # PATCH /api/config/media/:kind
825
+ # Body: { source: "off"|"auto"|"custom", model?, base_url?, api_key?,
826
+ # anthropic_format? }
827
+ # off / auto — remove any custom entry; "auto" lets the virtual
828
+ # derivation in AgentConfig#find_model_by_type take over.
829
+ # custom — replace any existing custom entry with the supplied fields.
830
+ def api_update_media_config(kind, req, res)
831
+ body = parse_json_body(req) || {}
832
+ source = body["source"].to_s
833
+ unless %w[off auto custom].include?(source)
834
+ return json_response(res, 422, { error: "invalid source" })
835
+ end
836
+
837
+ @agent_config.models.reject! { |m| m["type"] == kind }
838
+
839
+ if source == "custom"
840
+ model = body["model"].to_s.strip
841
+ base_url = body["base_url"].to_s.strip
842
+ api_key = body["api_key"].to_s
843
+ if model.empty? || base_url.empty? || api_key.empty? || api_key.include?("****")
844
+ return json_response(res, 422, { error: "model, base_url, api_key are required" })
845
+ end
846
+
847
+ @agent_config.models << {
848
+ "id" => SecureRandom.uuid,
849
+ "model" => model,
850
+ "base_url" => base_url,
851
+ "api_key" => api_key,
852
+ "anthropic_format" => body["anthropic_format"] || false,
853
+ "type" => kind
854
+ }
855
+ end
856
+
857
+ @agent_config.save
858
+ json_response(res, 200, { ok: true, state: @agent_config.media_state(kind) })
859
+ rescue => e
860
+ json_response(res, 422, { error: e.message })
861
+ end
862
+
700
863
  # POST /api/onboard/complete
701
864
  # Called after key setup is done (soul_setup is optional/skipped).
702
865
  # Creates the default session if none exists yet, returns it.
@@ -1614,7 +1777,7 @@ module Clacky
1614
1777
  # Returns current config and running status for all supported platforms.
1615
1778
  # POST /api/tool/browser
1616
1779
  # Executes a browser tool action via the shared BrowserManager daemon.
1617
- # Used by skill scripts (e.g. feishu_setup.rb) to reuse the server's
1780
+ # Used by skill scripts to reuse the server's
1618
1781
  # existing Chrome connection without spawning a second MCP daemon.
1619
1782
  #
1620
1783
  # Request body: JSON with same params as the browser tool
@@ -3285,8 +3448,13 @@ module Clacky
3285
3448
  type: m["type"]
3286
3449
  }
3287
3450
  end
3288
- # Filter out auto-injected models (like lite) from UI display
3289
- models.reject! { |m| @agent_config.models[m[:index]]["auto_injected"] }
3451
+ # Filter out auto-injected models (lite, derived media) AND media
3452
+ # entries (image/video/audio) those are managed via the dedicated
3453
+ # media-config UI, not the chat-model card list.
3454
+ models.reject! do |m|
3455
+ raw = @agent_config.models[m[:index]]
3456
+ raw["auto_injected"] || Clacky::Providers::MEDIA_KINDS.include?(raw["type"].to_s)
3457
+ end
3290
3458
  json_response(res, 200, {
3291
3459
  models: models,
3292
3460
  current_index: @agent_config.current_model_index,
@@ -3506,29 +3674,51 @@ module Clacky
3506
3674
  return json_response(res, 400, { error: "Invalid JSON" }) unless body
3507
3675
 
3508
3676
  api_key = body["api_key"].to_s
3509
- # If masked, use the stored key from the matching model (by index or current)
3510
3677
  if api_key.include?("****")
3511
3678
  idx = body["index"]&.to_i || @agent_config.current_model_index
3512
3679
  api_key = @agent_config.models.dig(idx, "api_key").to_s
3513
3680
  end
3514
3681
 
3515
- begin
3516
- model = body["model"].to_s
3517
- test_client = Clacky::Client.new(
3518
- api_key,
3519
- base_url: body["base_url"].to_s,
3520
- model: model,
3521
- anthropic_format: body["anthropic_format"] || false
3522
- )
3523
- result = test_client.test_connection(model: model)
3524
- if result[:success]
3525
- json_response(res, 200, { ok: true, message: "Connected successfully" })
3526
- else
3527
- json_response(res, 200, { ok: false, message: result[:error].to_s })
3528
- end
3529
- rescue => e
3530
- json_response(res, 200, { ok: false, message: e.message })
3682
+ model = body["model"].to_s
3683
+ base_url = body["base_url"].to_s
3684
+ anthropic_format = body["anthropic_format"] || false
3685
+
3686
+ result, used_base_url = try_test_with_base_url(api_key, base_url, model, anthropic_format)
3687
+
3688
+ if result[:success] && used_base_url != base_url
3689
+ json_response(res, 200, {
3690
+ ok: true,
3691
+ message: "Connected (auto-corrected base_url to add /v1)",
3692
+ effective_base_url: used_base_url
3693
+ })
3694
+ elsif result[:success]
3695
+ json_response(res, 200, { ok: true, message: "Connected successfully" })
3696
+ else
3697
+ json_response(res, 200, { ok: false, message: result[:error].to_s })
3531
3698
  end
3699
+ rescue => e
3700
+ json_response(res, 200, { ok: false, message: e.message })
3701
+ end
3702
+
3703
+ private def try_test_with_base_url(api_key, base_url, model, anthropic_format)
3704
+ result = run_test_connection(api_key, base_url, model, anthropic_format)
3705
+ return [result, base_url] if result[:success]
3706
+ return [result, base_url] unless result[:status] == 404
3707
+ return [result, base_url] if base_url.match?(%r{/v\d+/?\z})
3708
+
3709
+ candidate = "#{base_url.chomp("/")}/v1"
3710
+ retried = run_test_connection(api_key, candidate, model, anthropic_format)
3711
+ retried[:success] ? [retried, candidate] : [result, base_url]
3712
+ end
3713
+
3714
+ private def run_test_connection(api_key, base_url, model, anthropic_format)
3715
+ client = Clacky::Client.new(
3716
+ api_key,
3717
+ base_url: base_url,
3718
+ model: model,
3719
+ anthropic_format: anthropic_format
3720
+ )
3721
+ client.test_connection(model: model)
3532
3722
  end
3533
3723
 
3534
3724
  # GET /api/providers — return built-in provider presets for quick setup
@@ -23,6 +23,11 @@ module Clacky
23
23
  # POST /api/v1/telemetry/startup
24
24
  # POST /api/v1/telemetry/task
25
25
  module Telemetry
26
+ LAUNCH_SOURCES = {
27
+ "installer" => "installer",
28
+ nil => "cli"
29
+ }.freeze
30
+
26
31
  class << self
27
32
  # Called on every CLI startup (agent and server mode).
28
33
  # No local dedup — the server deduplicates by device_hash for unique
@@ -32,11 +37,12 @@ module Clacky
32
37
 
33
38
  brand = Clacky::BrandConfig.load
34
39
  payload = {
35
- device_id: resolve_device_id(brand),
36
- version: Clacky::VERSION,
37
- os: RbConfig::CONFIG["host_os"],
38
- ruby_version: RUBY_VERSION,
39
- brand: brand.branded? ? brand.package_name : nil
40
+ device_id: resolve_device_id(brand),
41
+ version: Clacky::VERSION,
42
+ os: RbConfig::CONFIG["host_os"],
43
+ ruby_version: RUBY_VERSION,
44
+ brand: brand.branded? ? brand.package_name : nil,
45
+ launch_source: LAUNCH_SOURCES.fetch(ENV["CLACKY_LAUNCHED_BY"], "cli")
40
46
  }.compact
41
47
 
42
48
  fire_and_forget("/api/v1/telemetry/startup", payload)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.2.8"
4
+ VERSION = "1.2.9"
5
5
  end