openclacky 0.7.0 → 0.7.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/commit/SKILL.md +29 -4
  3. data/.clackyrules +3 -1
  4. data/CHANGELOG.md +103 -2
  5. data/README.md +70 -161
  6. data/bin/clarky +11 -0
  7. data/docs/HOW-TO-USE-CN.md +96 -0
  8. data/docs/HOW-TO-USE.md +94 -0
  9. data/docs/config.example.yml +27 -0
  10. data/docs/deploy_subagent_design.md +540 -0
  11. data/docs/time_machine_design.md +247 -0
  12. data/docs/why-openclacky.md +0 -1
  13. data/lib/clacky/agent/cost_tracker.rb +180 -0
  14. data/lib/clacky/agent/llm_caller.rb +54 -0
  15. data/lib/clacky/{message_compressor.rb → agent/message_compressor.rb} +12 -36
  16. data/lib/clacky/agent/message_compressor_helper.rb +534 -0
  17. data/lib/clacky/agent/session_serializer.rb +152 -0
  18. data/lib/clacky/agent/skill_manager.rb +138 -0
  19. data/lib/clacky/agent/system_prompt_builder.rb +96 -0
  20. data/lib/clacky/agent/time_machine.rb +199 -0
  21. data/lib/clacky/agent/tool_executor.rb +434 -0
  22. data/lib/clacky/{tool_registry.rb → agent/tool_registry.rb} +1 -1
  23. data/lib/clacky/agent.rb +260 -1370
  24. data/lib/clacky/agent_config.rb +447 -10
  25. data/lib/clacky/cli.rb +275 -98
  26. data/lib/clacky/client.rb +12 -2
  27. data/lib/clacky/default_skills/code-explorer/SKILL.md +34 -0
  28. data/lib/clacky/default_skills/deploy/SKILL.md +13 -0
  29. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +383 -0
  30. data/lib/clacky/default_skills/deploy/tools/check_health.rb +116 -0
  31. data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +174 -0
  32. data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +67 -0
  33. data/lib/clacky/default_skills/deploy/tools/list_services.rb +80 -0
  34. data/lib/clacky/default_skills/deploy/tools/report_deploy_status.rb +67 -0
  35. data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +138 -0
  36. data/lib/clacky/default_skills/new/SKILL.md +2 -2
  37. data/lib/clacky/json_ui_controller.rb +195 -0
  38. data/lib/clacky/providers.rb +107 -0
  39. data/lib/clacky/skill.rb +48 -7
  40. data/lib/clacky/skill_loader.rb +7 -0
  41. data/lib/clacky/tools/edit.rb +105 -48
  42. data/lib/clacky/tools/file_reader.rb +44 -73
  43. data/lib/clacky/tools/invoke_skill.rb +89 -0
  44. data/lib/clacky/tools/list_tasks.rb +54 -0
  45. data/lib/clacky/tools/redo_task.rb +41 -0
  46. data/lib/clacky/tools/safe_shell.rb +1 -1
  47. data/lib/clacky/tools/shell.rb +74 -62
  48. data/lib/clacky/tools/trash_manager.rb +1 -1
  49. data/lib/clacky/tools/undo_task.rb +32 -0
  50. data/lib/clacky/tools/web_fetch.rb +2 -1
  51. data/lib/clacky/ui2/components/command_suggestions.rb +13 -3
  52. data/lib/clacky/ui2/components/inline_input.rb +23 -2
  53. data/lib/clacky/ui2/components/input_area.rb +65 -21
  54. data/lib/clacky/ui2/components/modal_component.rb +199 -62
  55. data/lib/clacky/ui2/layout_manager.rb +75 -25
  56. data/lib/clacky/ui2/line_editor.rb +23 -2
  57. data/lib/clacky/ui2/markdown_renderer.rb +31 -10
  58. data/lib/clacky/ui2/screen_buffer.rb +2 -0
  59. data/lib/clacky/ui2/ui_controller.rb +316 -37
  60. data/lib/clacky/ui2.rb +2 -0
  61. data/lib/clacky/ui_interface.rb +50 -0
  62. data/lib/clacky/utils/arguments_parser.rb +31 -3
  63. data/lib/clacky/utils/file_processor.rb +13 -18
  64. data/lib/clacky/version.rb +1 -1
  65. data/lib/clacky.rb +19 -9
  66. data/scripts/install.sh +274 -97
  67. data/scripts/uninstall.sh +12 -12
  68. metadata +40 -13
  69. data/.clacky/skills/test-skill/SKILL.md +0 -15
  70. data/lib/clacky/compression/base.rb +0 -231
  71. data/lib/clacky/compression/standard.rb +0 -339
  72. data/lib/clacky/config.rb +0 -117
  73. /data/lib/clacky/{hook_manager.rb → agent/hook_manager.rb} +0 -0
  74. /data/lib/clacky/{progress_indicator.rb → ui2/progress_indicator.rb} +0 -0
  75. /data/lib/clacky/{thinking_verbs.rb → ui2/thinking_verbs.rb} +0 -0
  76. /data/lib/clacky/{gitignore_parser.rb → utils/gitignore_parser.rb} +0 -0
  77. /data/lib/clacky/{model_pricing.rb → utils/model_pricing.rb} +0 -0
  78. /data/lib/clacky/{trash_directory.rb → utils/trash_directory.rb} +0 -0
@@ -1,34 +1,407 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "yaml"
4
+ require "fileutils"
5
+
3
6
  module Clacky
7
+ # ClaudeCode environment variable compatibility layer
8
+ # Provides configuration detection from ClaudeCode's environment variables
9
+ module ClaudeCodeEnv
10
+ # Environment variable names used by ClaudeCode
11
+ ENV_API_KEY = "ANTHROPIC_API_KEY"
12
+ ENV_AUTH_TOKEN = "ANTHROPIC_AUTH_TOKEN"
13
+ ENV_BASE_URL = "ANTHROPIC_BASE_URL"
14
+
15
+ # Default Anthropic API endpoint
16
+ DEFAULT_BASE_URL = "https://api.anthropic.com"
17
+
18
+ class << self
19
+ # Check if any ClaudeCode authentication is configured
20
+ def configured?
21
+ !api_key.nil? && !api_key.empty?
22
+ end
23
+
24
+ # Get API key - prefer ANTHROPIC_API_KEY, fallback to ANTHROPIC_AUTH_TOKEN
25
+ def api_key
26
+ if ENV[ENV_API_KEY] && !ENV[ENV_API_KEY].empty?
27
+ ENV[ENV_API_KEY]
28
+ elsif ENV[ENV_AUTH_TOKEN] && !ENV[ENV_AUTH_TOKEN].empty?
29
+ ENV[ENV_AUTH_TOKEN]
30
+ end
31
+ end
32
+
33
+ # Get base URL from environment, or return default Anthropic API URL
34
+ def base_url
35
+ ENV[ENV_BASE_URL] && !ENV[ENV_BASE_URL].empty? ? ENV[ENV_BASE_URL] : DEFAULT_BASE_URL
36
+ end
37
+
38
+ # Get configuration as a hash (includes configured values)
39
+ # Returns api_key and base_url (always available as there's a default)
40
+ def to_h
41
+ {
42
+ "api_key" => api_key,
43
+ "base_url" => base_url
44
+ }.compact
45
+ end
46
+ end
47
+ end
48
+
49
+ # Clacky environment variable layer
50
+ # Provides configuration from CLACKY_XXX environment variables
51
+ module ClackyEnv
52
+ # Environment variable names for default model
53
+ ENV_API_KEY = "CLACKY_API_KEY"
54
+ ENV_BASE_URL = "CLACKY_BASE_URL"
55
+ ENV_MODEL = "CLACKY_MODEL"
56
+ ENV_ANTHROPIC_FORMAT = "CLACKY_ANTHROPIC_FORMAT"
57
+
58
+ # Environment variable names for lite model
59
+ ENV_LITE_API_KEY = "CLACKY_LITE_API_KEY"
60
+ ENV_LITE_BASE_URL = "CLACKY_LITE_BASE_URL"
61
+ ENV_LITE_MODEL = "CLACKY_LITE_MODEL"
62
+ ENV_LITE_ANTHROPIC_FORMAT = "CLACKY_LITE_ANTHROPIC_FORMAT"
63
+
64
+ # Default model name (only for model, not base_url)
65
+ DEFAULT_MODEL = "claude-sonnet-4-5"
66
+
67
+ class << self
68
+ # Check if default model is configured via environment variables
69
+ def default_configured?
70
+ !default_api_key.nil? && !default_api_key.empty?
71
+ end
72
+
73
+ # Check if lite model is configured via environment variables
74
+ def lite_configured?
75
+ !lite_api_key.nil? && !lite_api_key.empty?
76
+ end
77
+
78
+ # Get default model API key
79
+ def default_api_key
80
+ ENV[ENV_API_KEY] if ENV[ENV_API_KEY] && !ENV[ENV_API_KEY].empty?
81
+ end
82
+
83
+ # Get default model base URL (no default, must be explicitly set)
84
+ def default_base_url
85
+ ENV[ENV_BASE_URL] if ENV[ENV_BASE_URL] && !ENV[ENV_BASE_URL].empty?
86
+ end
87
+
88
+ # Get default model name
89
+ def default_model
90
+ ENV[ENV_MODEL] && !ENV[ENV_MODEL].empty? ? ENV[ENV_MODEL] : DEFAULT_MODEL
91
+ end
92
+
93
+ # Get default model anthropic_format flag
94
+ def default_anthropic_format
95
+ return true if ENV[ENV_ANTHROPIC_FORMAT].nil? || ENV[ENV_ANTHROPIC_FORMAT].empty?
96
+ ENV[ENV_ANTHROPIC_FORMAT].downcase == "true"
97
+ end
98
+
99
+ # Get default model configuration as a hash
100
+ def default_model_config
101
+ {
102
+ "type" => "default",
103
+ "api_key" => default_api_key,
104
+ "base_url" => default_base_url,
105
+ "model" => default_model,
106
+ "anthropic_format" => default_anthropic_format
107
+ }.compact
108
+ end
109
+
110
+ # Get lite model API key
111
+ def lite_api_key
112
+ ENV[ENV_LITE_API_KEY] if ENV[ENV_LITE_API_KEY] && !ENV[ENV_LITE_API_KEY].empty?
113
+ end
114
+
115
+ # Get lite model base URL (no default, must be explicitly set)
116
+ def lite_base_url
117
+ ENV[ENV_LITE_BASE_URL] if ENV[ENV_LITE_BASE_URL] && !ENV[ENV_LITE_BASE_URL].empty?
118
+ end
119
+
120
+ # Get lite model name
121
+ def lite_model
122
+ ENV[ENV_LITE_MODEL] && !ENV[ENV_LITE_MODEL].empty? ? ENV[ENV_LITE_MODEL] : "claude-haiku-4"
123
+ end
124
+
125
+ # Get lite model anthropic_format flag
126
+ def lite_anthropic_format
127
+ return true if ENV[ENV_LITE_ANTHROPIC_FORMAT].nil? || ENV[ENV_LITE_ANTHROPIC_FORMAT].empty?
128
+ ENV[ENV_LITE_ANTHROPIC_FORMAT].downcase == "true"
129
+ end
130
+
131
+ # Get lite model configuration as a hash
132
+ def lite_model_config
133
+ {
134
+ "type" => "lite",
135
+ "api_key" => lite_api_key,
136
+ "base_url" => lite_base_url,
137
+ "model" => lite_model,
138
+ "anthropic_format" => lite_anthropic_format
139
+ }.compact
140
+ end
141
+ end
142
+ end
143
+
4
144
  class AgentConfig
5
- PERMISSION_MODES = [:auto_approve, :confirm_safes, :confirm_edits, :plan_only].freeze
6
- EDITING_TOOLS = %w[write edit].freeze
145
+ CONFIG_DIR = File.join(Dir.home, ".clacky")
146
+ CONFIG_FILE = File.join(CONFIG_DIR, "config.yml")
147
+
148
+ # Default model for ClaudeCode environment
149
+ CLAUDE_DEFAULT_MODEL = "claude-sonnet-4-5"
7
150
 
8
- attr_accessor :model, :permission_mode,
9
- :max_tokens, :verbose, :enable_compression, :keep_recent_messages,
10
- :enable_prompt_caching
151
+ PERMISSION_MODES = [:auto_approve, :confirm_safes, :plan_only].freeze
152
+
153
+ attr_accessor :permission_mode, :max_tokens, :verbose,
154
+ :enable_compression, :enable_prompt_caching,
155
+ :models, :current_model_index
11
156
 
12
157
  def initialize(options = {})
13
- @model = options[:model] || "gpt-3.5-turbo"
14
158
  @permission_mode = validate_permission_mode(options[:permission_mode])
15
159
  @max_tokens = options[:max_tokens] || 8192
16
160
  @verbose = options[:verbose] || false
17
161
  @enable_compression = options[:enable_compression].nil? ? true : options[:enable_compression]
18
- @keep_recent_messages = options[:keep_recent_messages] || 20
19
162
  # Enable prompt caching by default for cost savings
20
163
  @enable_prompt_caching = options[:enable_prompt_caching].nil? ? true : options[:enable_prompt_caching]
164
+
165
+ # Models configuration
166
+ @models = options[:models] || []
167
+ @current_model_index = options[:current_model_index] || 0
168
+ end
169
+
170
+ # Load configuration from file
171
+ def self.load(config_file = CONFIG_FILE)
172
+ # Load from config file first
173
+ if File.exist?(config_file)
174
+ data = YAML.load_file(config_file)
175
+ else
176
+ data = nil
177
+ end
178
+
179
+ # Parse models from config
180
+ models = parse_models(data)
181
+
182
+ # Priority: config file > CLACKY_XXX env vars > ClaudeCode env vars
183
+ if models.empty?
184
+ # Try CLACKY_XXX environment variables first
185
+ if ClackyEnv.default_configured?
186
+ models << ClackyEnv.default_model_config
187
+ # Fallback to ClaudeCode environment variables
188
+ elsif ClaudeCodeEnv.configured?
189
+ models << {
190
+ "type" => "default",
191
+ "api_key" => ClaudeCodeEnv.api_key,
192
+ "base_url" => ClaudeCodeEnv.base_url,
193
+ "model" => CLAUDE_DEFAULT_MODEL,
194
+ "anthropic_format" => true
195
+ }
196
+ end
197
+
198
+ # Add CLACKY_LITE_XXX if configured (only when loading from env)
199
+ if ClackyEnv.lite_configured?
200
+ models << ClackyEnv.lite_model_config
201
+ end
202
+ else
203
+ # Config file exists, but check if we need to add env-based models
204
+ # Only add if no model with that type exists
205
+ has_default = models.any? { |m| m["type"] == "default" }
206
+ has_lite = models.any? { |m| m["type"] == "lite" }
207
+
208
+ # Add CLACKY default if not in config and env is set
209
+ if !has_default && ClackyEnv.default_configured?
210
+ models << ClackyEnv.default_model_config
211
+ end
212
+
213
+ # Add CLACKY lite if not in config and env is set
214
+ if !has_lite && ClackyEnv.lite_configured?
215
+ models << ClackyEnv.lite_model_config
216
+ end
217
+
218
+ # Ensure at least one model has type: default
219
+ # If no model has type: default, assign it to the first model
220
+ unless models.any? { |m| m["type"] == "default" }
221
+ models.first["type"] = "default" if models.any?
222
+ end
223
+ end
224
+
225
+ new(models: models)
226
+ end
227
+
228
+ # Save configuration to file
229
+ def save(config_file = CONFIG_FILE)
230
+ config_dir = File.dirname(config_file)
231
+ FileUtils.mkdir_p(config_dir)
232
+ File.write(config_file, to_yaml)
233
+ FileUtils.chmod(0o600, config_file)
234
+ end
235
+
236
+ # Convert to YAML format (top-level array)
237
+ def to_yaml
238
+ YAML.dump(@models)
239
+ end
240
+
241
+ # Check if any model is configured
242
+ def models_configured?
243
+ !@models.empty? && !current_model.nil?
244
+ end
245
+
246
+ # Get current model configuration
247
+ def current_model
248
+ return nil if @models.empty?
249
+ @models[@current_model_index]
250
+ end
251
+
252
+ # Get model by index
253
+ def get_model(index)
254
+ @models[index]
255
+ end
256
+
257
+ # Switch to model by index
258
+ # Updates the type: default to the selected model
259
+ # Returns true if switched, false if index out of range
260
+ def switch_model(index)
261
+ return false if index < 0 || index >= @models.length
262
+
263
+ # Remove type: default from all models
264
+ @models.each { |m| m.delete("type") if m["type"] == "default" }
265
+
266
+ # Set type: default on the selected model
267
+ @models[index]["type"] = "default"
268
+
269
+ # Update current_model_index for backward compatibility
270
+ @current_model_index = index
271
+
272
+ true
273
+ end
274
+
275
+ # List all model names
276
+ def model_names
277
+ @models.map { |m| m["model"] }
278
+ end
279
+
280
+ # Get API key for current model
281
+ def api_key
282
+ current_model&.dig("api_key")
283
+ end
284
+
285
+ # Set API key for current model
286
+ def api_key=(value)
287
+ return unless current_model
288
+ current_model["api_key"] = value
289
+ end
290
+
291
+ # Get base URL for current model
292
+ def base_url
293
+ current_model&.dig("base_url")
294
+ end
295
+
296
+ # Set base URL for current model
297
+ def base_url=(value)
298
+ return unless current_model
299
+ current_model["base_url"] = value
300
+ end
301
+
302
+ # Get model name for current model
303
+ def model_name
304
+ current_model&.dig("model")
305
+ end
306
+
307
+ # Set model name for current model
308
+ def model_name=(value)
309
+ return unless current_model
310
+ current_model["model"] = value
311
+ end
312
+
313
+ # Check if should use Anthropic format for current model
314
+ def anthropic_format?
315
+ current_model&.dig("anthropic_format") || false
316
+ end
317
+
318
+ # Add a new model configuration
319
+ def add_model(model:, api_key:, base_url:, anthropic_format: false, type: nil)
320
+ @models << {
321
+ "api_key" => api_key,
322
+ "base_url" => base_url,
323
+ "model" => model,
324
+ "anthropic_format" => anthropic_format,
325
+ "type" => type
326
+ }.compact
327
+ end
328
+
329
+ # Find model by type (default or lite)
330
+ # Returns the model hash or nil if not found
331
+ def find_model_by_type(type)
332
+ @models.find { |m| m["type"] == type }
333
+ end
334
+
335
+ # Get the default model (type: default)
336
+ # Falls back to current_model for backward compatibility
337
+ def default_model
338
+ find_model_by_type("default") || current_model
339
+ end
340
+
341
+ # Get the lite model (type: lite)
342
+ # Returns nil if no lite model configured
343
+ def lite_model
344
+ find_model_by_type("lite")
21
345
  end
22
346
 
347
+ # Get current model configuration
348
+ # Looks for type: default first, falls back to current_model_index
349
+ def current_model
350
+ return nil if @models.empty?
351
+ default_model = find_model_by_type("default")
352
+ return default_model if default_model
353
+
354
+ # Fallback to index-based for backward compatibility
355
+ @models[@current_model_index]
356
+ end
357
+
358
+ # Set a model's type (default or lite)
359
+ # Ensures only one model has each type
360
+ # @param index [Integer] the model index
361
+ # @param type [String, nil] "default", "lite", or nil to remove type
362
+ # Returns true if successful
363
+ def set_model_type(index, type)
364
+ return false if index < 0 || index >= @models.length
365
+ return false unless ["default", "lite", nil].include?(type)
23
366
 
367
+ if type
368
+ # Remove type from any other model that has it
369
+ @models.each do |m|
370
+ m.delete("type") if m["type"] == type
371
+ end
372
+
373
+ # Set type on target model
374
+ @models[index]["type"] = type
375
+ else
376
+ # Remove type from target model
377
+ @models[index].delete("type")
378
+ end
379
+
380
+ true
381
+ end
382
+
383
+ # Remove a model by index
384
+ # Returns true if removed, false if index out of range or it's the last model
385
+ def remove_model(index)
386
+ # Don't allow removing the last model
387
+ return false if @models.length <= 1
388
+ return false if index < 0 || index >= @models.length
389
+
390
+ @models.delete_at(index)
391
+
392
+ # Adjust current_model_index if necessary
393
+ if @current_model_index >= @models.length
394
+ @current_model_index = @models.length - 1
395
+ end
396
+
397
+ true
398
+ end
24
399
 
25
400
  def is_plan_only?
26
401
  @permission_mode == :plan_only
27
402
  end
28
403
 
29
- private
30
-
31
- def validate_permission_mode(mode)
404
+ private def validate_permission_mode(mode)
32
405
  mode ||= :confirm_safes
33
406
  mode = mode.to_sym
34
407
 
@@ -39,6 +412,70 @@ module Clacky
39
412
  mode
40
413
  end
41
414
 
415
+ # Parse models from config data
416
+ # Supports new top-level array format and old formats for backward compatibility
417
+ private_class_method def self.parse_models(data)
418
+ models = []
419
+
420
+ # Handle nil or empty data
421
+ return models if data.nil?
42
422
 
423
+ if data.is_a?(Array)
424
+ # New format: top-level array of model configurations
425
+ models = data.map do |m|
426
+ # Convert old name-based format to new model-based format if needed
427
+ if m["name"] && !m["model"]
428
+ m["model"] = m["name"]
429
+ m.delete("name")
430
+ end
431
+ m
432
+ end
433
+ elsif data.is_a?(Hash) && data["models"]
434
+ # Old format with "models:" key
435
+ if data["models"].is_a?(Array)
436
+ # Array under models key
437
+ models = data["models"].map do |m|
438
+ # Convert old name-based format to new model-based format
439
+ if m["name"] && !m["model"]
440
+ m["model"] = m["name"]
441
+ m.delete("name")
442
+ end
443
+ m
444
+ end
445
+ elsif data["models"].is_a?(Hash)
446
+ # Hash format with tier names as keys (very old format)
447
+ data["models"].each do |tier_name, config|
448
+ if config.is_a?(Hash)
449
+ model_config = {
450
+ "api_key" => config["api_key"],
451
+ "base_url" => config["base_url"],
452
+ "model" => config["model_name"] || config["model"] || tier_name,
453
+ "anthropic_format" => config["anthropic_format"] || false
454
+ }
455
+ models << model_config
456
+ elsif config.is_a?(String)
457
+ # Old-style tier with just model name
458
+ model_config = {
459
+ "api_key" => data["api_key"],
460
+ "base_url" => data["base_url"],
461
+ "model" => config,
462
+ "anthropic_format" => data["anthropic_format"] || false
463
+ }
464
+ models << model_config
465
+ end
466
+ end
467
+ end
468
+ elsif data.is_a?(Hash) && data["api_key"]
469
+ # Very old format: single model with global config
470
+ models << {
471
+ "api_key" => data["api_key"],
472
+ "base_url" => data["base_url"],
473
+ "model" => data["model"] || CLAUDE_DEFAULT_MODEL,
474
+ "anthropic_format" => data["anthropic_format"] || false
475
+ }
476
+ end
477
+
478
+ models
479
+ end
43
480
  end
44
481
  end