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.
Files changed (112) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/lib/clacky/agent/hook_manager.rb +10 -1
  4. data/lib/clacky/agent/system_prompt_builder.rb +1 -1
  5. data/lib/clacky/agent.rb +17 -3
  6. data/lib/clacky/agent_profile.rb +124 -53
  7. data/lib/clacky/billing/billing_store.rb +3 -2
  8. data/lib/clacky/brand_config.rb +309 -6
  9. data/lib/clacky/cli.rb +4 -57
  10. data/lib/clacky/default_extensions/coding/agents/coding/avatar.png +0 -0
  11. data/lib/clacky/default_extensions/coding/ext.yml +19 -0
  12. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/avatar.png +0 -0
  13. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +95 -0
  14. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +254 -0
  15. data/lib/clacky/default_extensions/ext-studio/ext.yml +33 -0
  16. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +1288 -0
  17. data/lib/clacky/default_extensions/ext-studio/skills/ext-debug/SKILL.md +71 -0
  18. data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +74 -0
  19. data/lib/clacky/default_extensions/ext-studio/skills/ext-scaffold/SKILL.md +65 -0
  20. data/lib/clacky/default_extensions/general/agents/general/avatar.png +0 -0
  21. data/lib/clacky/default_extensions/general/ext.yml +19 -0
  22. data/lib/clacky/default_extensions/git/ext.yml +12 -0
  23. data/lib/clacky/{default_agents/_panels/git/panel.js → default_extensions/git/panels/git/view.js} +4 -5
  24. data/lib/clacky/default_extensions/meeting/{handler.rb → api/handler.rb} +78 -26
  25. data/lib/clacky/default_extensions/meeting/ext.yml +15 -0
  26. data/lib/clacky/default_extensions/meeting/{meeting.js → panels/meeting/view.js} +266 -90
  27. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +2 -1
  28. data/lib/clacky/default_extensions/time_machine/ext.yml +12 -0
  29. data/lib/clacky/{default_agents/_panels/time_machine/panel.js → default_extensions/time_machine/panels/time_machine/view.js} +58 -47
  30. data/lib/clacky/default_skills/channel-manager/discord_setup.rb +1 -1
  31. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +83 -18
  32. data/lib/clacky/{api_extension.rb → extension/api_extension.rb} +27 -11
  33. data/lib/clacky/extension/api_loader.rb +136 -0
  34. data/lib/clacky/extension/cli_commands.rb +226 -0
  35. data/lib/clacky/{server/api_extension_dispatcher.rb → extension/dispatcher.rb} +22 -9
  36. data/lib/clacky/extension/hook_loader.rb +77 -0
  37. data/lib/clacky/extension/loader.rb +489 -0
  38. data/lib/clacky/extension/packager.rb +226 -0
  39. data/lib/clacky/{patch_loader.rb → extension/patch_loader.rb} +45 -0
  40. data/lib/clacky/extension/scaffold/template_renderer.rb +64 -0
  41. data/lib/clacky/extension/scaffold/templates/full/README.md.erb +48 -0
  42. data/lib/clacky/extension/scaffold/templates/full/agents/designer.md +5 -0
  43. data/lib/clacky/extension/scaffold/templates/full/api/handler.rb.erb +16 -0
  44. data/lib/clacky/extension/scaffold/templates/full/channels/noop.rb.erb +40 -0
  45. data/lib/clacky/extension/scaffold/templates/full/ext.yml.erb +41 -0
  46. data/lib/clacky/extension/scaffold/templates/full/hooks/audit.rb +9 -0
  47. data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +194 -0
  48. data/lib/clacky/extension/scaffold/templates/full/patches/audit.rb +15 -0
  49. data/lib/clacky/extension/scaffold/templates/full/skills/__slug__-skill/SKILL.md.erb +8 -0
  50. data/lib/clacky/extension/scaffold/templates/hello/api/handler.rb.erb +9 -0
  51. data/lib/clacky/extension/scaffold/templates/hello/ext.yml.erb +12 -0
  52. data/lib/clacky/extension/scaffold/templates/hello/panels/hello/view.js.erb +31 -0
  53. data/lib/clacky/extension/scaffold.rb +55 -0
  54. data/lib/clacky/extension/verifier.rb +196 -0
  55. data/lib/clacky/identity.rb +70 -0
  56. data/lib/clacky/locales/en.rb +19 -1
  57. data/lib/clacky/locales/zh.rb +19 -1
  58. data/lib/clacky/platform_http_client.rb +18 -15
  59. data/lib/clacky/server/channel/extension_adapter_loader.rb +32 -0
  60. data/lib/clacky/server/channel.rb +6 -0
  61. data/lib/clacky/server/http_server.rb +439 -146
  62. data/lib/clacky/server/session_registry.rb +9 -2
  63. data/lib/clacky/shell_hook_loader.rb +266 -22
  64. data/lib/clacky/skill_loader.rb +28 -1
  65. data/lib/clacky/ui2/components/welcome_banner.rb +1 -1
  66. data/lib/clacky/ui2/ui_controller.rb +9 -6
  67. data/lib/clacky/utils/workspace_rules.rb +2 -2
  68. data/lib/clacky/version.rb +1 -1
  69. data/lib/clacky/web/app.css +1034 -675
  70. data/lib/clacky/web/app.js +63 -15
  71. data/lib/clacky/web/auth.js +2 -0
  72. data/lib/clacky/web/components/notify.js +2 -0
  73. data/lib/clacky/web/components/sidebar.js +1 -5
  74. data/lib/clacky/web/core/ext.js +432 -89
  75. data/lib/clacky/web/features/backup/store.js +1 -0
  76. data/lib/clacky/web/features/backup/view.js +0 -1
  77. data/lib/clacky/web/features/brand/view.js +13 -5
  78. data/lib/clacky/web/features/extensions/store.js +184 -0
  79. data/lib/clacky/web/features/extensions/view.js +390 -0
  80. data/lib/clacky/web/features/new-session/store.js +199 -0
  81. data/lib/clacky/web/features/new-session/view.js +619 -0
  82. data/lib/clacky/web/features/share/view.js +63 -14
  83. data/lib/clacky/web/features/skills/store.js +3 -1
  84. data/lib/clacky/web/features/skills/view.js +1 -1
  85. data/lib/clacky/web/features/tasks/view.js +12 -0
  86. data/lib/clacky/web/features/trash/store.js +24 -6
  87. data/lib/clacky/web/features/trash/view.js +48 -12
  88. data/lib/clacky/web/features/workspace/store.js +2 -0
  89. data/lib/clacky/web/features/workspace/view.js +0 -1
  90. data/lib/clacky/web/i18n.js +152 -106
  91. data/lib/clacky/web/index.html +172 -190
  92. data/lib/clacky/web/sessions.js +67 -272
  93. data/lib/clacky/web/settings.js +1 -1
  94. data/lib/clacky/web/skills.js +88 -48
  95. data/lib/clacky/web/theme.js +5 -0
  96. data/lib/clacky/web/ws-dispatcher.js +12 -2
  97. data/lib/clacky/web/ws.js +2 -0
  98. data/lib/clacky.rb +21 -10
  99. metadata +52 -21
  100. data/benchmark/runner.rb +0 -441
  101. data/lib/clacky/api_extension_loader.rb +0 -168
  102. data/lib/clacky/default_agents/SOUL.md +0 -3
  103. data/lib/clacky/default_agents/USER.md +0 -1
  104. data/lib/clacky/default_agents/coding/profile.yml +0 -5
  105. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  106. data/lib/clacky/default_agents/general/profile.yml +0 -2
  107. data/lib/clacky/default_extensions/meeting/meta.yml +0 -3
  108. data/lib/clacky/web/features/creator/store.js +0 -81
  109. data/lib/clacky/web/features/creator/view.js +0 -380
  110. /data/lib/clacky/{default_agents → default_extensions/coding/agents}/coding/system_prompt.md +0 -0
  111. /data/lib/clacky/{default_agents → default_extensions/general/agents}/general/system_prompt.md +0 -0
  112. /data/lib/clacky/{default_agents/base_prompt.md → prompts/base.md} +0 -0
@@ -583,13 +583,279 @@ module Clacky
583
583
  { success: false, error: "Network error: #{e.message}" }
584
584
  end
585
585
 
586
- # Fetch the public store skills list from the OpenClacky Cloud API.
587
- # Requires an activated license for HMAC authentication.
588
- # Passes scope: "store" to retrieve platform-wide published public skills
589
- # (not filtered by the authenticated user's own skills).
590
- # Returns { success: bool, skills: [], error: }.
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
- # Fetch the creator's own published skills from the platform API.
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 "api_ext_new NAME", "Scaffold a custom HTTP API extension at ~/.clacky/api_ext/NAME/"
1217
- long_desc <<-LONGDESC
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
@@ -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."