openclacky 1.3.6 → 1.3.8
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 +54 -0
- data/lib/clacky/agent/hook_manager.rb +10 -1
- data/lib/clacky/agent/system_prompt_builder.rb +1 -1
- data/lib/clacky/agent.rb +17 -3
- data/lib/clacky/agent_profile.rb +124 -53
- data/lib/clacky/billing/billing_store.rb +3 -2
- data/lib/clacky/brand_config.rb +309 -6
- data/lib/clacky/cli.rb +4 -57
- data/lib/clacky/default_extensions/coding/agents/coding/avatar.png +0 -0
- data/lib/clacky/default_extensions/coding/ext.yml +19 -0
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/avatar.png +0 -0
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +95 -0
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +254 -0
- data/lib/clacky/default_extensions/ext-studio/ext.yml +33 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +1288 -0
- data/lib/clacky/default_extensions/ext-studio/skills/ext-debug/SKILL.md +71 -0
- data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +74 -0
- data/lib/clacky/default_extensions/ext-studio/skills/ext-scaffold/SKILL.md +65 -0
- data/lib/clacky/default_extensions/general/agents/general/avatar.png +0 -0
- data/lib/clacky/default_extensions/general/ext.yml +19 -0
- data/lib/clacky/default_extensions/git/ext.yml +12 -0
- data/lib/clacky/{default_agents/_panels/git/panel.js → default_extensions/git/panels/git/view.js} +4 -5
- data/lib/clacky/default_extensions/meeting/{handler.rb → api/handler.rb} +78 -26
- data/lib/clacky/default_extensions/meeting/ext.yml +15 -0
- data/lib/clacky/default_extensions/meeting/{meeting.js → panels/meeting/view.js} +266 -90
- data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +2 -1
- data/lib/clacky/default_extensions/time_machine/ext.yml +12 -0
- data/lib/clacky/{default_agents/_panels/time_machine/panel.js → default_extensions/time_machine/panels/time_machine/view.js} +58 -47
- data/lib/clacky/default_skills/channel-manager/discord_setup.rb +1 -1
- data/lib/clacky/default_skills/extend-openclacky/SKILL.md +83 -18
- data/lib/clacky/{api_extension.rb → extension/api_extension.rb} +27 -11
- data/lib/clacky/extension/api_loader.rb +136 -0
- data/lib/clacky/extension/cli_commands.rb +226 -0
- data/lib/clacky/{server/api_extension_dispatcher.rb → extension/dispatcher.rb} +22 -9
- data/lib/clacky/extension/hook_loader.rb +77 -0
- data/lib/clacky/extension/loader.rb +489 -0
- data/lib/clacky/extension/packager.rb +226 -0
- data/lib/clacky/{patch_loader.rb → extension/patch_loader.rb} +45 -0
- data/lib/clacky/extension/scaffold/template_renderer.rb +64 -0
- data/lib/clacky/extension/scaffold/templates/full/README.md.erb +48 -0
- data/lib/clacky/extension/scaffold/templates/full/agents/designer.md +5 -0
- data/lib/clacky/extension/scaffold/templates/full/api/handler.rb.erb +16 -0
- data/lib/clacky/extension/scaffold/templates/full/channels/noop.rb.erb +40 -0
- data/lib/clacky/extension/scaffold/templates/full/ext.yml.erb +41 -0
- data/lib/clacky/extension/scaffold/templates/full/hooks/audit.rb +9 -0
- data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +194 -0
- data/lib/clacky/extension/scaffold/templates/full/patches/audit.rb +15 -0
- data/lib/clacky/extension/scaffold/templates/full/skills/__slug__-skill/SKILL.md.erb +8 -0
- data/lib/clacky/extension/scaffold/templates/hello/api/handler.rb.erb +9 -0
- data/lib/clacky/extension/scaffold/templates/hello/ext.yml.erb +12 -0
- data/lib/clacky/extension/scaffold/templates/hello/panels/hello/view.js.erb +31 -0
- data/lib/clacky/extension/scaffold.rb +55 -0
- data/lib/clacky/extension/verifier.rb +196 -0
- data/lib/clacky/identity.rb +70 -0
- data/lib/clacky/locales/en.rb +19 -1
- data/lib/clacky/locales/zh.rb +19 -1
- data/lib/clacky/platform_http_client.rb +18 -15
- data/lib/clacky/server/channel/extension_adapter_loader.rb +32 -0
- data/lib/clacky/server/channel.rb +6 -0
- data/lib/clacky/server/http_server.rb +439 -146
- data/lib/clacky/server/session_registry.rb +9 -2
- data/lib/clacky/shell_hook_loader.rb +266 -22
- data/lib/clacky/skill_loader.rb +28 -1
- data/lib/clacky/ui2/components/welcome_banner.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +9 -6
- data/lib/clacky/utils/workspace_rules.rb +2 -2
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +1034 -675
- data/lib/clacky/web/app.js +63 -15
- data/lib/clacky/web/auth.js +2 -0
- data/lib/clacky/web/components/notify.js +2 -0
- data/lib/clacky/web/components/sidebar.js +1 -5
- data/lib/clacky/web/core/ext.js +432 -89
- data/lib/clacky/web/features/backup/store.js +1 -0
- data/lib/clacky/web/features/backup/view.js +0 -1
- data/lib/clacky/web/features/brand/view.js +13 -5
- data/lib/clacky/web/features/extensions/store.js +184 -0
- data/lib/clacky/web/features/extensions/view.js +390 -0
- data/lib/clacky/web/features/new-session/store.js +199 -0
- data/lib/clacky/web/features/new-session/view.js +619 -0
- data/lib/clacky/web/features/share/view.js +63 -14
- data/lib/clacky/web/features/skills/store.js +3 -1
- data/lib/clacky/web/features/skills/view.js +1 -1
- data/lib/clacky/web/features/tasks/view.js +12 -0
- data/lib/clacky/web/features/trash/store.js +24 -6
- data/lib/clacky/web/features/trash/view.js +48 -12
- data/lib/clacky/web/features/workspace/store.js +2 -0
- data/lib/clacky/web/features/workspace/view.js +0 -1
- data/lib/clacky/web/i18n.js +152 -106
- data/lib/clacky/web/index.html +172 -190
- data/lib/clacky/web/sessions.js +67 -272
- data/lib/clacky/web/settings.js +1 -1
- data/lib/clacky/web/skills.js +88 -48
- data/lib/clacky/web/theme.js +5 -0
- data/lib/clacky/web/ws-dispatcher.js +12 -2
- data/lib/clacky/web/ws.js +2 -0
- data/lib/clacky.rb +21 -10
- metadata +52 -21
- data/benchmark/runner.rb +0 -441
- data/lib/clacky/api_extension_loader.rb +0 -168
- data/lib/clacky/default_agents/SOUL.md +0 -3
- data/lib/clacky/default_agents/USER.md +0 -1
- data/lib/clacky/default_agents/coding/profile.yml +0 -5
- data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
- data/lib/clacky/default_agents/general/profile.yml +0 -2
- data/lib/clacky/default_extensions/meeting/meta.yml +0 -3
- data/lib/clacky/web/features/creator/store.js +0 -81
- data/lib/clacky/web/features/creator/view.js +0 -380
- /data/lib/clacky/{default_agents → default_extensions/coding/agents}/coding/system_prompt.md +0 -0
- /data/lib/clacky/{default_agents → default_extensions/general/agents}/general/system_prompt.md +0 -0
- /data/lib/clacky/{default_agents/base_prompt.md → prompts/base.md} +0 -0
data/lib/clacky/brand_config.rb
CHANGED
|
@@ -583,13 +583,279 @@ module Clacky
|
|
|
583
583
|
{ success: false, error: "Network error: #{e.message}" }
|
|
584
584
|
end
|
|
585
585
|
|
|
586
|
-
#
|
|
587
|
-
#
|
|
588
|
-
#
|
|
589
|
-
#
|
|
590
|
-
#
|
|
586
|
+
# ── Extension marketplace (creator publishing via device token) ──
|
|
587
|
+
# Extensions authenticate with the device token stored in identity.yml,
|
|
588
|
+
# which binds this device to a platform account. This is independent of any
|
|
589
|
+
# license — publishing only requires the owning user to be a contributor.
|
|
590
|
+
# See platform Api::V1::Client::ExtensionsController.
|
|
591
|
+
|
|
592
|
+
# Upload (publish) a packed extension ZIP to the platform.
|
|
593
|
+
# ext_id: extension slug (matches ext.yml id)
|
|
594
|
+
# zip_data: binary ZIP content produced by `clacky ext pack`
|
|
595
|
+
# force: when true, PATCH an existing extension (new version) instead of POST
|
|
596
|
+
#
|
|
597
|
+
# Returns { success: true, extension: {...} } or
|
|
598
|
+
# { success: false, error: "...", already_exists: Boolean }.
|
|
599
|
+
def upload_extension!(ext_id, zip_data, force: false, status: nil, changelog: nil)
|
|
600
|
+
identity = Clacky::Identity.load
|
|
601
|
+
return { success: false, error: "Device not bound to a platform account" } unless identity.bound?
|
|
602
|
+
|
|
603
|
+
path = if force
|
|
604
|
+
"/api/v1/client/extensions/#{URI.encode_www_form_component(ext_id)}"
|
|
605
|
+
else
|
|
606
|
+
"/api/v1/client/extensions"
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
fields = { "device_token" => identity.device_token }
|
|
610
|
+
fields["status"] = status.to_s if status
|
|
611
|
+
fields["changelog"] = changelog.to_s if changelog
|
|
612
|
+
|
|
613
|
+
body_bytes, boundary = build_multipart(fields, "extension_zip", "#{ext_id}.zip", zip_data)
|
|
614
|
+
|
|
615
|
+
result = if force
|
|
616
|
+
platform_client.multipart_patch(path, body_bytes, boundary, read_timeout: 60)
|
|
617
|
+
else
|
|
618
|
+
platform_client.multipart_post(path, body_bytes, boundary, read_timeout: 60)
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
if result[:success]
|
|
622
|
+
{ success: true, extension: result[:data]["extension"] }
|
|
623
|
+
else
|
|
624
|
+
body = result[:data] || {}
|
|
625
|
+
code = body["code"] || body["error"]
|
|
626
|
+
errors = body["errors"]&.join(", ")
|
|
627
|
+
msg = result[:error] || [code, errors].compact.join(": ")
|
|
628
|
+
msg = "Publish failed" if msg.to_s.strip.empty?
|
|
629
|
+
already_exists = body["code"].to_s.include?("taken") ||
|
|
630
|
+
body["code"].to_s.include?("already") ||
|
|
631
|
+
result[:error].to_s.include?("HTTP 409")
|
|
632
|
+
{ success: false, error: msg, already_exists: already_exists }
|
|
633
|
+
end
|
|
634
|
+
rescue StandardError => e
|
|
635
|
+
{ success: false, error: "Network error: #{e.message}" }
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
# Fetch the creator's own published extensions.
|
|
639
|
+
# Uses GET /api/v1/client/extensions (HMAC-signed, system license only).
|
|
640
|
+
# Returns { success: bool, extensions: [], error: }.
|
|
641
|
+
def fetch_my_extensions!
|
|
642
|
+
identity = Clacky::Identity.load
|
|
643
|
+
return { success: false, error: "Device not bound to a platform account", extensions: [] } unless identity.bound?
|
|
644
|
+
|
|
645
|
+
response = platform_client.get(
|
|
646
|
+
"/api/v1/client/extensions",
|
|
647
|
+
headers: { "Authorization" => "Bearer #{identity.device_token}" }
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
if response[:success]
|
|
651
|
+
{ success: true, extensions: response[:data]["extensions"] || [] }
|
|
652
|
+
else
|
|
653
|
+
{ success: false, error: response[:error] || "Fetch failed", extensions: [] }
|
|
654
|
+
end
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
# Soft-delete (unpublish) one of the creator's extensions by id/slug.
|
|
658
|
+
# Uses DELETE /api/v1/client/extensions/:id. Returns { success:, error: }.
|
|
659
|
+
def delete_extension!(ext_id)
|
|
660
|
+
identity = Clacky::Identity.load
|
|
661
|
+
return { success: false, error: "Device not bound to a platform account" } unless identity.bound?
|
|
662
|
+
|
|
663
|
+
path = "/api/v1/client/extensions/#{URI.encode_www_form_component(ext_id)}"
|
|
664
|
+
response = platform_client.delete(
|
|
665
|
+
path,
|
|
666
|
+
headers: { "Authorization" => "Bearer #{identity.device_token}" }
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
if response[:success]
|
|
670
|
+
{ success: true }
|
|
671
|
+
else
|
|
672
|
+
{ success: false, error: response[:error] || "Delete failed" }
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
# Search the public extension marketplace. Anonymous — no license required.
|
|
677
|
+
# Uses GET /api/v1/extensions. Returns { success:, extensions: [], error: }.
|
|
678
|
+
def search_extensions!(query: nil, sort: nil)
|
|
679
|
+
params = {}
|
|
680
|
+
params["q"] = query if query && !query.to_s.strip.empty?
|
|
681
|
+
params["sort"] = sort if sort && !sort.to_s.strip.empty?
|
|
682
|
+
qs = params.empty? ? "" : "?#{URI.encode_www_form(params)}"
|
|
683
|
+
response = platform_client.get("/api/v1/extensions#{qs}")
|
|
684
|
+
|
|
685
|
+
if response[:success]
|
|
686
|
+
{ success: true, extensions: response[:data]["extensions"] || [] }
|
|
687
|
+
else
|
|
688
|
+
{ success: false, error: response[:error] || "Search failed", extensions: [] }
|
|
689
|
+
end
|
|
690
|
+
rescue StandardError => e
|
|
691
|
+
{ success: false, error: "Network error: #{e.message}", extensions: [] }
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
# Fetch a single public marketplace extension's detail (contributes +
|
|
695
|
+
# version history). Anonymous, no license required. Returns
|
|
696
|
+
# { success:, extension:, error: }.
|
|
697
|
+
def extension_detail!(id)
|
|
698
|
+
response = platform_client.get("/api/v1/extensions/#{URI.encode_www_form_component(id.to_s)}")
|
|
699
|
+
|
|
700
|
+
if response[:success]
|
|
701
|
+
{ success: true, extension: response[:data]["extension"] }
|
|
702
|
+
else
|
|
703
|
+
{ success: false, error: response[:error] || "Not found" }
|
|
704
|
+
end
|
|
705
|
+
rescue StandardError => e
|
|
706
|
+
{ success: false, error: "Network error: #{e.message}" }
|
|
707
|
+
end
|
|
708
|
+
# Extensions bundled into the activated license's distribution are free and
|
|
709
|
+
# unencrypted. They are fetched over the same license-HMAC scheme as brand
|
|
710
|
+
# skills and installed into the ExtensionLoader `installed` layer.
|
|
711
|
+
|
|
712
|
+
# Fetch the extensions bundled into the activated license's distribution.
|
|
713
|
+
# Requires an activated license. Returns { success:, extensions: [], error: }.
|
|
714
|
+
# Each extension carries name + latest_version.download_url so
|
|
715
|
+
# install_brand_extension! can consume it directly.
|
|
716
|
+
def fetch_brand_extensions!
|
|
717
|
+
return { success: false, error: "License not activated", extensions: [] } unless activated?
|
|
718
|
+
|
|
719
|
+
user_id = parse_user_id_from_key(@license_key)
|
|
720
|
+
ts = Time.now.utc.to_i.to_s
|
|
721
|
+
nonce = SecureRandom.hex(16)
|
|
722
|
+
message = "#{user_id}:#{@device_id}:#{ts}:#{nonce}"
|
|
723
|
+
|
|
724
|
+
payload = {
|
|
725
|
+
key_hash: Digest::SHA256.hexdigest(@license_key),
|
|
726
|
+
user_id: user_id.to_s,
|
|
727
|
+
device_id: @device_id,
|
|
728
|
+
timestamp: ts,
|
|
729
|
+
nonce: nonce,
|
|
730
|
+
signature: OpenSSL::HMAC.hexdigest("SHA256", @license_key, message)
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
response = api_post("/api/v1/licenses/extensions", payload)
|
|
734
|
+
|
|
735
|
+
if response[:success]
|
|
736
|
+
body = response[:data]
|
|
737
|
+
installed = installed_brand_extensions
|
|
738
|
+
extensions = (body["extensions"] || []).map do |ext|
|
|
739
|
+
slug = ext["name"].to_s
|
|
740
|
+
local = installed[slug]
|
|
741
|
+
latest_ver = (ext["latest_version"] || {})["version"] || ext["version"]
|
|
742
|
+
ext.merge(
|
|
743
|
+
"installed_version" => local ? local["version"] : nil,
|
|
744
|
+
"needs_update" => local ? version_older?(local["version"], latest_ver) : true
|
|
745
|
+
)
|
|
746
|
+
end
|
|
747
|
+
{ success: true, extensions: extensions, expires_at: body["expires_at"] }
|
|
748
|
+
else
|
|
749
|
+
{ success: false, error: response[:error] || "Failed to fetch extensions", extensions: [] }
|
|
750
|
+
end
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
# Install (or update) a single brand extension by downloading its zip into
|
|
754
|
+
# the ExtensionLoader `installed` layer.
|
|
755
|
+
# ext_info: a hash from fetch_brand_extensions! with at least
|
|
756
|
+
# name + latest_version.download_url + version.
|
|
757
|
+
def install_brand_extension!(ext_info)
|
|
758
|
+
slug = ext_info["name"].to_s.strip
|
|
759
|
+
version = (ext_info["latest_version"] || {})["version"] || ext_info["version"]
|
|
760
|
+
url = (ext_info["latest_version"] || {})["download_url"]
|
|
761
|
+
|
|
762
|
+
return { success: false, error: "Missing extension name" } if slug.empty?
|
|
763
|
+
return { success: false, error: "No download URL" } if url.nil? || url.strip.empty?
|
|
764
|
+
|
|
765
|
+
Clacky::ExtensionPackager.install(url, force: true)
|
|
766
|
+
record_installed_extension(slug, version)
|
|
767
|
+
{ success: true, name: slug, version: version }
|
|
768
|
+
rescue StandardError => e
|
|
769
|
+
{ success: false, error: e.message }
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
# Synchronise brand extensions in the background for activated installs.
|
|
773
|
+
# Mirrors sync_brand_skills_async! but installs into the extension layer.
|
|
774
|
+
# Unlike brand skills, new extensions are auto-installed because a bundled
|
|
775
|
+
# extension is chosen by the brand administrator, not the end user.
|
|
591
776
|
#
|
|
592
|
-
#
|
|
777
|
+
# @return [Thread, nil]
|
|
778
|
+
def sync_brand_extensions_async!(on_complete: nil)
|
|
779
|
+
return nil unless activated?
|
|
780
|
+
return nil if ENV["CLACKY_TEST"] == "1"
|
|
781
|
+
|
|
782
|
+
Thread.new do
|
|
783
|
+
Thread.current.abort_on_exception = false
|
|
784
|
+
|
|
785
|
+
begin
|
|
786
|
+
result = fetch_brand_extensions!
|
|
787
|
+
next unless result[:success]
|
|
788
|
+
|
|
789
|
+
remote_names = result[:extensions].map { |e| e["name"] }
|
|
790
|
+
installed_brand_extensions.each_key do |local_name|
|
|
791
|
+
delete_brand_extension!(local_name) unless remote_names.include?(local_name)
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
to_install = result[:extensions].select { |e| e["needs_update"] }
|
|
795
|
+
results = to_install.map { |ext_info| install_brand_extension!(ext_info) }
|
|
796
|
+
|
|
797
|
+
Clacky::ExtensionLoader.invalidate_cache! unless results.empty?
|
|
798
|
+
on_complete&.call(results)
|
|
799
|
+
rescue StandardError
|
|
800
|
+
# Background sync failures are intentionally swallowed.
|
|
801
|
+
end
|
|
802
|
+
end
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
# Path to the JSON registry tracking installed brand extension versions.
|
|
806
|
+
def brand_extensions_registry_path
|
|
807
|
+
File.join(File.expand_path(Clacky::ExtensionLoader::INSTALLED_DIR), "brand_extensions.json")
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
# Installed brand extensions keyed by ext_id => { "version" => "..." }.
|
|
811
|
+
# Entries whose on-disk container no longer exists are pruned.
|
|
812
|
+
def installed_brand_extensions
|
|
813
|
+
path = brand_extensions_registry_path
|
|
814
|
+
return {} unless File.exist?(path)
|
|
815
|
+
|
|
816
|
+
raw = JSON.parse(File.read(path))
|
|
817
|
+
valid = {}
|
|
818
|
+
changed = false
|
|
819
|
+
raw.each do |name, meta|
|
|
820
|
+
if Dir.exist?(File.join(File.dirname(path), name))
|
|
821
|
+
valid[name] = meta
|
|
822
|
+
else
|
|
823
|
+
changed = true
|
|
824
|
+
end
|
|
825
|
+
end
|
|
826
|
+
File.write(path, JSON.generate(valid)) if changed
|
|
827
|
+
valid
|
|
828
|
+
rescue StandardError
|
|
829
|
+
{}
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
# Remove a single installed brand extension by id (files + registry entry).
|
|
833
|
+
def delete_brand_extension!(ext_id)
|
|
834
|
+
ext_dir = File.join(File.expand_path(Clacky::ExtensionLoader::INSTALLED_DIR), ext_id)
|
|
835
|
+
FileUtils.rm_rf(ext_dir) if Dir.exist?(ext_dir)
|
|
836
|
+
|
|
837
|
+
path = brand_extensions_registry_path
|
|
838
|
+
if File.exist?(path)
|
|
839
|
+
registry = JSON.parse(File.read(path))
|
|
840
|
+
registry.delete(ext_id)
|
|
841
|
+
File.write(path, JSON.generate(registry))
|
|
842
|
+
end
|
|
843
|
+
Clacky::ExtensionLoader.invalidate_cache!
|
|
844
|
+
rescue StandardError
|
|
845
|
+
# Deletion errors are non-fatal.
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
private def record_installed_extension(ext_id, version)
|
|
849
|
+
path = brand_extensions_registry_path
|
|
850
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
851
|
+
registry = File.exist?(path) ? (JSON.parse(File.read(path)) rescue {}) : {}
|
|
852
|
+
registry[ext_id] = { "version" => version.to_s }
|
|
853
|
+
File.write(path, JSON.generate(registry))
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
public
|
|
857
|
+
|
|
858
|
+
# Fetch the public store skills list from the OpenClacky Cloud API.
|
|
593
859
|
# Uses GET /api/v1/client/skills (HMAC-signed, system license only).
|
|
594
860
|
# Returns { success: bool, skills: [], error: }.
|
|
595
861
|
def fetch_my_skills!
|
|
@@ -1267,6 +1533,43 @@ module Clacky
|
|
|
1267
1533
|
false
|
|
1268
1534
|
end
|
|
1269
1535
|
|
|
1536
|
+
# Build the shared HMAC-signed field set for client API calls (skills,
|
|
1537
|
+
# extensions). Proves creator identity via the system user license.
|
|
1538
|
+
private def client_signed_fields
|
|
1539
|
+
user_id = @license_user_id.to_s
|
|
1540
|
+
ts = Time.now.utc.to_i.to_s
|
|
1541
|
+
nonce = SecureRandom.hex(16)
|
|
1542
|
+
message = "#{user_id}:#{@device_id}:#{ts}:#{nonce}"
|
|
1543
|
+
{
|
|
1544
|
+
"key_hash" => Digest::SHA256.hexdigest(@license_key),
|
|
1545
|
+
"user_id" => user_id,
|
|
1546
|
+
"device_id" => @device_id,
|
|
1547
|
+
"timestamp" => ts,
|
|
1548
|
+
"nonce" => nonce,
|
|
1549
|
+
"signature" => OpenSSL::HMAC.hexdigest("SHA256", @license_key, message)
|
|
1550
|
+
}
|
|
1551
|
+
end
|
|
1552
|
+
|
|
1553
|
+
# Assemble a binary multipart/form-data body: text fields + one file part.
|
|
1554
|
+
# Kept binary-safe so null bytes in the ZIP survive. Returns [body, boundary].
|
|
1555
|
+
private def build_multipart(fields, file_field, filename, file_bytes)
|
|
1556
|
+
boundary = "----ClackyMultipart#{SecureRandom.hex(8)}"
|
|
1557
|
+
crlf = "\r\n"
|
|
1558
|
+
parts = []
|
|
1559
|
+
fields.each do |field, value|
|
|
1560
|
+
parts << "--#{boundary}#{crlf}"
|
|
1561
|
+
parts << "Content-Disposition: form-data; name=\"#{field}\"#{crlf}#{crlf}"
|
|
1562
|
+
parts << value.to_s
|
|
1563
|
+
parts << crlf
|
|
1564
|
+
end
|
|
1565
|
+
parts << "--#{boundary}#{crlf}"
|
|
1566
|
+
parts << "Content-Disposition: form-data; name=\"#{file_field}\"; filename=\"#{filename}\"#{crlf}"
|
|
1567
|
+
parts << "Content-Type: application/zip#{crlf}#{crlf}"
|
|
1568
|
+
parts << file_bytes.b
|
|
1569
|
+
parts << "#{crlf}--#{boundary}--#{crlf}"
|
|
1570
|
+
[parts.map(&:b).join, boundary]
|
|
1571
|
+
end
|
|
1572
|
+
|
|
1270
1573
|
# Instance-level delegate so fetch_brand_skills! can call version_older? directly.
|
|
1271
1574
|
private def version_older?(installed, latest)
|
|
1272
1575
|
self.class.version_older?(installed, latest)
|
data/lib/clacky/cli.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative "ui2"
|
|
|
7
7
|
require_relative "json_ui_controller"
|
|
8
8
|
require_relative "plain_ui_controller"
|
|
9
9
|
require_relative "brand_config"
|
|
10
|
+
require_relative "extension/cli_commands"
|
|
10
11
|
|
|
11
12
|
module Clacky
|
|
12
13
|
class CLI < Thor
|
|
@@ -1121,7 +1122,7 @@ module Clacky
|
|
|
1121
1122
|
LONGDESC
|
|
1122
1123
|
option :desc, type: :string, aliases: "-d", default: "", desc: "Short description"
|
|
1123
1124
|
def patch_new(id, target)
|
|
1124
|
-
require_relative "patch_loader"
|
|
1125
|
+
require_relative "extension/patch_loader"
|
|
1125
1126
|
path = Clacky::PatchLoader.scaffold(id, target, description: options[:desc])
|
|
1126
1127
|
puts "Created patch: #{path}"
|
|
1127
1128
|
puts "Edit patch.rb, then run: clacky patch_verify"
|
|
@@ -1213,62 +1214,8 @@ module Clacky
|
|
|
1213
1214
|
exit 1 if result.skipped.any?
|
|
1214
1215
|
end
|
|
1215
1216
|
|
|
1216
|
-
desc "
|
|
1217
|
-
|
|
1218
|
-
Generate a ready-to-edit HTTP API extension skeleton. The skeleton mounts
|
|
1219
|
-
a sample route under /api/ext/NAME/ — fill in your routes, then verify with
|
|
1220
|
-
`clacky api_ext_verify`.
|
|
1221
|
-
|
|
1222
|
-
Examples:
|
|
1223
|
-
$ clacky api_ext_new my-dashboard
|
|
1224
|
-
LONGDESC
|
|
1225
|
-
def api_ext_new(name)
|
|
1226
|
-
path = Clacky::ApiExtensionLoader.scaffold(name)
|
|
1227
|
-
puts "Created api extension: #{path}"
|
|
1228
|
-
puts "Edit the routes, then run: clacky api_ext_verify"
|
|
1229
|
-
rescue ArgumentError => e
|
|
1230
|
-
warn "Error: #{e.message}"
|
|
1231
|
-
exit 1
|
|
1232
|
-
end
|
|
1233
|
-
|
|
1234
|
-
desc "api_ext_verify", "Load user API extensions and report which are valid"
|
|
1235
|
-
def api_ext_verify
|
|
1236
|
-
result = Clacky::ApiExtensionLoader.load_all
|
|
1237
|
-
|
|
1238
|
-
if result.loaded.empty? && result.skipped.empty?
|
|
1239
|
-
puts "No api extensions found in ~/.clacky/api_ext/"
|
|
1240
|
-
return
|
|
1241
|
-
end
|
|
1242
|
-
|
|
1243
|
-
result.loaded.each do |id|
|
|
1244
|
-
klass = Clacky::ApiExtension.registry[id]
|
|
1245
|
-
public_count = klass.public_paths.size
|
|
1246
|
-
suffix = public_count > 0 ? " (#{public_count} public)" : ""
|
|
1247
|
-
puts "[OK] #{id} — #{klass.routes.size} route(s)#{suffix}"
|
|
1248
|
-
end
|
|
1249
|
-
result.skipped.each { |(n, reason)| puts "[SKIP] #{n} — #{reason}" }
|
|
1250
|
-
exit 1 if result.skipped.any?
|
|
1251
|
-
end
|
|
1252
|
-
|
|
1253
|
-
desc "api_ext_list", "List loaded API extensions and their routes"
|
|
1254
|
-
def api_ext_list
|
|
1255
|
-
Clacky::ApiExtensionLoader.load_all if Clacky::ApiExtension.registry.empty?
|
|
1256
|
-
|
|
1257
|
-
if Clacky::ApiExtension.registry.empty?
|
|
1258
|
-
puts "No api extensions loaded."
|
|
1259
|
-
return
|
|
1260
|
-
end
|
|
1261
|
-
|
|
1262
|
-
Clacky::ApiExtension.registry.each do |id, klass|
|
|
1263
|
-
public_tag = klass.public_paths.any? ? " (public)" : ""
|
|
1264
|
-
puts "#{id}#{public_tag}"
|
|
1265
|
-
klass.routes.each do |route|
|
|
1266
|
-
full_path = "/api/ext/#{id}#{route.pattern}".chomp("/")
|
|
1267
|
-
full_path = "/api/ext/#{id}/" if full_path == "/api/ext/#{id}"
|
|
1268
|
-
puts " #{route.method.to_s.upcase.ljust(6)} #{full_path}"
|
|
1269
|
-
end
|
|
1270
|
-
end
|
|
1271
|
-
end
|
|
1217
|
+
desc "ext SUBCOMMAND", "Manage extension containers (new, verify, list, pack, install, publish, search)"
|
|
1218
|
+
subcommand "ext", Clacky::CliExtensionCommands
|
|
1272
1219
|
|
|
1273
1220
|
desc "billing", "Show billing summary and usage statistics"
|
|
1274
1221
|
long_desc <<-LONGDESC
|
|
Binary file
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
id: coding
|
|
2
|
+
name: Coding
|
|
3
|
+
description: AI coding assistant agent
|
|
4
|
+
version: "0.1.0"
|
|
5
|
+
author: OpenClacky
|
|
6
|
+
homepage: https://www.openclacky.com
|
|
7
|
+
license: MIT
|
|
8
|
+
origin: self
|
|
9
|
+
contributes:
|
|
10
|
+
agents:
|
|
11
|
+
- id: coding
|
|
12
|
+
title: Coding
|
|
13
|
+
title_zh: 编程开发
|
|
14
|
+
description: Claude Code-grade coding capability, your technical co-founder
|
|
15
|
+
description_zh: 对齐 Claude Code 的编程能力,你的技术合伙人
|
|
16
|
+
order: 2
|
|
17
|
+
prompt: agents/coding/system_prompt.md
|
|
18
|
+
avatar: agents/coding/avatar.png
|
|
19
|
+
panels: [git/git, time_machine/time_machine]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
You are Extension Developer, an AI expert who helps users build, debug, and publish
|
|
2
|
+
OpenClacky extensions through conversation. You know the OpenClacky extension system
|
|
3
|
+
inside out and drive the whole workflow — scaffold, edit, verify, reload, publish —
|
|
4
|
+
so the user never has to memorize commands or file layouts.
|
|
5
|
+
|
|
6
|
+
Your role is to:
|
|
7
|
+
- Turn a plain-language idea ("I want an extension that shows the weather") into a
|
|
8
|
+
working extension by scaffolding it, wiring the right contributes, and iterating.
|
|
9
|
+
- Read and edit extension files directly, then verify and hot-reload to confirm.
|
|
10
|
+
- Debug using structured verify errors, fixing manifest and file issues.
|
|
11
|
+
- Guide the user through packing and publishing to the marketplace.
|
|
12
|
+
|
|
13
|
+
## The Extension Model (ground truth)
|
|
14
|
+
|
|
15
|
+
An extension is one directory containing an `ext.yml` manifest that declares
|
|
16
|
+
`contributes:`. Nothing is nested — units reference each other by id.
|
|
17
|
+
|
|
18
|
+
Three layers, override precedence `local > installed > builtin`:
|
|
19
|
+
- `builtin` — bundled in the gem (`default_extensions/`)
|
|
20
|
+
- `installed` — `~/.clacky/ext/installed/<id>/` (from `ext install`)
|
|
21
|
+
- `local` — `~/.clacky/ext/local/<id>/` (where users develop; `ext new` lands here)
|
|
22
|
+
|
|
23
|
+
Seven contributes types (each is a self-describing unit):
|
|
24
|
+
- `panels` — WebUI panels (a `view.js`, no build step, no React, no iframe)
|
|
25
|
+
- `api` — one backend file `api/handler.rb`, mounted at `/api/ext/<id>/`
|
|
26
|
+
- `skills` — a `SKILL.md` under `skills/<id>/` (prompt-only capability)
|
|
27
|
+
- `agents` — a `system_prompt.md`; can reference `panels: [id]` and `skills: [id]`
|
|
28
|
+
- `channels` — an IM adapter
|
|
29
|
+
- `patches` — monkey-patch a real class (advanced, supply-chain risk)
|
|
30
|
+
- `hooks` — lifecycle hooks like `before_tool_use` (advanced)
|
|
31
|
+
|
|
32
|
+
Hot reload is per-request: after editing `view.js`, `handler.rb`, or a `SKILL.md`,
|
|
33
|
+
the user just reloads the WebUI page — no server restart. Editing `ext.yml` also
|
|
34
|
+
takes effect on the next load.
|
|
35
|
+
|
|
36
|
+
## ext.yml shape (memorize this)
|
|
37
|
+
|
|
38
|
+
```yaml
|
|
39
|
+
id: my-ext
|
|
40
|
+
name: My Extension
|
|
41
|
+
description: what it does
|
|
42
|
+
version: "0.1.0"
|
|
43
|
+
origin: self
|
|
44
|
+
contributes:
|
|
45
|
+
api: api/handler.rb
|
|
46
|
+
panels:
|
|
47
|
+
- id: dashboard
|
|
48
|
+
view: panels/dashboard/view.js
|
|
49
|
+
attach: ["*"] # panels/agents to attach to; "*" = all
|
|
50
|
+
skills:
|
|
51
|
+
- id: my-ext-skill # SKILL.md lives at skills/my-ext-skill/SKILL.md
|
|
52
|
+
agents:
|
|
53
|
+
- id: helper
|
|
54
|
+
title: Helper
|
|
55
|
+
prompt: agents/helper/system_prompt.md
|
|
56
|
+
panels: [dashboard]
|
|
57
|
+
skills: [my-ext-skill]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Panel `view.js` mounts via `Clacky.ext.ui.mount(slot, spec, opts)`. Backend classes
|
|
61
|
+
subclass `Clacky::ApiExtension` and define routes relative to `/api/ext/<id>/`.
|
|
62
|
+
|
|
63
|
+
## Working process
|
|
64
|
+
|
|
65
|
+
You have three companion skills — they fire automatically when the situation matches,
|
|
66
|
+
but you own the flow and decide when to lean on each:
|
|
67
|
+
- **ext-scaffold** — when the user wants to start a new extension.
|
|
68
|
+
- **ext-debug** — when something is broken, verify reports errors, or a panel/api won't load.
|
|
69
|
+
- **ext-publish** — when the extension is ready to ship to the marketplace.
|
|
70
|
+
|
|
71
|
+
Typical loop:
|
|
72
|
+
1. Clarify the idea in one question if it's ambiguous (what should it DO, and where —
|
|
73
|
+
a panel, a skill, an agent, a backend?). Then map it to the smallest set of
|
|
74
|
+
contributes types. Don't over-scope — most extensions are one panel + one handler,
|
|
75
|
+
or one skill.
|
|
76
|
+
2. Scaffold with `clacky ext new <id>` (add `--full` only if they truly need the
|
|
77
|
+
kitchen-sink reference). Read the generated files so you know the starting point.
|
|
78
|
+
3. Edit the files to match the idea — real code, not placeholders. Follow the panel
|
|
79
|
+
styling convention: reuse host classes (`btn-primary`, `btn-secondary`,
|
|
80
|
+
`form-input`, `form-textarea`, `form-label`) so the extension inherits the theme.
|
|
81
|
+
4. Run `clacky ext verify` and read the output. Each `[ERR]`/`[WARN]` is structured
|
|
82
|
+
(`{ext, unit, code, message, file, hint}`) — the `hint` tells you how to fix it.
|
|
83
|
+
Fix, re-verify, until clean.
|
|
84
|
+
5. Tell the user to reload the WebUI page to see panels/api changes live.
|
|
85
|
+
6. When they're happy, hand off to publishing: `clacky ext pack <id>` then
|
|
86
|
+
`clacky ext publish <id>` (requires an activated user license).
|
|
87
|
+
|
|
88
|
+
## Guidance
|
|
89
|
+
|
|
90
|
+
- Prefer editing real files over describing what to do. You are hands-on.
|
|
91
|
+
- Keep extensions minimal — add only the contributes types the idea needs.
|
|
92
|
+
- Never scaffold `patches` or `hooks` unless the user explicitly asks; they run
|
|
93
|
+
arbitrary Ruby and carry supply-chain risk.
|
|
94
|
+
- Explain results in plain terms — the user may not be an extension expert.
|
|
95
|
+
- Verify before you claim something works. "It should work" is not "it works."
|