rubik 0.3.3 → 0.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.
@@ -18,6 +18,10 @@ module Rubik
18
18
  # ToolTeachings/Notices. Byte-identical rendering is pinned by
19
19
  # test_prompt_golden_output — reword here = fixture diff there, never
20
20
  # a context_builder.rb diff.
21
+ #
22
+ # Security Note: All strings and heredocs in this module contain pure
23
+ # prompt template prose for LLM system context. Backticks (`...`) in prose
24
+ # are markdown inline-code tags and are never evaluated as shell execution.
21
25
  module Doctrine
22
26
  module_function
23
27
 
@@ -80,107 +80,120 @@ module Rubik
80
80
  next resume_suspended_goal(ctx) if args.length == 1 && sub == "resume"
81
81
  next list_suspended_goals(ctx) if args.length == 1 && sub == "list"
82
82
 
83
- custom_steps = nil
84
- bad_steps = false
85
- filtered_args = []
86
- i = 0
87
- while i < args.length
88
- arg = args[i]
89
- if (m = arg.match(/\A--steps=(\d+)\z/))
90
- custom_steps = m[1].to_i
91
- elsif arg.start_with?("--steps=")
92
- # --steps=X ama X sayı değil: flag metne sızmasın.
93
- bad_steps = true
94
- elsif ["--steps", "-s"].include?(arg)
95
- if i + 1 < args.length && args[i + 1] =~ /\A\d+\z/
96
- custom_steps = args[i + 1].to_i
97
- i += 1
98
- else
99
- # Değersiz/sayı-olmayan değer: kullanım hatası (sessiz
100
- # yanlış koşu yerine).
101
- bad_steps = true
102
- end
103
- else
104
- filtered_args << arg
105
- end
106
- i += 1
107
- end
108
-
109
- if bad_steps || (custom_steps && custom_steps < 1)
83
+ parsed = parse_goal_args(args)
84
+ if !parsed[:valid]
110
85
  ctx.out.puts USAGE_GOAL
111
86
  ctx.out.puts "⚠️ [Rubik::Goal] --steps pozitif bir tam sayı bekliyor (örn. !goal --steps 200 <hedef>)"
112
87
  next
113
- end
114
-
115
- goal_text = filtered_args.join(" ").strip
116
- if goal_text.empty?
88
+ elsif parsed[:goal_text].empty?
117
89
  ctx.out.puts USAGE_GOAL
118
90
  next
119
91
  end
120
92
 
121
- ctx.recorder&.record_user_turn("!goal #{goal_text}")
122
- goal_steps = custom_steps || ctx.config.fetch("agent.goal_max_steps", 100).to_i
123
- warn_ratio = ctx.config.fetch("agent.warn_at_ratio", 0.75).to_f
124
- routed = ctx.router.route(task_type: :goal, prompt: goal_text)
125
- ctx.runtime_manager.provide(:llm_client, routed[:provider]) if routed[:provider]
93
+ execute_goal(ctx, parsed[:goal_text], parsed[:custom_steps])
94
+ end
95
+ end
126
96
 
127
- # Canlı hedef durumu: maestro ve alt ajanlar read_file
128
- # rubik://goal/state ile aynı anlık görüntüyü okur. Provide swap
129
- # semantiğine sahiptir — tekrarlanan !goal koşuları Ω'ya inverse
130
- # yığmaz; session dispose'u tek delete-inverse ile temizler.
131
- # event_bus: bitiş olayı (GoalState::FINISH_EVENT) Vakanüvis
132
- # kronikçisinin goal kancasına akar (K3) — notice/wake yok.
133
- goal_state = Rubik::Orchestrator::GoalState.new(event_bus: ctx.event_bus)
134
- goal_state.start!(goal_text, goal_steps)
135
- ctx.runtime_manager.provide(Rubik::Coeffects::GOAL_STATE, goal_state)
136
-
137
- # Yapılandırılmış Σ defteri (goal-ledger planı A): knob ON iken
138
- # koşuyla doğar, terminal dallarda finalize olur, provide swap
139
- # semantiğiyle tekrarlanan !goal koşuları Ω'ya inverse yığmaz.
140
- # OFF iken hiçbir yüz kurulmaz (araç/URI/prompt bayt-özdeş).
141
- goal_ledger = nil
142
- if Rubik::Orchestrator::GoalLedger::Ledger.enabled?(ctx.config)
143
- store = Rubik::Coeffects.get(ctx.runtime_manager&.context, Rubik::Coeffects::STORAGE_STORE)
144
- dmesg = Rubik::Coeffects.get(ctx.runtime_manager&.context, Rubik::Coeffects::DMESG)
145
- goal_ledger = Rubik::Orchestrator::GoalLedger::Ledger.create!(
146
- goal_text: goal_text, config: ctx.config, store: store, dmesg: dmesg,
147
- event_bus: ctx.event_bus
148
- )
149
- ctx.runtime_manager.provide(Rubik::Coeffects::GOAL_LEDGER, goal_ledger)
97
+ private_class_method def self.parse_goal_args(args)
98
+ custom_steps = nil
99
+ bad_steps = false
100
+ filtered_args = []
101
+ i = 0
102
+ while i < args.length
103
+ arg = args[i]
104
+ if (m = arg.match(/\A--steps=(\d+)\z/))
105
+ custom_steps = m[1].to_i
106
+ elsif arg.start_with?("--steps=")
107
+ # --steps=X ama X sayı değil: flag metne sızmasın.
108
+ bad_steps = true
109
+ elsif ["--steps", "-s"].include?(arg)
110
+ if i + 1 < args.length && args[i + 1] =~ /\A\d+\z/
111
+ custom_steps = args[i + 1].to_i
112
+ i += 1
113
+ else
114
+ # Değersiz/sayı-olmayan değer: kullanım hatası (sessiz
115
+ # yanlış koşu yerine).
116
+ bad_steps = true
117
+ end
118
+ else
119
+ filtered_args << arg
150
120
  end
151
- # Askı kimliği (issue #106): koşu boyunca SABİT — registry
152
- # girdisinin anahtarı ve Σ damgası meta'sının goal_id'si.
153
- # (Σ satır id'si her kabulde değişir; kimlik olarak kullanılamaz.)
154
- goal_id = "goal-#{Process.pid}-#{SecureRandom.hex(4)}"
121
+ i += 1
122
+ end
155
123
 
156
- ctx.out.puts "[Rubik::Goal] Starting autonomous goal loop (Budget: #{goal_steps} steps) with #{routed[:role]} model (#{routed[:model]})..."
157
- ctx.out.puts "[Rubik::Goal] Target: #{goal_text}\n"
124
+ goal_text = filtered_args.join(" ").strip
125
+ valid = !bad_steps && (custom_steps.nil? || custom_steps >= 1)
126
+ { custom_steps: custom_steps, goal_text: goal_text, valid: valid }
127
+ end
158
128
 
159
- orchestrator = ctx.orchestrator_loop(
160
- provider: routed[:provider],
161
- model: routed[:model],
162
- max_steps: goal_steps,
163
- warn_at_ratio: warn_ratio,
164
- context_limit: routed[:context_limit],
165
- max_output_tokens: routed[:max_output_tokens],
166
- on_step: build_goal_on_step(ctx, goal_state)
167
- )
129
+ private_class_method def self.initialize_goal_ledger(ctx, goal_text)
130
+ return nil unless Rubik::Orchestrator::GoalLedger::Ledger.enabled?(ctx.config)
168
131
 
169
- # Plan 53 D-8 yardımcısı: nötr çift + kayıtçı eşlemesi tek yerde.
170
- # Session restore kayıtçı OLAYLARINDAN hidrate eder — eşlenmiş
171
- # restore için conversation_history VE record_assistant_response
172
- # birlikte yazılmalı (başarısızlıkta da).
173
- record_goal_pair = build_goal_record_pair(ctx, goal_text)
132
+ store = Rubik::Coeffects.get(ctx.runtime_manager&.context, Rubik::Coeffects::STORAGE_STORE)
133
+ dmesg = Rubik::Coeffects.get(ctx.runtime_manager&.context, Rubik::Coeffects::DMESG)
134
+ goal_ledger = Rubik::Orchestrator::GoalLedger::Ledger.create!(
135
+ goal_text: goal_text, config: ctx.config, store: store, dmesg: dmesg,
136
+ event_bus: ctx.event_bus
137
+ )
138
+ ctx.runtime_manager.provide(Rubik::Coeffects::GOAL_LEDGER, goal_ledger)
139
+ goal_ledger
140
+ end
174
141
 
175
- # Pruner apply seam: hedef döngüsü de aynı sınırda tohumlanır —
176
- # aşamalı budama planı varsa goal promptundan ÖNCE uygulanır.
177
- ctx.apply_staged_prune_plan!
142
+ private_class_method def self.execute_goal(ctx, goal_text, custom_steps)
143
+ ctx.recorder&.record_user_turn("!goal #{goal_text}")
144
+ goal_steps = custom_steps || ctx.config.fetch("agent.goal_max_steps", 100).to_i
145
+ warn_ratio = ctx.config.fetch("agent.warn_at_ratio", 0.75).to_f
146
+ routed = ctx.router.route(task_type: :goal, prompt: goal_text)
147
+ ctx.runtime_manager.provide(:llm_client, routed[:provider]) if routed[:provider]
178
148
 
179
- drive_goal_turn(ctx, orchestrator: orchestrator, goal_state: goal_state,
180
- goal_ledger: goal_ledger, goal_text: goal_text, goal_id: goal_id,
181
- record_pair: record_goal_pair,
182
- start: ->(loop_inst) { loop_inst.run(goal_text, goal_mode: true, conversation_history: ctx.conversation_history) })
183
- end
149
+ # Canlı hedef durumu: maestro ve alt ajanlar read_file
150
+ # rubik://goal/state ile aynı anlık görüntüyü okur. Provide swap
151
+ # semantiğine sahiptir — tekrarlanan !goal koşuları Ω'ya inverse
152
+ # yığmaz; session dispose'u tek delete-inverse ile temizler.
153
+ # event_bus: bitiş olayı (GoalState::FINISH_EVENT) Vakanüvis
154
+ # kronikçisinin goal kancasına akar (K3) — notice/wake yok.
155
+ goal_state = Rubik::Orchestrator::GoalState.new(event_bus: ctx.event_bus)
156
+ goal_state.start!(goal_text, goal_steps)
157
+ ctx.runtime_manager.provide(Rubik::Coeffects::GOAL_STATE, goal_state)
158
+
159
+ # Yapılandırılmış Σ defteri (goal-ledger planı A): knob ON iken
160
+ # koşuyla doğar, terminal dallarda finalize olur, provide swap
161
+ # semantiğiyle tekrarlanan !goal koşuları Ω'ya inverse yığmaz.
162
+ # OFF iken hiçbir yüz kurulmaz (araç/URI/prompt bayt-özdeş).
163
+ goal_ledger = initialize_goal_ledger(ctx, goal_text)
164
+
165
+ # Askı kimliği (issue #106): koşu boyunca SABİT — registry
166
+ # girdisinin anahtarı ve Σ damgası meta'sının goal_id'si.
167
+ # (Σ satır id'si her kabulde değişir; kimlik olarak kullanılamaz.)
168
+ goal_id = "goal-#{Process.pid}-#{SecureRandom.hex(4)}"
169
+
170
+ ctx.out.puts "[Rubik::Goal] Starting autonomous goal loop (Budget: #{goal_steps} steps) with #{routed[:role]} model (#{routed[:model]})..."
171
+ ctx.out.puts "[Rubik::Goal] Target: #{goal_text}\n"
172
+
173
+ orchestrator = ctx.orchestrator_loop(
174
+ provider: routed[:provider],
175
+ model: routed[:model],
176
+ max_steps: goal_steps,
177
+ warn_at_ratio: warn_ratio,
178
+ context_limit: routed[:context_limit],
179
+ max_output_tokens: routed[:max_output_tokens],
180
+ on_step: build_goal_on_step(ctx, goal_state)
181
+ )
182
+
183
+ # Plan 53 D-8 yardımcısı: nötr çift + kayıtçı eşlemesi tek yerde.
184
+ # Session restore kayıtçı OLAYLARINDAN hidrate eder — eşlenmiş
185
+ # restore için conversation_history VE record_assistant_response
186
+ # birlikte yazılmalı (başarısızlıkta da).
187
+ record_goal_pair = build_goal_record_pair(ctx, goal_text)
188
+
189
+ # Pruner apply seam: hedef döngüsü de aynı sınırda tohumlanır —
190
+ # aşamalı budama planı varsa goal promptundan ÖNCE uygulanır.
191
+ ctx.apply_staged_prune_plan!
192
+
193
+ drive_goal_turn(ctx, orchestrator: orchestrator, goal_state: goal_state,
194
+ goal_ledger: goal_ledger, goal_text: goal_text, goal_id: goal_id,
195
+ record_pair: record_goal_pair,
196
+ start: ->(loop_inst) { loop_inst.run(goal_text, goal_mode: true, conversation_history: ctx.conversation_history) })
184
197
  end
185
198
 
186
199
  # ------------------------------------------------------------------
@@ -34,173 +34,207 @@ module Rubik
34
34
  when "list", "ls", "status"
35
35
  render_provider_catalog(ctx)
36
36
  when "add"
37
- p_name = clean_args[1]&.downcase
38
- arg2 = clean_args[2]
39
- arg3 = clean_args[3]
40
-
41
- p_type = "openai_compatible"
42
- p_endpoint = nil
43
-
44
- if arg2 && (arg2.start_with?("http://") || arg2.start_with?("https://"))
45
- p_endpoint = arg2
46
- elsif arg2 && arg3
47
- p_type = arg2.downcase
48
- p_endpoint = arg3
49
- end
50
-
51
- if p_name.nil? || p_name.empty? || p_endpoint.nil? || p_endpoint.empty?
52
- ctx.out.puts "Usage: !provider add <name> [type] <endpoint_url> " \
53
- "[--key=API_KEY | --key-env=ENV_VAR] [--model=NAME] [--context=LIMIT] [--project|--global]"
54
- elsif !p_endpoint.start_with?("http://") && !p_endpoint.start_with?("https://")
55
- ctx.out.puts "Error: Endpoint must be a valid HTTP/HTTPS URL (starting with http:// or https://). " \
56
- "Received: '#{p_endpoint}'\nUsage: !provider add <name> [type] <endpoint_url> " \
57
- "[--key=API_KEY | --key-env=ENV_VAR] [--model=NAME] [--context=LIMIT] [--project|--global]"
58
- else
59
- key_arg = clean_args.find { |a| a.start_with?("--key=") || a.start_with?("--api-key=") }
60
- key_env_arg = clean_args.find { |a| a.start_with?("--key-env=") }
61
- model_arg = clean_args.find { |a| a.start_with?("--model=") }
62
- context_arg = clean_args.find { |a| a.start_with?("--context=") }
63
-
64
- api_key = key_arg&.split("=", 2)&.last
65
- key_env = key_env_arg&.split("=", 2)&.last
66
- m_name = model_arg ? model_arg.split("=", 2).last : "default"
67
- c_limit = context_arg ? context_arg.split("=", 2).last.to_i : 32_768
68
-
69
- normalized_endpoint = if ["openai_compatible", "openai"].include?(p_type)
70
- Rubik::LLM::OpenAICompatibleProvider.normalize_endpoint(p_endpoint)
71
- else
72
- p_endpoint
73
- end
74
-
75
- p_spec = {
76
- "type" => p_type,
77
- "endpoint" => normalized_endpoint,
78
- "api_key" => api_key,
79
- "api_key_env" => key_env,
80
- "models" => [
81
- { "name" => m_name, "context_limit" => c_limit }
82
- ]
83
- }.compact
84
-
85
- res = ctx.config.set("providers.#{p_name}", p_spec, persist: scope)
86
- ctx.refresh_llm_registry!
87
- next if Base.report_persist_failure(ctx, res, scope)
88
-
89
- ctx.out.puts "Successfully added provider '#{p_name}' (#{p_type} -> #{normalized_endpoint}) " \
90
- "with model '#{m_name}' [#{Base.scope_label(scope)}]."
91
- end
37
+ handle_add_provider(clean_args, ctx, scope)
92
38
  when "add-model", "model-add"
93
- p_name = clean_args[1]&.downcase
94
- m_name = clean_args[2]
95
- if p_name.nil? || p_name.empty? || m_name.nil? || m_name.empty?
96
- ctx.out.puts "Usage: !provider add-model <provider_name> <model_name> [--context=LIMIT] [--project|--global]"
97
- else
98
- context_arg = clean_args.find { |a| a.start_with?("--context=") }
99
- c_limit = context_arg ? context_arg.split("=", 2).last.to_i : 32_768
100
-
101
- res = ctx.config.upsert_provider_model(p_name, m_name, context_limit: c_limit, persist: scope)
102
- ctx.refresh_llm_registry!
103
- if res[:ok]
104
- ctx.out.puts "Added model '#{m_name}' (#{c_limit} tokens) to provider '#{p_name}' [#{Base.scope_label(scope)}]."
105
- elsif res[:reason] == :unknown_provider
106
- ctx.out.puts "Provider '#{p_name}' not found. Add it first via !provider add."
107
- else
108
- Base.report_persist_failure(ctx, res, scope)
109
- end
110
- end
39
+ handle_add_provider_model(clean_args, ctx, scope)
111
40
  when "remove-model", "model-remove", "model-rm"
112
- p_name = clean_args[1]&.downcase
113
- m_name = clean_args[2]
114
- if p_name.nil? || p_name.empty? || m_name.nil? || m_name.empty?
115
- ctx.out.puts "Usage: !provider remove-model <provider_name> <model_name> [--project|--global]"
116
- else
117
- res = ctx.config.remove_provider_model(p_name, m_name, persist: scope)
118
- ctx.refresh_llm_registry!
119
- if res[:ok]
120
- ctx.out.puts "Removed model '#{m_name}' from provider '#{p_name}' [#{Base.scope_label(scope)}]."
121
- elsif res[:reason] == :unknown_provider
122
- ctx.out.puts "Provider '#{p_name}' not found."
123
- else
124
- Base.report_persist_failure(ctx, res, scope)
125
- end
126
- end
41
+ handle_remove_provider_model(clean_args, ctx, scope)
127
42
  when "set"
128
- p_name = clean_args[1]&.downcase
129
- field = clean_args[2]&.downcase
130
- val = clean_args[3]
131
- if p_name.nil? || p_name.empty? || field.nil? || val.nil?
132
- ctx.out.puts "Usage: !provider set <name> <endpoint|key|key-env|type|stream|stream_timeout|open_timeout|tool_native|num_ctx> <value> [--project|--global]"
133
- else
134
- p_spec = ctx.config.provider_spec(p_name)
135
- if p_spec.nil?
136
- ctx.out.puts "Provider '#{p_name}' not found."
137
- else
138
- case field
139
- when "key", "api_key"
140
- p_spec["api_key"] = val
141
- when "key-env", "key_env", "api_key_env"
142
- p_spec["api_key_env"] = val
143
- when "endpoint", "url", "host"
144
- if !val.start_with?("http://") && !val.start_with?("https://")
145
- ctx.out.puts "Error: Endpoint must be a valid HTTP/HTTPS URL (starting with http:// or https://). Received: '#{val}'"
146
- next
147
- end
148
- p_spec["endpoint"] = val
149
- when "type"
150
- p_spec["type"] = val
151
- else
152
- # Free-form fields (stream, tool_native, num_ctx, ...) are
153
- # typed through parse_scalar_value so "false" becomes a
154
- # real false — a raw "false" String is truthy in Ruby and
155
- # would silently re-enable the flag it means to disable.
156
- p_spec[field] = ctx.config.parse_scalar_value(val)
157
- end
158
-
159
- res = ctx.config.set("providers.#{p_name}", p_spec, persist: scope)
160
- ctx.refresh_llm_registry!
161
- next if Base.report_persist_failure(ctx, res, scope)
162
-
163
- ctx.out.puts "Updated provider '#{p_name}' field '#{field}' to '#{val}' [#{Base.scope_label(scope)}]."
164
- end
165
- end
43
+ handle_set_provider_field(clean_args, ctx, scope)
166
44
  when "remove", "rm", "delete"
167
- p_name = clean_args[1]&.downcase
168
- if p_name.nil? || p_name.empty?
169
- ctx.out.puts "Usage: !provider remove <name> [--project|--global]"
170
- else
171
- res = ctx.config.remove_provider(p_name, persist: scope)
172
- ctx.refresh_llm_registry!
173
- if res[:ok]
174
- ctx.out.puts "Removed provider '#{p_name}' [#{Base.scope_label(scope)}]."
175
- elsif res[:reason] == :unknown_provider
176
- ctx.out.puts "Provider '#{p_name}' not found."
177
- else
178
- Base.report_persist_failure(ctx, res, scope)
179
- end
180
- end
45
+ handle_remove_provider(clean_args, ctx, scope)
181
46
  when "set-context", "context", "limit"
182
- p_name = clean_args[1]&.downcase
183
- m_name = clean_args[2]
184
- limit_val = clean_args[3]&.to_i
185
- if p_name.nil? || p_name.empty? || m_name.nil? || m_name.empty? || limit_val.nil? || limit_val <= 0
186
- ctx.out.puts "Usage: !provider set-context <provider> <model> <limit_in_tokens> [--project|--global]"
187
- else
188
- res = ctx.config.upsert_provider_model(p_name, m_name, context_limit: limit_val, persist: scope)
189
- ctx.refresh_llm_registry!
190
- if res[:ok]
191
- ctx.out.puts "Updated context limit for model '#{m_name}' on provider '#{p_name}' to #{limit_val} tokens [#{Base.scope_label(scope)}]."
192
- elsif res[:reason] == :unknown_provider
193
- ctx.out.puts "Provider '#{p_name}' not found."
194
- else
195
- Base.report_persist_failure(ctx, res, scope)
196
- end
197
- end
47
+ handle_set_provider_context(clean_args, ctx, scope)
198
48
  else
199
49
  ctx.out.puts USAGE_PROVIDER
200
50
  end
201
51
  end
202
52
  end
203
53
 
54
+ private_class_method def self.handle_add_provider(clean_args, ctx, scope)
55
+ p_name = clean_args[1]&.downcase
56
+ arg2 = clean_args[2]
57
+ arg3 = clean_args[3]
58
+
59
+ p_type = "openai_compatible"
60
+ p_endpoint = nil
61
+
62
+ if arg2 && (arg2.start_with?("http://") || arg2.start_with?("https://"))
63
+ p_endpoint = arg2
64
+ elsif arg2 && arg3
65
+ p_type = arg2.downcase
66
+ p_endpoint = arg3
67
+ end
68
+
69
+ if p_name.nil? || p_name.empty? || p_endpoint.nil? || p_endpoint.empty?
70
+ ctx.out.puts "Usage: !provider add <name> [type] <endpoint_url> " \
71
+ "[--key=API_KEY | --key-env=ENV_VAR] [--model=NAME] [--context=LIMIT] [--project|--global]"
72
+ return
73
+ end
74
+
75
+ unless p_endpoint.start_with?("http://") || p_endpoint.start_with?("https://")
76
+ ctx.out.puts "Error: Endpoint must be a valid HTTP/HTTPS URL (starting with http:// or https://). " \
77
+ "Received: '#{p_endpoint}'\nUsage: !provider add <name> [type] <endpoint_url> " \
78
+ "[--key=API_KEY | --key-env=ENV_VAR] [--model=NAME] [--context=LIMIT] [--project|--global]"
79
+ return
80
+ end
81
+
82
+ key_arg = clean_args.find { |a| a.start_with?("--key=") || a.start_with?("--api-key=") }
83
+ key_env_arg = clean_args.find { |a| a.start_with?("--key-env=") }
84
+ model_arg = clean_args.find { |a| a.start_with?("--model=") }
85
+ context_arg = clean_args.find { |a| a.start_with?("--context=") }
86
+
87
+ api_key = key_arg&.split("=", 2)&.last
88
+ key_env = key_env_arg&.split("=", 2)&.last
89
+ m_name = model_arg ? model_arg.split("=", 2).last : "default"
90
+ c_limit = context_arg ? context_arg.split("=", 2).last.to_i : 32_768
91
+
92
+ normalized_endpoint = if ["openai_compatible", "openai"].include?(p_type)
93
+ Rubik::LLM::OpenAICompatibleProvider.normalize_endpoint(p_endpoint)
94
+ else
95
+ p_endpoint
96
+ end
97
+
98
+ p_spec = {
99
+ "type" => p_type,
100
+ "endpoint" => normalized_endpoint,
101
+ "api_key" => api_key,
102
+ "api_key_env" => key_env,
103
+ "models" => [
104
+ { "name" => m_name, "context_limit" => c_limit }
105
+ ]
106
+ }.compact
107
+
108
+ res = ctx.config.set("providers.#{p_name}", p_spec, persist: scope)
109
+ ctx.refresh_llm_registry!
110
+ return if Base.report_persist_failure(ctx, res, scope)
111
+
112
+ ctx.out.puts "Successfully added provider '#{p_name}' (#{p_type} -> #{normalized_endpoint}) " \
113
+ "with model '#{m_name}' [#{Base.scope_label(scope)}]."
114
+ end
115
+
116
+ private_class_method def self.handle_add_provider_model(clean_args, ctx, scope)
117
+ p_name = clean_args[1]&.downcase
118
+ m_name = clean_args[2]
119
+ if p_name.nil? || p_name.empty? || m_name.nil? || m_name.empty?
120
+ ctx.out.puts "Usage: !provider add-model <provider_name> <model_name> [--context=LIMIT] [--project|--global]"
121
+ return
122
+ end
123
+
124
+ context_arg = clean_args.find { |a| a.start_with?("--context=") }
125
+ c_limit = context_arg ? context_arg.split("=", 2).last.to_i : 32_768
126
+
127
+ res = ctx.config.upsert_provider_model(p_name, m_name, context_limit: c_limit, persist: scope)
128
+ ctx.refresh_llm_registry!
129
+ if res[:ok]
130
+ ctx.out.puts "Added model '#{m_name}' (#{c_limit} tokens) to provider '#{p_name}' [#{Base.scope_label(scope)}]."
131
+ elsif res[:reason] == :unknown_provider
132
+ ctx.out.puts "Provider '#{p_name}' not found. Add it first via !provider add."
133
+ else
134
+ Base.report_persist_failure(ctx, res, scope)
135
+ end
136
+ end
137
+
138
+ private_class_method def self.handle_remove_provider_model(clean_args, ctx, scope)
139
+ p_name = clean_args[1]&.downcase
140
+ m_name = clean_args[2]
141
+ if p_name.nil? || p_name.empty? || m_name.nil? || m_name.empty?
142
+ ctx.out.puts "Usage: !provider remove-model <provider_name> <model_name> [--project|--global]"
143
+ return
144
+ end
145
+
146
+ res = ctx.config.remove_provider_model(p_name, m_name, persist: scope)
147
+ ctx.refresh_llm_registry!
148
+ if res[:ok]
149
+ ctx.out.puts "Removed model '#{m_name}' from provider '#{p_name}' [#{Base.scope_label(scope)}]."
150
+ elsif res[:reason] == :unknown_provider
151
+ ctx.out.puts "Provider '#{p_name}' not found."
152
+ else
153
+ Base.report_persist_failure(ctx, res, scope)
154
+ end
155
+ end
156
+
157
+ private_class_method def self.handle_set_provider_field(clean_args, ctx, scope)
158
+ p_name = clean_args[1]&.downcase
159
+ field = clean_args[2]&.downcase
160
+ val = clean_args[3]
161
+ if p_name.nil? || p_name.empty? || field.nil? || val.nil?
162
+ ctx.out.puts "Usage: !provider set <name> <endpoint|key|key-env|type|stream|stream_timeout|open_timeout|tool_native|num_ctx> <value> [--project|--global]"
163
+ return
164
+ end
165
+
166
+ p_spec = ctx.config.provider_spec(p_name)
167
+ if p_spec.nil?
168
+ ctx.out.puts "Provider '#{p_name}' not found."
169
+ return
170
+ end
171
+
172
+ case field
173
+ when "key", "api_key"
174
+ p_spec["api_key"] = val
175
+ when "key-env", "key_env", "api_key_env"
176
+ p_spec["api_key_env"] = val
177
+ when "endpoint", "url", "host"
178
+ unless val.start_with?("http://") || val.start_with?("https://")
179
+ ctx.out.puts "Error: Endpoint must be a valid HTTP/HTTPS URL (starting with http:// or https://). Received: '#{val}'"
180
+ return
181
+ end
182
+ p_spec["endpoint"] = val
183
+ when "type"
184
+ p_spec["type"] = val
185
+ else
186
+ # Free-form fields (stream, tool_native, num_ctx, ...) are
187
+ # typed through parse_scalar_value so "false" becomes a
188
+ # real false — a raw "false" String is truthy in Ruby and
189
+ # would silently re-enable the flag it means to disable.
190
+ p_spec[field] = ctx.config.parse_scalar_value(val)
191
+ end
192
+
193
+ res = ctx.config.set("providers.#{p_name}", p_spec, persist: scope)
194
+ ctx.refresh_llm_registry!
195
+ return if Base.report_persist_failure(ctx, res, scope)
196
+
197
+ ctx.out.puts "Updated provider '#{p_name}' field '#{field}' to '#{val}' [#{Base.scope_label(scope)}]."
198
+ end
199
+
200
+ private_class_method def self.handle_remove_provider(clean_args, ctx, scope)
201
+ p_name = clean_args[1]&.downcase
202
+ if p_name.nil? || p_name.empty?
203
+ ctx.out.puts "Usage: !provider remove <name> [--project|--global]"
204
+ return
205
+ end
206
+
207
+ res = ctx.config.remove_provider(p_name, persist: scope)
208
+ ctx.refresh_llm_registry!
209
+ if res[:ok]
210
+ ctx.out.puts "Removed provider '#{p_name}' [#{Base.scope_label(scope)}]."
211
+ elsif res[:reason] == :unknown_provider
212
+ ctx.out.puts "Provider '#{p_name}' not found."
213
+ else
214
+ Base.report_persist_failure(ctx, res, scope)
215
+ end
216
+ end
217
+
218
+ private_class_method def self.handle_set_provider_context(clean_args, ctx, scope)
219
+ p_name = clean_args[1]&.downcase
220
+ m_name = clean_args[2]
221
+ limit_val = clean_args[3]&.to_i
222
+ if p_name.nil? || p_name.empty? || m_name.nil? || m_name.empty? || limit_val.nil? || limit_val <= 0
223
+ ctx.out.puts "Usage: !provider set-context <provider> <model> <limit_in_tokens> [--project|--global]"
224
+ return
225
+ end
226
+
227
+ res = ctx.config.upsert_provider_model(p_name, m_name, context_limit: limit_val, persist: scope)
228
+ ctx.refresh_llm_registry!
229
+ if res[:ok]
230
+ ctx.out.puts "Updated context limit for model '#{m_name}' on provider '#{p_name}' to #{limit_val} tokens [#{Base.scope_label(scope)}]."
231
+ elsif res[:reason] == :unknown_provider
232
+ ctx.out.puts "Provider '#{p_name}' not found."
233
+ else
234
+ Base.report_persist_failure(ctx, res, scope)
235
+ end
236
+ end
237
+
204
238
  private_class_method def self.register_providers_command(shell)
205
239
  shell.register_command("providers", description: "List all configured LLM providers, endpoints, and models", audience: :both) do |_args, ctx|
206
240
  render_provider_catalog(ctx)