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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +39 -0
- data/README.md +87 -53
- data/lib/clacky/agent/cost_tracker.rb +19 -2
- data/lib/clacky/agent/llm_caller.rb +218 -0
- data/lib/clacky/agent/message_compressor_helper.rb +32 -2
- data/lib/clacky/agent.rb +54 -22
- data/lib/clacky/client.rb +44 -5
- data/lib/clacky/default_parsers/pdf_parser.rb +58 -17
- data/lib/clacky/default_parsers/pdf_parser_ocr.py +103 -0
- data/lib/clacky/default_parsers/pdf_parser_plumber.py +62 -0
- data/lib/clacky/default_skills/deploy/SKILL.md +201 -77
- data/lib/clacky/default_skills/new/SKILL.md +3 -114
- data/lib/clacky/default_skills/onboard/SKILL.md +349 -133
- data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +371 -0
- data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +175 -0
- data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +59 -26
- data/lib/clacky/message_format/anthropic.rb +72 -8
- data/lib/clacky/message_format/bedrock.rb +6 -3
- data/lib/clacky/providers.rb +146 -3
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +14 -0
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +10 -0
- data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +1 -0
- data/lib/clacky/server/channel/channel_manager.rb +12 -4
- data/lib/clacky/server/channel/channel_ui_controller.rb +8 -2
- data/lib/clacky/server/http_server.rb +746 -13
- data/lib/clacky/server/session_registry.rb +55 -24
- data/lib/clacky/skill.rb +10 -9
- data/lib/clacky/skill_loader.rb +23 -11
- data/lib/clacky/tools/file_reader.rb +232 -127
- data/lib/clacky/tools/security.rb +42 -64
- data/lib/clacky/tools/terminal/persistent_session.rb +15 -4
- data/lib/clacky/tools/terminal/safe_rm.sh +106 -0
- data/lib/clacky/tools/terminal/session_manager.rb +8 -3
- data/lib/clacky/tools/terminal.rb +263 -16
- data/lib/clacky/ui2/layout_manager.rb +8 -1
- data/lib/clacky/ui2/output_buffer.rb +83 -23
- data/lib/clacky/ui2/ui_controller.rb +74 -7
- data/lib/clacky/utils/file_processor.rb +14 -40
- data/lib/clacky/utils/model_pricing.rb +215 -0
- data/lib/clacky/utils/parser_manager.rb +70 -6
- data/lib/clacky/utils/string_matcher.rb +23 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +673 -9
- data/lib/clacky/web/app.js +40 -1608
- data/lib/clacky/web/i18n.js +209 -0
- data/lib/clacky/web/index.html +166 -2
- data/lib/clacky/web/onboard.js +77 -1
- data/lib/clacky/web/profile.js +442 -0
- data/lib/clacky/web/sessions.js +1034 -2
- data/lib/clacky/web/settings.js +127 -6
- data/lib/clacky/web/sidebar.js +39 -0
- data/lib/clacky/web/skills.js +460 -0
- data/lib/clacky/web/trash.js +343 -0
- data/lib/clacky/web/ws-dispatcher.js +255 -0
- data/lib/clacky.rb +5 -3
- metadata +16 -17
- data/lib/clacky/clacky_auth_client.rb +0 -152
- data/lib/clacky/clacky_cloud_config.rb +0 -123
- data/lib/clacky/cloud_project_client.rb +0 -169
- data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +0 -1377
- data/lib/clacky/default_skills/deploy/tools/check_health.rb +0 -116
- data/lib/clacky/default_skills/deploy/tools/create_database_service.rb +0 -341
- data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +0 -99
- data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +0 -77
- data/lib/clacky/default_skills/deploy/tools/list_services.rb +0 -67
- data/lib/clacky/default_skills/deploy/tools/report_deploy_status.rb +0 -67
- data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +0 -189
- data/lib/clacky/default_skills/new/scripts/cloud_project_init.sh +0 -74
- 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
|
-
@
|
|
37
|
+
@skipped_skills = []
|
|
38
|
+
@errors = []
|
|
32
39
|
end
|
|
33
40
|
|
|
34
|
-
#
|
|
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}"
|
|
41
|
-
raise ArgumentError, "Not a zip file: #{@zip_source}"
|
|
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
|
-
|
|
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}\
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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 "
|
|
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
|
-
|
|
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 "
|
|
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 "
|
|
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 "
|
|
272
|
+
puts "Warnings:"
|
|
240
273
|
@errors.each { |e| puts " • #{e}" }
|
|
241
274
|
puts
|
|
242
275
|
end
|