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
@@ -0,0 +1,371 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'fileutils'
5
+ require 'pathname'
6
+
7
+ # Import skills from external AI tool installations into ~/.clacky/skills/.
8
+ #
9
+ # Supported sources:
10
+ # - OpenClaw: ~/.openclaw/skills/, ~/.openclaw/workspace/skills/,
11
+ # ~/.agents/skills/, ~/.openclaw/workspace/.agents/skills/
12
+ #
13
+ # Each source is imported into a dedicated category subdirectory under ~/.clacky/skills/,
14
+ # e.g. ~/.clacky/skills/openclaw-imports/<skill-name>/. This keeps imported skills
15
+ # isolated from the user's own skills and makes the origin traceable.
16
+ #
17
+ # Usage: ruby import_external_skills.rb [--source <name>] [--dry-run] [--yes]
18
+ #
19
+ # Options:
20
+ # --source <name> Import only from the named source (e.g. "openclaw").
21
+ # Defaults to all supported sources.
22
+ # --dry-run Preview what would be imported without making any changes.
23
+ # --yes Skip confirmation prompt and execute immediately.
24
+ #
25
+ # Exit codes:
26
+ # 0 - success (including "nothing to import" case)
27
+ # 1 - unexpected error
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Base class for a single-source importer
31
+ # ---------------------------------------------------------------------------
32
+ class ExternalSkillsImporter
33
+ # @param target_skills_dir [Pathname] ~/.clacky/skills/
34
+ # @param category_subdir [String] subdirectory name used to group imported skills
35
+ # @param dry_run [Boolean] when true, only preview without making changes
36
+ def initialize(target_skills_dir:, category_subdir:, dry_run: false)
37
+ @target_skills_dir = target_skills_dir
38
+ @target_import_dir = target_skills_dir.join(category_subdir)
39
+ @dry_run = dry_run
40
+ @imported = []
41
+ @errors = []
42
+ end
43
+
44
+ # Run the import for this source.
45
+ # @return [Integer] number of skills imported (or would be imported in dry-run mode)
46
+ def run
47
+ unless source_available?
48
+ puts "[INFO] #{source_label} not found - skipping."
49
+ return 0
50
+ end
51
+
52
+ skills = discover_skills
53
+ if skills.empty?
54
+ puts "[INFO] No #{source_label} skills found - nothing to import."
55
+ return 0
56
+ end
57
+
58
+ skills.each { |skill| process_skill(skill) }
59
+
60
+ @imported.size
61
+ end
62
+
63
+ # Errors encountered during this import run.
64
+ # @return [Array<String>]
65
+ def errors
66
+ @errors.dup
67
+ end
68
+
69
+ # Imported skill records for reporting.
70
+ # @return [Array<Hash>]
71
+ def imported
72
+ @imported.dup
73
+ end
74
+
75
+ # Human-readable name for this source (used in output messages).
76
+ # Subclasses must override.
77
+ # @return [String]
78
+ private def source_label
79
+ raise NotImplementedError
80
+ end
81
+
82
+ # Return true when the source root directory exists on this machine.
83
+ # Subclasses must override.
84
+ # @return [Boolean]
85
+ private def source_available?
86
+ raise NotImplementedError
87
+ end
88
+
89
+ # Discover all valid skill directories from the external source.
90
+ # Each element must be a Hash with at least: { name:, source_dir:, origin: }
91
+ # Subclasses must override.
92
+ # @return [Array<Hash>]
93
+ private def discover_skills
94
+ raise NotImplementedError
95
+ end
96
+
97
+ # Process a single skill: record it for preview, and copy if not in dry-run mode.
98
+ #
99
+ # @param skill [Hash] { name:, source_dir:, origin: }
100
+ private def process_skill(skill)
101
+ name = skill[:name]
102
+ source_dir = Pathname.new(skill[:source_dir])
103
+ dest_dir = @target_import_dir.join(name)
104
+
105
+ action = dest_dir.exist? ? 'updated' : 'imported'
106
+ description = read_description(source_dir.join('SKILL.md'))
107
+
108
+ @imported << {
109
+ name: name,
110
+ action: action,
111
+ description: description,
112
+ dest: dest_dir,
113
+ source_dir: source_dir,
114
+ origin: skill[:origin]
115
+ }
116
+
117
+ return if @dry_run
118
+
119
+ copy_skill(name, source_dir, dest_dir, action)
120
+ rescue StandardError => e
121
+ @errors << "Failed to process '#{name}': #{e.message}"
122
+ end
123
+
124
+ # Copy a single skill directory into @target_import_dir.
125
+ # Existing destinations are removed first so re-running is idempotent.
126
+ #
127
+ # @param name [String]
128
+ # @param source_dir [Pathname]
129
+ # @param dest_dir [Pathname]
130
+ # @param action [String] 'imported' or 'updated'
131
+ private def copy_skill(name, source_dir, dest_dir, action)
132
+ FileUtils.mkdir_p(@target_import_dir)
133
+ FileUtils.rm_rf(dest_dir) if dest_dir.exist?
134
+ FileUtils.mkdir_p(dest_dir)
135
+
136
+ # Copy all contents: SKILL.md, scripts/, assets/, etc.
137
+ source_dir.children.each { |child| FileUtils.cp_r(child, dest_dir) }
138
+ rescue StandardError => e
139
+ @errors << "Failed to import '#{name}': #{e.message}"
140
+ end
141
+
142
+ # Extract the description field from SKILL.md YAML frontmatter.
143
+ # @param skill_file [Pathname]
144
+ # @return [String]
145
+ private def read_description(skill_file)
146
+ return 'No description' unless skill_file.exist?
147
+
148
+ content = skill_file.read
149
+ return $1.strip if content =~ /\A---\s*\n.*?^description:\s*(.+)$/m
150
+
151
+ 'No description'
152
+ rescue StandardError
153
+ 'No description'
154
+ end
155
+ end
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # OpenClaw importer
159
+ # ---------------------------------------------------------------------------
160
+ class OpenClawImporter < ExternalSkillsImporter
161
+ SOURCE_NAME = 'openclaw'
162
+ DEFAULT_OPENCLAW_DIR = File.join(Dir.home, '.openclaw')
163
+
164
+ # @param kwargs forwarded to ExternalSkillsImporter
165
+ def initialize(**kwargs)
166
+ super(category_subdir: 'openclaw-imports', **kwargs)
167
+ @openclaw_dir = Pathname.new(DEFAULT_OPENCLAW_DIR).expand_path
168
+ end
169
+
170
+ private def source_label
171
+ 'OpenClaw (~/.openclaw)'
172
+ end
173
+
174
+ private def source_available?
175
+ @openclaw_dir.exist?
176
+ end
177
+
178
+ # Returns all directories that may contain OpenClaw skills.
179
+ # Each entry is a hash: { root: Pathname, layout: :flat }
180
+ #
181
+ # Mirrors the four sources from hermes openclaw_to_hermes.py:
182
+ # - ~/.openclaw/workspace/skills/ (workspace skills)
183
+ # - ~/.openclaw/skills/ (managed/shared skills)
184
+ # - ~/.agents/skills/ (personal cross-project skills)
185
+ # - ~/.openclaw/workspace/.agents/skills/ (project-level shared skills)
186
+ private def source_dirs
187
+ [
188
+ @openclaw_dir.join('workspace', 'skills'),
189
+ @openclaw_dir.join('skills'),
190
+ Pathname.new(Dir.home).join('.agents', 'skills'),
191
+ @openclaw_dir.join('workspace', '.agents', 'skills')
192
+ ].select(&:exist?)
193
+ end
194
+
195
+ private def discover_skills
196
+ skills = []
197
+
198
+ source_dirs.each do |dir|
199
+ dir.children.select(&:directory?).each do |skill_dir|
200
+ next unless skill_dir.join('SKILL.md').exist?
201
+
202
+ skills << {
203
+ name: skill_dir.basename.to_s,
204
+ source_dir: skill_dir,
205
+ origin: dir.basename.to_s
206
+ }
207
+ end
208
+ end
209
+
210
+ skills.sort_by { |s| s[:name] }
211
+ end
212
+ end
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # Coordinator - runs all enabled importers and prints a combined report
216
+ # ---------------------------------------------------------------------------
217
+ class ExternalSkillsImportRunner
218
+ # Register new importer classes here to add support for more sources.
219
+ IMPORTERS = [OpenClawImporter].freeze
220
+ SOURCES = IMPORTERS.map { |klass| klass::SOURCE_NAME }.freeze
221
+
222
+ # @param sources [Array<String>] subset of SOURCES to run; nil means all
223
+ # @param target_skills_dir [String]
224
+ # @param dry_run [Boolean] when true, only preview without making changes
225
+ # @param yes [Boolean] when true, skip confirmation prompt
226
+ def initialize(sources: nil,
227
+ target_skills_dir: File.join(Dir.home, '.clacky', 'skills'),
228
+ dry_run: false,
229
+ yes: false)
230
+ @sources = (sources || SOURCES) & SOURCES
231
+ @target_skills_dir = Pathname.new(target_skills_dir).expand_path
232
+ @dry_run = dry_run
233
+ @yes = yes
234
+ end
235
+
236
+ def run
237
+ # In dry-run mode: collect plan and print preview only
238
+ if @dry_run
239
+ importers = build_importers(dry_run: true)
240
+ all_imported = []
241
+ importers.each { |i| i.run; all_imported.concat(i.imported) }
242
+ print_preview(all_imported, dry_run: true)
243
+ return all_imported.size
244
+ end
245
+
246
+ # Normal mode: collect plan first, show preview, then confirm
247
+ preview_importers = build_importers(dry_run: true)
248
+ all_preview = []
249
+ preview_importers.each { |i| i.run; all_preview.concat(i.imported) }
250
+
251
+ if all_preview.empty?
252
+ puts 'Nothing to import.'
253
+ return 0
254
+ end
255
+
256
+ print_preview(all_preview, dry_run: false)
257
+
258
+ unless @yes || confirm?
259
+ puts 'Import cancelled.'
260
+ return 0
261
+ end
262
+
263
+ # Execute the actual import
264
+ importers = build_importers(dry_run: false)
265
+ all_imported = []
266
+ all_errors = []
267
+
268
+ importers.each do |importer|
269
+ importer.run
270
+ all_imported.concat(importer.imported)
271
+ all_errors.concat(importer.errors)
272
+ end
273
+
274
+ print_summary(all_imported, all_errors)
275
+ all_imported.size
276
+ end
277
+
278
+ private def build_importers(dry_run:)
279
+ common = { target_skills_dir: @target_skills_dir, dry_run: dry_run }
280
+
281
+ IMPORTERS
282
+ .select { |klass| @sources.include?(klass::SOURCE_NAME) }
283
+ .map { |klass| klass.new(**common) }
284
+ end
285
+
286
+ # Print a Hermes-style preview of what would be / will be imported.
287
+ # @param skills [Array<Hash>]
288
+ # @param dry_run [Boolean]
289
+ private def print_preview(skills, dry_run:)
290
+ if dry_run
291
+ puts 'Dry Run Results'
292
+ puts ' No files will be modified. This is a preview of what would happen.'
293
+ else
294
+ puts 'Import Preview'
295
+ puts ' The following skills will be imported/updated:'
296
+ end
297
+ puts
298
+
299
+ if skills.empty?
300
+ puts ' (nothing to import)'
301
+ else
302
+ label_width = skills.map { |s| s[:origin].length }.max || 0
303
+ skills.each do |s|
304
+ action_marker = s[:action] == 'updated' ? '~' : '✓'
305
+ puts " #{action_marker} Would import: #{s[:origin].ljust(label_width)} → #{s[:dest]}"
306
+ end
307
+ end
308
+
309
+ puts
310
+ puts " Summary: #{skills.size} skill(s) would be #{dry_run ? 'imported' : 'imported/updated'}"
311
+ puts
312
+ end
313
+
314
+ # Print summary after actual import.
315
+ private def print_summary(imported, errors)
316
+ puts '=' * 60
317
+
318
+ if imported.empty? && errors.empty?
319
+ puts 'Nothing was imported.'
320
+ elsif imported.any?
321
+ puts "Import complete! #{imported.size} skill(s) ready:\n\n"
322
+ imported.each do |s|
323
+ action_label = s[:action] == 'updated' ? '[updated]' : '[new]'
324
+ puts " #{action_label} #{s[:name]}"
325
+ puts " #{s[:description]}"
326
+ puts " -> #{s[:dest]}"
327
+ puts
328
+ end
329
+ puts 'Skills will be available automatically next time Clacky starts.'
330
+ end
331
+
332
+ if errors.any?
333
+ puts 'Errors:'
334
+ errors.each { |e| puts " - #{e}" }
335
+ end
336
+
337
+ puts '=' * 60
338
+ end
339
+
340
+ # Prompt user for confirmation.
341
+ # @return [Boolean]
342
+ private def confirm?
343
+ print 'Proceed with import? [y/N] '
344
+ $stdout.flush
345
+ answer = $stdin.gets&.strip&.downcase
346
+ answer == 'y' || answer == 'yes'
347
+ end
348
+ end
349
+
350
+ # -- Entry point ------------------------------------------------------------
351
+ if __FILE__ == $PROGRAM_NAME
352
+ require 'optparse'
353
+
354
+ options = {}
355
+
356
+ OptionParser.new do |opts|
357
+ opts.banner = "Usage: #{File.basename($PROGRAM_NAME)} [options]"
358
+ opts.on('--source NAME',
359
+ "Import only from NAME (e.g. openclaw). Supported: #{ExternalSkillsImportRunner::SOURCES.join(', ')}") do |name|
360
+ options[:sources] = [name]
361
+ end
362
+ opts.on('--dry-run', 'Preview what would be imported without making any changes.') do
363
+ options[:dry_run] = true
364
+ end
365
+ opts.on('--yes', '-y', 'Skip confirmation prompt and execute immediately.') do
366
+ options[:yes] = true
367
+ end
368
+ end.parse!
369
+
370
+ ExternalSkillsImportRunner.new(**options).run
371
+ end
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Install builtin skills into ~/.clacky/skills/.
5
+ #
6
+ # Fetches the server-curated builtin list from GET /api/v1/skills/builtin on
7
+ # the openclacky platform (public, no auth), then downloads and installs each
8
+ # skill's zip package in parallel (5 workers, 30s total timeout).
9
+ #
10
+ # The "builtin" whitelist is enforced server-side — this script takes no
11
+ # filter flags. Admin toggles the `builtin` flag per skill on the platform.
12
+ #
13
+ # Called by onboard skill: `ruby install_builtin_skills.rb`
14
+ #
15
+ # Output:
16
+ # - Diagnostics → STDERR
17
+ # - Last line of STDOUT → JSON: {"installed":N,"attempted":N,"skipped_existing":N}
18
+ # - Exit code: always 0
19
+
20
+ require 'uri'
21
+ require 'net/http'
22
+ require 'json'
23
+ require 'timeout'
24
+
25
+ # Reuse the downloader/extractor/installer from the skill-add skill.
26
+ # Physical relocation to lib/clacky/ is deferred until a third caller appears.
27
+ require_relative '../../skill-add/scripts/install_from_zip'
28
+
29
+ class BuiltinSkillsInstaller
30
+ PRIMARY_HOST = ENV.fetch('CLACKY_LICENSE_SERVER', 'https://www.openclacky.com')
31
+ FALLBACK_HOST = 'https://openclacky.up.railway.app'
32
+ API_HOSTS = ENV['CLACKY_LICENSE_SERVER'] ? [PRIMARY_HOST] : [PRIMARY_HOST, FALLBACK_HOST]
33
+ API_PATH = '/api/v1/skills/builtin'
34
+ API_OPEN_TIMEOUT = 5
35
+ API_READ_TIMEOUT = 10
36
+ CONCURRENCY = 5
37
+
38
+ def initialize
39
+ @target_dir = File.join(Dir.home, '.clacky', 'skills')
40
+ @per_skill_timeout = 10
41
+ @total_timeout = 30
42
+
43
+ @installed = 0
44
+ @skipped_existing = 0
45
+ @attempted = 0
46
+ @errors = []
47
+ @mutex = Mutex.new
48
+ end
49
+
50
+ def run
51
+ skills = fetch_skill_list
52
+ if skills.nil? || skills.empty?
53
+ emit_summary
54
+ return
55
+ end
56
+
57
+ install_concurrently(skills)
58
+ ensure
59
+ emit_summary
60
+ end
61
+
62
+ # --- Internals -------------------------------------------------------------
63
+
64
+ # Returns an array of skill hashes, or nil on total failure.
65
+ private def fetch_skill_list
66
+ API_HOSTS.each do |host|
67
+ begin
68
+ uri = URI.parse(host + API_PATH)
69
+ Net::HTTP.start(uri.host, uri.port,
70
+ use_ssl: uri.scheme == 'https',
71
+ open_timeout: API_OPEN_TIMEOUT,
72
+ read_timeout: API_READ_TIMEOUT) do |http|
73
+ response = http.request(Net::HTTP::Get.new(uri.request_uri))
74
+ if response.code.to_i == 200
75
+ payload = JSON.parse(response.body)
76
+ return Array(payload['skills'])
77
+ else
78
+ @errors << "API #{host}: HTTP #{response.code}"
79
+ end
80
+ end
81
+ rescue StandardError => e
82
+ @errors << "API #{host}: #{e.class}: #{e.message}"
83
+ end
84
+ end
85
+ nil
86
+ end
87
+
88
+ # Install skills in parallel, bounded by CONCURRENCY and @total_timeout.
89
+ # Workers pull from a shared queue and self-check the deadline, so the
90
+ # global timeout is enforced without killing threads mid-download (which
91
+ # would leak temp dirs). Whatever finishes before the deadline stays
92
+ # installed; the rest is recovered on the next onboard run via skip_if_exists.
93
+ private def install_concurrently(skills)
94
+ queue = Queue.new
95
+ skills.each { |s| queue << s }
96
+
97
+ deadline = Time.now + @total_timeout
98
+ worker_pool = [CONCURRENCY, skills.size].min
99
+
100
+ workers = Array.new(worker_pool) do
101
+ Thread.new do
102
+ loop do
103
+ break if Time.now >= deadline
104
+ skill = queue.pop(true) rescue nil # non-blocking pop
105
+ break if skill.nil?
106
+ install_one(skill)
107
+ end
108
+ end
109
+ end
110
+
111
+ workers.each(&:join)
112
+
113
+ # If the deadline cut us off with items still in the queue, record it.
114
+ remaining = queue.size
115
+ if remaining.positive?
116
+ @mutex.synchronize do
117
+ @errors << "overall timeout after #{@total_timeout}s " \
118
+ "(installed=#{@installed}, attempted=#{@attempted}, remaining=#{remaining})"
119
+ end
120
+ end
121
+ end
122
+
123
+ # Install one skill entry (hash from the API payload).
124
+ # Bounded by @per_skill_timeout; any failure is swallowed into @errors.
125
+ # Thread-safe: all shared state writes go through @mutex.
126
+ private def install_one(skill)
127
+ name = skill['name'].to_s
128
+ download_url = skill['download_url'].to_s
129
+
130
+ @mutex.synchronize { @attempted += 1 }
131
+
132
+ if name.empty? || download_url.empty?
133
+ @mutex.synchronize do
134
+ @errors << "skill payload missing name or download_url: #{skill.inspect}"
135
+ end
136
+ return
137
+ end
138
+
139
+ Timeout.timeout(@per_skill_timeout) do
140
+ installer = ZipSkillInstaller.new(
141
+ download_url,
142
+ skill_name: name,
143
+ target_dir: @target_dir,
144
+ skip_if_exists: true
145
+ )
146
+ result = installer.perform
147
+ @mutex.synchronize do
148
+ @installed += result[:installed].size
149
+ @skipped_existing += result[:skipped].size
150
+ @errors.concat(result[:errors]) if result[:errors].any?
151
+ end
152
+ end
153
+ rescue Timeout::Error
154
+ @mutex.synchronize { @errors << "#{name}: install timeout after #{@per_skill_timeout}s" }
155
+ rescue StandardError => e
156
+ @mutex.synchronize { @errors << "#{name}: #{e.class}: #{e.message}" }
157
+ end
158
+
159
+ # Diagnostics to stderr; single-line JSON summary to stdout.
160
+ # The caller (onboard) should parse the LAST stdout line.
161
+ private def emit_summary
162
+ unless @errors.empty?
163
+ warn '[install_builtin_skills] non-fatal errors:'
164
+ @errors.each { |e| warn " - #{e}" }
165
+ end
166
+ puts JSON.generate(
167
+ installed: @installed,
168
+ attempted: @attempted,
169
+ skipped_existing: @skipped_existing
170
+ )
171
+ end
172
+ end
173
+
174
+ # ── Entry point ───────────────────────────────────────────────────────────────
175
+ BuiltinSkillsInstaller.new.run if __FILE__ == $0
@@ -19,7 +19,7 @@ require 'find'
19
19
  class ZipSkillInstaller
20
20
  ZIP_URL_PATTERN = %r{^https?://.+\.zip(\?.*)?$}i
21
21
 
22
- def initialize(zip_source, skill_name: nil, target_dir: nil)
22
+ def initialize(zip_source, skill_name: nil, target_dir: nil, skip_if_exists: false)
23
23
  @zip_source = zip_source
24
24
  @local_path = local_zip_path?(zip_source)
25
25
  # skill_name can be provided explicitly (e.g. slug from the store API).
@@ -27,45 +27,72 @@ class ZipSkillInstaller
27
27
  # "ui-ux-pro-max-1.0.0.zip" → "ui-ux-pro-max".
28
28
  @skill_name = skill_name || infer_skill_name(zip_source)
29
29
  @target_dir = target_dir || File.join(Dir.home, '.clacky', 'skills')
30
+ # When true, existing skill directories are preserved and the install for
31
+ # that specific skill is skipped (recorded in @skipped_skills).
32
+ # Default false keeps the legacy "overwrite" behaviour for `install`.
33
+ @skip_if_exists = skip_if_exists
34
+ # Suppresses user-facing puts for programmatic callers (set by `perform`).
35
+ @silent = false
30
36
  @installed_skills = []
31
- @errors = []
37
+ @skipped_skills = []
38
+ @errors = []
32
39
  end
33
40
 
34
- # Main installation entry point.
41
+ # Programmatic entry point for library-style callers (e.g. onboard pre-install).
42
+ #
43
+ # Unlike `install`, this method:
44
+ # - does NOT print user-facing output
45
+ # - does NOT call `exit` on failure (raises instead)
46
+ # - returns a result hash: { installed: [...], skipped: [...], errors: [...] }
47
+ #
48
+ # The caller is responsible for rendering feedback and deciding whether any
49
+ # error is fatal.
50
+ def perform
51
+ @silent = true
52
+ do_install
53
+ { installed: @installed_skills, skipped: @skipped_skills, errors: @errors }
54
+ end
55
+
56
+ # Main installation entry point (CLI). Prints progress, prints a final
57
+ # report, and calls `exit` on failure. Use `perform` for programmatic use.
35
58
  def install
59
+ do_install
60
+ report_results
61
+ rescue ArgumentError => e
62
+ puts "Error: #{e.message}"
63
+ exit 1
64
+ rescue StandardError => e
65
+ puts "Error: Installation failed: #{e.message}"
66
+ exit 1
67
+ end
68
+
69
+ # Shared core used by both `install` (CLI) and `perform` (library).
70
+ # Raises on invalid input; the caller decides how to surface errors.
71
+ private def do_install
36
72
  if @local_path
37
73
  # Install directly from a local zip file — no download needed.
38
- # Expand tilde in path (e.g. ~/Downloads/skill.zip)
74
+ # Expand tilde in path (e.g. ~/Downloads/skill.zip).
39
75
  expanded = File.expand_path(@zip_source)
40
- raise ArgumentError, "File not found: #{@zip_source}" unless File.exist?(expanded)
41
- raise ArgumentError, "Not a zip file: #{@zip_source}" unless expanded.end_with?('.zip')
76
+ raise ArgumentError, "File not found: #{@zip_source}" unless File.exist?(expanded)
77
+ raise ArgumentError, "Not a zip file: #{@zip_source}" unless expanded.end_with?('.zip')
42
78
 
43
79
  Dir.mktmpdir('clacky-zip-') do |tmpdir|
44
80
  extract_zip(expanded, tmpdir)
45
- extracted_dir = File.join(tmpdir, 'extracted')
46
- discover_and_install_skills(extracted_dir)
81
+ discover_and_install_skills(File.join(tmpdir, 'extracted'))
47
82
  end
48
83
  else
49
84
  # Install from a remote URL.
50
85
  unless valid_zip_url?
51
- raise ArgumentError, "Invalid zip source: #{@zip_source}\nProvide an http(s) URL ending with .zip, or an absolute path to a local zip file."
86
+ raise ArgumentError, "Invalid zip source: #{@zip_source}\n" \
87
+ "Provide an http(s) URL ending with .zip, or an absolute path to a local zip file."
52
88
  end
53
89
 
54
90
  Dir.mktmpdir('clacky-zip-') do |tmpdir|
55
91
  zip_path = download_zip(tmpdir)
56
92
  extract_zip(zip_path, tmpdir)
57
- extracted_dir = File.join(tmpdir, 'extracted')
58
- discover_and_install_skills(extracted_dir)
93
+ discover_and_install_skills(File.join(tmpdir, 'extracted'))
59
94
  end
60
95
  end
61
-
62
- report_results
63
- rescue ArgumentError => e
64
- puts "❌ #{e.message}"
65
- exit 1
66
- rescue StandardError => e
67
- puts "❌ Installation failed: #{e.message}"
68
- exit 1
69
96
  end
70
97
 
71
98
  # Return true if the source looks like a local file path (absolute or relative ending in .zip).
@@ -93,8 +120,10 @@ class ZipSkillInstaller
93
120
 
94
121
  # Download the zip file to tmpdir and return its local path.
95
122
  private def download_zip(tmpdir)
96
- puts "⬇️ Downloading skill package..."
97
- puts " #{@zip_source}"
123
+ unless @silent
124
+ puts "Downloading skill package..."
125
+ puts " #{@zip_source}"
126
+ end
98
127
 
99
128
  zip_path = File.join(tmpdir, 'skill.zip')
100
129
  uri = URI.parse(@zip_source)
@@ -129,7 +158,7 @@ class ZipSkillInstaller
129
158
 
130
159
  # Extract the zip archive into <tmpdir>/extracted/.
131
160
  private def extract_zip(zip_path, tmpdir)
132
- puts "📂 Extracting package..."
161
+ puts "Extracting package..." unless @silent
133
162
  extracted_dir = File.join(tmpdir, 'extracted')
134
163
  FileUtils.mkdir_p(extracted_dir)
135
164
 
@@ -184,7 +213,11 @@ class ZipSkillInstaller
184
213
  target_path = File.join(@target_dir, name)
185
214
 
186
215
  if File.exist?(target_path)
187
- puts "♻️ Skill '#{name}' already exists — overwriting..."
216
+ if @skip_if_exists
217
+ @skipped_skills << { name: name, path: target_path, reason: 'already exists' }
218
+ return
219
+ end
220
+ puts "Skill '#{name}' already exists — overwriting..." unless @silent
188
221
  FileUtils.rm_rf(target_path)
189
222
  end
190
223
 
@@ -218,7 +251,7 @@ class ZipSkillInstaller
218
251
  puts "\n" + "=" * 60
219
252
 
220
253
  if @installed_skills.empty?
221
- puts "No skills were installed."
254
+ puts "No skills were installed."
222
255
  if @errors.any?
223
256
  puts "\nErrors:"
224
257
  @errors.each { |e| puts " • #{e}" }
@@ -226,7 +259,7 @@ class ZipSkillInstaller
226
259
  exit 1
227
260
  end
228
261
 
229
- puts "Installation complete!"
262
+ puts "Installation complete!"
230
263
  puts "\nInstalled #{@installed_skills.size} skill(s):\n\n"
231
264
  @installed_skills.each do |skill|
232
265
  puts " ✓ #{skill[:name]}"
@@ -236,7 +269,7 @@ class ZipSkillInstaller
236
269
  end
237
270
 
238
271
  if @errors.any?
239
- puts "⚠️ Warnings:"
272
+ puts "Warnings:"
240
273
  @errors.each { |e| puts " • #{e}" }
241
274
  puts
242
275
  end