openclacky 1.3.6 → 1.3.7

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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/lib/clacky/agent.rb +8 -1
  4. data/lib/clacky/agent_profile.rb +121 -53
  5. data/lib/clacky/brand_config.rb +309 -6
  6. data/lib/clacky/cli.rb +4 -57
  7. data/lib/clacky/default_extensions/coding/ext.yml +18 -0
  8. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +95 -0
  9. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +254 -0
  10. data/lib/clacky/default_extensions/ext-studio/ext.yml +32 -0
  11. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +1269 -0
  12. data/lib/clacky/default_extensions/ext-studio/skills/ext-debug/SKILL.md +71 -0
  13. data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +74 -0
  14. data/lib/clacky/default_extensions/ext-studio/skills/ext-scaffold/SKILL.md +65 -0
  15. data/lib/clacky/default_extensions/general/ext.yml +18 -0
  16. data/lib/clacky/default_extensions/git/ext.yml +12 -0
  17. data/lib/clacky/{default_agents/_panels/git/panel.js → default_extensions/git/panels/git/view.js} +1 -2
  18. data/lib/clacky/default_extensions/meeting/{handler.rb → api/handler.rb} +78 -26
  19. data/lib/clacky/default_extensions/meeting/ext.yml +15 -0
  20. data/lib/clacky/default_extensions/meeting/{meeting.js → panels/meeting/view.js} +266 -89
  21. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +2 -1
  22. data/lib/clacky/default_extensions/time_machine/ext.yml +12 -0
  23. data/lib/clacky/{default_agents/_panels/time_machine/panel.js → default_extensions/time_machine/panels/time_machine/view.js} +58 -47
  24. data/lib/clacky/default_skills/channel-manager/discord_setup.rb +1 -1
  25. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +83 -18
  26. data/lib/clacky/{api_extension.rb → extension/api_extension.rb} +27 -11
  27. data/lib/clacky/extension/api_loader.rb +136 -0
  28. data/lib/clacky/extension/cli_commands.rb +226 -0
  29. data/lib/clacky/{server/api_extension_dispatcher.rb → extension/dispatcher.rb} +22 -9
  30. data/lib/clacky/extension/hook_loader.rb +77 -0
  31. data/lib/clacky/extension/loader.rb +483 -0
  32. data/lib/clacky/extension/packager.rb +226 -0
  33. data/lib/clacky/{patch_loader.rb → extension/patch_loader.rb} +45 -0
  34. data/lib/clacky/extension/scaffold/template_renderer.rb +64 -0
  35. data/lib/clacky/extension/scaffold/templates/full/README.md.erb +48 -0
  36. data/lib/clacky/extension/scaffold/templates/full/agents/designer.md +5 -0
  37. data/lib/clacky/extension/scaffold/templates/full/api/handler.rb.erb +16 -0
  38. data/lib/clacky/extension/scaffold/templates/full/channels/noop.rb.erb +40 -0
  39. data/lib/clacky/extension/scaffold/templates/full/ext.yml.erb +41 -0
  40. data/lib/clacky/extension/scaffold/templates/full/hooks/audit.rb +9 -0
  41. data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +194 -0
  42. data/lib/clacky/extension/scaffold/templates/full/patches/audit.rb +15 -0
  43. data/lib/clacky/extension/scaffold/templates/full/skills/__slug__-skill/SKILL.md.erb +8 -0
  44. data/lib/clacky/extension/scaffold/templates/hello/api/handler.rb.erb +9 -0
  45. data/lib/clacky/extension/scaffold/templates/hello/ext.yml.erb +12 -0
  46. data/lib/clacky/extension/scaffold/templates/hello/panels/hello/view.js.erb +31 -0
  47. data/lib/clacky/extension/scaffold.rb +55 -0
  48. data/lib/clacky/extension/verifier.rb +196 -0
  49. data/lib/clacky/identity.rb +70 -0
  50. data/lib/clacky/locales/en.rb +19 -1
  51. data/lib/clacky/locales/zh.rb +19 -1
  52. data/lib/clacky/platform_http_client.rb +18 -15
  53. data/lib/clacky/server/channel/extension_adapter_loader.rb +32 -0
  54. data/lib/clacky/server/channel.rb +6 -0
  55. data/lib/clacky/server/http_server.rb +313 -144
  56. data/lib/clacky/server/session_registry.rb +9 -2
  57. data/lib/clacky/skill_loader.rb +28 -1
  58. data/lib/clacky/ui2/ui_controller.rb +9 -6
  59. data/lib/clacky/version.rb +1 -1
  60. data/lib/clacky/web/app.css +748 -573
  61. data/lib/clacky/web/app.js +63 -15
  62. data/lib/clacky/web/auth.js +2 -0
  63. data/lib/clacky/web/components/notify.js +2 -0
  64. data/lib/clacky/web/components/sidebar.js +1 -5
  65. data/lib/clacky/web/core/ext.js +316 -79
  66. data/lib/clacky/web/features/backup/store.js +1 -0
  67. data/lib/clacky/web/features/backup/view.js +0 -1
  68. data/lib/clacky/web/features/brand/view.js +13 -5
  69. data/lib/clacky/web/features/extensions/store.js +184 -0
  70. data/lib/clacky/web/features/extensions/view.js +389 -0
  71. data/lib/clacky/web/features/new-session/store.js +186 -0
  72. data/lib/clacky/web/features/new-session/view.js +319 -0
  73. data/lib/clacky/web/features/share/view.js +63 -14
  74. data/lib/clacky/web/features/skills/store.js +3 -1
  75. data/lib/clacky/web/features/skills/view.js +1 -1
  76. data/lib/clacky/web/features/tasks/view.js +12 -0
  77. data/lib/clacky/web/features/trash/store.js +24 -6
  78. data/lib/clacky/web/features/trash/view.js +46 -9
  79. data/lib/clacky/web/features/workspace/store.js +2 -0
  80. data/lib/clacky/web/features/workspace/view.js +0 -1
  81. data/lib/clacky/web/i18n.js +118 -106
  82. data/lib/clacky/web/index.html +138 -188
  83. data/lib/clacky/web/sessions.js +64 -269
  84. data/lib/clacky/web/settings.js +1 -1
  85. data/lib/clacky/web/skills.js +2 -0
  86. data/lib/clacky/web/theme.js +2 -0
  87. data/lib/clacky/web/ws-dispatcher.js +1 -0
  88. data/lib/clacky/web/ws.js +2 -0
  89. data/lib/clacky.rb +21 -10
  90. metadata +55 -24
  91. data/benchmark/runner.rb +0 -441
  92. data/lib/clacky/api_extension_loader.rb +0 -168
  93. data/lib/clacky/default_agents/SOUL.md +0 -3
  94. data/lib/clacky/default_agents/USER.md +0 -1
  95. data/lib/clacky/default_agents/coding/profile.yml +0 -5
  96. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  97. data/lib/clacky/default_agents/general/profile.yml +0 -2
  98. data/lib/clacky/default_extensions/meeting/meta.yml +0 -3
  99. data/lib/clacky/web/features/creator/store.js +0 -81
  100. data/lib/clacky/web/features/creator/view.js +0 -380
  101. /data/lib/clacky/{default_agents → default_extensions/coding/agents}/coding/system_prompt.md +0 -0
  102. /data/lib/clacky/{default_agents → default_extensions/general/agents}/general/system_prompt.md +0 -0
  103. /data/lib/clacky/{default_agents/base_prompt.md → prompts/base.md} +0 -0
@@ -0,0 +1,194 @@
1
+ // Dashboard panel + demo UI hooks for the "<%= slug %>" extension.
2
+ // One file, six slots + one workspace — a tour of every UI hook:
3
+ // • sidebar.nav.top — top-of-rail entry for a first-class workspace
4
+ // • sidebar.nav — regular menu entry (between Sessions and Config)
5
+ // • sidebar.nav.bottom — end-of-rail entry for a secondary link
6
+ // • main.workspace — a full-page view opened by those entries
7
+ // • session.banner — a strip above the message list
8
+ // • session.aside — a tab in the right aside
9
+ // • session.composer — quick-action buttons above the input bar
10
+ // Reload the WebUI page to see edits.
11
+ (function () {
12
+ var EXT = "<%= slug %>";
13
+
14
+ // ── 0. Full-page workspace: a "console" view mounted in #main.
15
+ // Registered once; opened via openWorkspace(id) below or by
16
+ // navigating to #ext/<%= slug %>. Router hides other panels first,
17
+ // then calls render(container) — container is empty on entry.
18
+ Clacky.ext.ui.registerWorkspace(EXT, {
19
+ title: EXT + " console",
20
+ render: function (container) {
21
+ container.innerHTML =
22
+ '<div style="max-width:720px;margin:32px auto;padding:24px;">' +
23
+ '<h2 style="margin:0 0 8px">' + EXT + ' console</h2>' +
24
+ '<p style="opacity:.7;margin:0 0 16px">' +
25
+ 'Full-page workspace registered by the ' + EXT + ' extension. ' +
26
+ 'Replace this render() with a real dashboard, form, or embedded iframe.' +
27
+ '</p>' +
28
+ '<button id="ws-load-stats" class="btn-primary">Load stats</button>' +
29
+ '<pre id="ws-out" style="margin-top:12px;white-space:pre-wrap;"></pre>' +
30
+ '</div>';
31
+ container.querySelector("#ws-load-stats").addEventListener("click", async function () {
32
+ var res = await fetch("/api/ext/" + EXT + "/stats");
33
+ container.querySelector("#ws-out").textContent =
34
+ JSON.stringify(await res.json(), null, 2);
35
+ });
36
+ },
37
+ });
38
+
39
+ // Shared factory for a sidebar row (icon + label + click handler).
40
+ function navRow(label, onClick) {
41
+ var item = document.createElement("div");
42
+ item.className = "task-item task-item-summary";
43
+ item.innerHTML =
44
+ '<div class="task-row">' +
45
+ '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" ' +
46
+ 'fill="none" stroke="currentColor" stroke-width="2" ' +
47
+ 'stroke-linecap="round" stroke-linejoin="round" class="task-icon">' +
48
+ '<rect x="3" y="3" width="7" height="7"/>' +
49
+ '<rect x="14" y="3" width="7" height="7"/>' +
50
+ '<rect x="3" y="14" width="7" height="7"/>' +
51
+ '<rect x="14" y="14" width="7" height="7"/>' +
52
+ '</svg>' +
53
+ '<div class="task-info"><span class="task-name">' + label + '</span></div>' +
54
+ '</div>';
55
+ item.addEventListener("click", onClick);
56
+ return item;
57
+ }
58
+
59
+ // ── 1a. Top slot: first-class workspace entry, above Sessions ──
60
+ // Global chrome — visible for every agent regardless of the
61
+ // panel file this mount lives in.
62
+ Clacky.ext.ui.mount("sidebar.nav.top", function () {
63
+ return navRow(EXT + " console", function () {
64
+ Clacky.ext.ui.openWorkspace(EXT);
65
+ });
66
+ });
67
+
68
+ // ── 1b. Main slot: regular menu row between Sessions and Config ─
69
+ Clacky.ext.ui.mount("sidebar.nav", function () {
70
+ return navRow(EXT + " menu (middle)", function () {
71
+ Clacky.ext.ui.openWorkspace(EXT);
72
+ });
73
+ });
74
+
75
+ // ── 1c. Bottom slot: secondary link at the end of the rail ─────
76
+ Clacky.ext.ui.mount("sidebar.nav.bottom", function () {
77
+ return navRow(EXT + " footer link", function () {
78
+ Clacky.ext.ui.openWorkspace(EXT);
79
+ });
80
+ });
81
+
82
+ // ── 2. Session banner: horizontal strip above the messages ─────
83
+ Clacky.ext.ui.mount("session.banner", function () {
84
+ var bar = document.createElement("div");
85
+ bar.style.cssText =
86
+ "padding:8px 12px;margin:8px 0;border-radius:var(--radius-sm,6px);" +
87
+ "background:var(--color-bg-hover,#f4f4f2);color:var(--color-text-primary,#334);" +
88
+ "display:flex;align-items:center;gap:8px;font-size:13px;";
89
+ bar.innerHTML = '<strong>' + EXT + '</strong> — banner slot demo. ';
90
+ var dismiss = document.createElement("button");
91
+ dismiss.className = "btn-secondary";
92
+ dismiss.textContent = "Dismiss";
93
+ dismiss.style.cssText = "margin-left:auto;padding:4px 12px;font-size:12px;";
94
+ dismiss.addEventListener("click", function () { bar.remove(); });
95
+ bar.appendChild(dismiss);
96
+ return bar;
97
+ });
98
+
99
+ // ── 3. Session aside: a tab in the right column (tabbed slot) ──
100
+ //
101
+ // This form (spec = plain function) is stateless: every time the tab is
102
+ // shown the render runs from scratch. Fine for panels that are pure
103
+ // reflections of server data.
104
+ //
105
+ // If you need a runtime that lives as long as the session (a canvas'
106
+ // undo stack, a running audio recorder, a websocket client), pass an
107
+ // object with `create(ctx)` / `render(container, ctx, runtime)` /
108
+ // optional `runtime.dispose()`. `create` runs once per session and its
109
+ // return value is passed to every `render`; `dispose` runs when the
110
+ // session leaves the sidebar. Example:
111
+ //
112
+ // Clacky.ext.ui.mount("session.aside", {
113
+ // create(ctx) {
114
+ // const state = { count: 0 };
115
+ // return { state, dispose() { /* release resources */ } };
116
+ // },
117
+ // render(container, ctx, runtime) {
118
+ // const btn = document.createElement("button");
119
+ // btn.textContent = "Clicked " + runtime.state.count + " times";
120
+ // btn.onclick = () => { runtime.state.count++; render(container, ctx, runtime); };
121
+ // container.appendChild(btn);
122
+ // },
123
+ // }, { tab: { id: "counter", label: "Counter" } });
124
+ Clacky.ext.ui.mount("session.aside", function (ctx) {
125
+ var el = document.createElement("div");
126
+ el.style.padding = "16px";
127
+ el.innerHTML = '<h3 style="margin:0 0 8px">' + EXT + ' dashboard</h3>';
128
+
129
+ var btn = document.createElement("button");
130
+ btn.className = "btn-primary";
131
+ btn.textContent = "Load stats";
132
+ var out = document.createElement("pre");
133
+ out.style.cssText = "margin-top:12px;white-space:pre-wrap;";
134
+
135
+ btn.addEventListener("click", async function () {
136
+ var res = await fetch("/api/ext/" + EXT + "/stats");
137
+ out.textContent = JSON.stringify(await res.json(), null, 2);
138
+ });
139
+
140
+ el.appendChild(btn);
141
+ el.appendChild(out);
142
+ return el;
143
+ }, {
144
+ tab: { id: EXT, label: () => EXT },
145
+ order: 500,
146
+ });
147
+
148
+ // ── 4. Session composer: quick-action buttons above the input ──
149
+ // Clicking a button injects a slash command into #user-input
150
+ // and clicks #btn-send — the same code path as typing it.
151
+ //
152
+ // These "chips" are pill-shaped so they read differently from
153
+ // full btn-primary/btn-secondary buttons. Colors/radii still
154
+ // pull from host CSS variables so themes travel with them.
155
+ Clacky.ext.ui.mount("session.composer", function () {
156
+ var wrap = document.createElement("div");
157
+ wrap.style.cssText = "display:flex;gap:6px;padding:6px 8px;flex-wrap:wrap;";
158
+
159
+ function quick(label, command) {
160
+ var b = document.createElement("button");
161
+ b.type = "button";
162
+ b.textContent = label;
163
+ b.style.cssText =
164
+ "padding:4px 12px;font-size:12px;border-radius:14px;cursor:pointer;" +
165
+ "border:1px solid var(--color-border-primary,#e8e8e4);" +
166
+ "background:var(--color-bg-primary,#fff);" +
167
+ "color:var(--color-text-secondary,#56585e);" +
168
+ "transition:background var(--transition-fast,120ms), border-color var(--transition-fast,120ms), color var(--transition-fast,120ms);";
169
+ b.addEventListener("mouseenter", function () {
170
+ b.style.background = "var(--color-bg-hover,#f4f4f2)";
171
+ b.style.borderColor = "var(--color-border-strong,#d4d4d0)";
172
+ b.style.color = "var(--color-text-primary,#1a1a1a)";
173
+ });
174
+ b.addEventListener("mouseleave", function () {
175
+ b.style.background = "var(--color-bg-primary,#fff)";
176
+ b.style.borderColor = "var(--color-border-primary,#e8e8e4)";
177
+ b.style.color = "var(--color-text-secondary,#56585e)";
178
+ });
179
+ b.addEventListener("click", function () {
180
+ var input = document.getElementById("user-input");
181
+ var send = document.getElementById("btn-send");
182
+ if (!input || !send) return;
183
+ input.value = command;
184
+ input.dispatchEvent(new Event("input", { bubbles: true }));
185
+ send.click();
186
+ });
187
+ return b;
188
+ }
189
+
190
+ wrap.appendChild(quick("Say hi", "/" + EXT + "-skill hi"));
191
+ wrap.appendChild(quick("Explain code", "/" + EXT + "-skill explain the current file"));
192
+ return wrap;
193
+ });
194
+ })();
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Example monkey-patch. Prepends onto Clacky::Tools::Terminal#execute
4
+ # so we get one log line per shell tool invocation.
5
+ # In production a patch might enforce a denylist, rewrite arguments,
6
+ # or measure timing. Keep the body small and always call `super`.
7
+ module ExtSampleAuditPatch
8
+ def execute(*args, **kwargs)
9
+ cmd = kwargs[:command] || args.first
10
+ Clacky::Logger.info("[ext-audit] terminal.execute", command: cmd.to_s[0, 200])
11
+ super
12
+ end
13
+ end
14
+
15
+ Clacky::Tools::Terminal.prepend(ExtSampleAuditPatch)
@@ -0,0 +1,8 @@
1
+ ---
2
+ name: <%= slug %>-skill
3
+ description: Demo skill contributed by the "<%= slug %>" extension. Greets in style.
4
+ ---
5
+
6
+ You are the <%= slug %> demo skill.
7
+
8
+ When the user asks for a greeting, respond with one short, friendly line.
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # API backend for "<%= slug %>". Mounted at /api/ext/<%= slug %>/.
4
+ # Routes here are relative to that mount.
5
+ class <%= const_prefix %>Ext < Clacky::ApiExtension
6
+ get "/" do
7
+ json(message: "hello from <%= slug %>")
8
+ end
9
+ end
@@ -0,0 +1,12 @@
1
+ id: <%= slug %>
2
+ name: <%= slug %>
3
+ description: (describe what this extension does)
4
+ version: "0.1.0"
5
+ author: (your name) # shown as the credit on the New Session card
6
+ origin: self
7
+ contributes:
8
+ api: api/handler.rb
9
+ panels:
10
+ - id: hello
11
+ view: panels/hello/view.js
12
+ attach: ["*"] # panels/agents this panel wants to attach to; "*" = all
@@ -0,0 +1,31 @@
1
+ // Hello panel for the "<%= slug %>" extension.
2
+ // Mounts a tab in the session aside; talks to its backend at
3
+ // /api/ext/<%= slug %>/. Reload the WebUI page to see edits.
4
+ //
5
+ // Styling: use host classes (btn-primary, btn-secondary, form-input,
6
+ // form-textarea, form-label) so extensions inherit theme + hover
7
+ // states automatically. See docs/extensions/ui-styling.md.
8
+ (function () {
9
+ Clacky.ext.ui.mount("session.aside", function (ctx) {
10
+ var el = document.createElement("div");
11
+ el.style.padding = "16px";
12
+
13
+ var btn = document.createElement("button");
14
+ btn.className = "btn-primary";
15
+ btn.textContent = "Ping backend";
16
+ var out = document.createElement("pre");
17
+ out.style.cssText = "margin-top:12px;white-space:pre-wrap;";
18
+
19
+ btn.addEventListener("click", async function () {
20
+ var res = await fetch("/api/ext/<%= slug %>/");
21
+ out.textContent = JSON.stringify(await res.json(), null, 2);
22
+ });
23
+
24
+ el.appendChild(btn);
25
+ el.appendChild(out);
26
+ return el;
27
+ }, {
28
+ tab: { id: "<%= slug %>", label: () => "<%= slug %>" },
29
+ order: 500,
30
+ });
31
+ })();
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Clacky
6
+ # Scaffolds extension containers for the `ext new` command.
7
+ # Generated containers are complete, runnable examples — the best
8
+ # documentation for an AI author is a working "hello panel" it can copy.
9
+ module ExtensionScaffold
10
+ TEMPLATES_DIR = File.expand_path("scaffold/templates", __dir__)
11
+
12
+ class << self
13
+ # Create a new local container with a runnable hello panel + backend.
14
+ # @param full [Boolean] when true, generate a "kitchen-sink" container
15
+ # exercising all 7 contributes types (panels, api, skills, agents,
16
+ # channels, patches, hooks) — useful as a learn-by-example reference.
17
+ # @return [String] path to the created container directory
18
+ def new_container(id, dir: Clacky::ExtensionLoader::LOCAL_DIR, full: false)
19
+ slug = slugify(id)
20
+ raise ArgumentError, "invalid extension id: #{id.inspect}" if slug.empty?
21
+
22
+ target = File.join(dir, slug)
23
+ raise ArgumentError, "extension already exists: #{target}" if Dir.exist?(target)
24
+
25
+ return new_full_container(slug, target) if full
26
+
27
+ FileUtils.mkdir_p(target)
28
+ TemplateRenderer.render(
29
+ template_dir: File.join(TEMPLATES_DIR, "hello"),
30
+ target: target,
31
+ locals: { slug: slug, const_prefix: camelize(slug) }
32
+ )
33
+ target
34
+ end
35
+
36
+ private def slugify(id)
37
+ id.to_s.strip.downcase.gsub(/[^a-z0-9_-]+/, "-").gsub(/\A-+|-+\z/, "")
38
+ end
39
+
40
+ private def camelize(slug)
41
+ slug.split(/[-_]/).map(&:capitalize).join
42
+ end
43
+
44
+ private def new_full_container(slug, target)
45
+ FileUtils.mkdir_p(target)
46
+ TemplateRenderer.render(
47
+ template_dir: File.join(TEMPLATES_DIR, "full"),
48
+ target: target,
49
+ locals: { slug: slug, const_prefix: camelize(slug) }
50
+ )
51
+ target
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Clacky
6
+ # Static checks layered on top of ExtensionLoader's structural validation.
7
+ #
8
+ # ExtensionLoader already enforces required fields and file existence per unit
9
+ # while loading. The Verifier adds whole-program checks an AI author needs to
10
+ # close the feedback loop: unknown manifest keys, cross-unit reference
11
+ # integrity, and id collisions across layers (formal "override" warnings).
12
+ #
13
+ # Output is a flat array of Issue records — { ext, unit, level, code, message,
14
+ # file, hint } — each individually addressable so callers can render them in
15
+ # any UI (CLI, Web, JSON for tooling).
16
+ module ExtensionVerifier
17
+ Issue = Struct.new(:ext, :unit, :level, :code, :message, :file, :hint, keyword_init: true)
18
+
19
+ KNOWN_TOP_KEYS = %w[id name title description version origin author homepage license public license_required keywords contributes].freeze
20
+ KNOWN_CONTRIBUTES = %w[panels api skills agents channels patches hooks].freeze
21
+
22
+ PANEL_KEYS = %w[id title title_zh description description_zh view order attach].freeze
23
+ API_KEYS = %w[id handler].freeze
24
+ SKILL_KEYS = %w[id dir protected].freeze
25
+ AGENT_KEYS = %w[id title title_zh description description_zh order prompt panels skills].freeze
26
+ CHANNEL_KEYS = %w[id platform adapter].freeze
27
+ PATCH_KEYS = %w[target file fingerprint on_mismatch].freeze
28
+ HOOK_KEYS = %w[event file].freeze
29
+
30
+ ATTACH_TOKEN_RE = /\A(\*|[\w\-]+)\z/.freeze
31
+
32
+ class << self
33
+ # Run all checks against a fully-loaded ExtensionLoader::Result. Returns
34
+ # an array of Issue. The result already contains structural Errors from
35
+ # the loader; those are converted to Issues so callers see one stream.
36
+ def verify(result)
37
+ issues = []
38
+ issues.concat(loader_errors_as_issues(result))
39
+ issues.concat(override_issues(result))
40
+ issues.concat(manifest_schema_issues(result))
41
+ issues.concat(reference_issues(result))
42
+ issues
43
+ end
44
+
45
+ private def loader_errors_as_issues(result)
46
+ Array(result.errors).map do |err|
47
+ Issue.new(
48
+ ext: err.ext_id, unit: err.unit, level: :error,
49
+ code: "loader.error", message: err.message, file: err.file, hint: nil
50
+ )
51
+ end
52
+ end
53
+
54
+ private def override_issues(result)
55
+ Array(result.overridden).map do |(ext_id, losing, winning)|
56
+ Issue.new(
57
+ ext: ext_id, unit: nil, level: :warning, code: "override",
58
+ message: "container present in #{losing} layer is shadowed by #{winning}",
59
+ file: nil, hint: "Remove or rename one copy if the override is unintended."
60
+ )
61
+ end
62
+ end
63
+
64
+ private def manifest_schema_issues(result)
65
+ issues = []
66
+ Array(result.containers).each do |ext_id, container|
67
+ dir = container[:dir]
68
+ manifest = read_manifest_safely(dir)
69
+ next unless manifest.is_a?(Hash)
70
+
71
+ (manifest.keys.map(&:to_s) - KNOWN_TOP_KEYS).each do |unknown|
72
+ issues << Issue.new(
73
+ ext: ext_id, unit: nil, level: :warning, code: "schema.unknown_key",
74
+ message: "unknown top-level key #{unknown.inspect} in ext.yml",
75
+ file: File.join(dir, "ext.yml"),
76
+ hint: "Allowed: #{KNOWN_TOP_KEYS.join(', ')}"
77
+ )
78
+ end
79
+
80
+ contributes = manifest["contributes"]
81
+ next unless contributes.is_a?(Hash)
82
+
83
+ (contributes.keys.map(&:to_s) - KNOWN_CONTRIBUTES).each do |unknown|
84
+ issues << Issue.new(
85
+ ext: ext_id, unit: nil, level: :warning, code: "schema.unknown_contributes",
86
+ message: "unknown contributes type #{unknown.inspect}",
87
+ file: File.join(dir, "ext.yml"),
88
+ hint: "Allowed: #{KNOWN_CONTRIBUTES.join(', ')}"
89
+ )
90
+ end
91
+
92
+ issues.concat(per_unit_schema_issues(ext_id, dir, contributes))
93
+ end
94
+ issues
95
+ end
96
+
97
+ private def per_unit_schema_issues(ext_id, dir, contributes)
98
+ issues = []
99
+ manifest_path = File.join(dir, "ext.yml")
100
+
101
+ check_unit_keys(issues, ext_id, manifest_path, contributes["panels"], PANEL_KEYS, "panel")
102
+ check_unit_keys(issues, ext_id, manifest_path, contributes["api"], API_KEYS, "api")
103
+ check_unit_keys(issues, ext_id, manifest_path, contributes["skills"], SKILL_KEYS, "skill")
104
+ check_unit_keys(issues, ext_id, manifest_path, contributes["agents"], AGENT_KEYS, "agent")
105
+ check_unit_keys(issues, ext_id, manifest_path, contributes["channels"], CHANNEL_KEYS, "channel")
106
+ check_unit_keys(issues, ext_id, manifest_path, contributes["patches"], PATCH_KEYS, "patch")
107
+ check_unit_keys(issues, ext_id, manifest_path, contributes["hooks"], HOOK_KEYS, "hook")
108
+
109
+ Array(contributes["panels"]).each do |entry|
110
+ next unless entry.is_a?(Hash)
111
+ attach = entry["attach"]
112
+ next if attach.nil?
113
+ unless attach.is_a?(Array) && attach.all? { |t| t.is_a?(String) && t.match?(ATTACH_TOKEN_RE) }
114
+ issues << Issue.new(
115
+ ext: ext_id, unit: entry["id"], level: :error, code: "schema.bad_attach",
116
+ message: "panel `attach` must be an array of agent ids or `\"*\"`, got #{attach.inspect}",
117
+ file: manifest_path, hint: 'Example: attach: [coding] or attach: ["*"]'
118
+ )
119
+ end
120
+ end
121
+
122
+ issues
123
+ end
124
+
125
+ private def check_unit_keys(issues, ext_id, manifest_path, list, allowed, kind_label)
126
+ Array(list).each do |entry|
127
+ next unless entry.is_a?(Hash)
128
+ unknown = entry.keys.map(&:to_s) - allowed
129
+ next if unknown.empty?
130
+ issues << Issue.new(
131
+ ext: ext_id, unit: entry["id"], level: :warning, code: "schema.unknown_field",
132
+ message: "#{kind_label} unit has unknown key(s): #{unknown.join(', ')}",
133
+ file: manifest_path,
134
+ hint: "Allowed for #{kind_label}: #{allowed.join(', ')}"
135
+ )
136
+ end
137
+ end
138
+
139
+ private def reference_issues(result)
140
+ issues = []
141
+ panel_ids = result.panels.map { |u| "#{u.ext_id}/#{u.id}" }.to_set
142
+ agent_ids = result.agents.map { |u| u.id }.to_set
143
+ skill_ids = result.skills.map { |u| u.id }.to_set
144
+
145
+ result.agents.each do |agent|
146
+ spec = agent.spec || {}
147
+
148
+ Array(spec["panels"]).each do |pid|
149
+ ref = pid.include?("/") ? pid : "#{agent.ext_id}/#{pid}"
150
+ next if panel_ids.include?(ref)
151
+ issues << Issue.new(
152
+ ext: agent.ext_id, unit: agent.id, level: :error, code: "ref.missing_panel",
153
+ message: "agent references panel #{pid.inspect} which does not exist",
154
+ file: File.join(agent.dir, "ext.yml"),
155
+ hint: "Define it under contributes.panels or remove the reference."
156
+ )
157
+ end
158
+
159
+ Array(spec["skills"]).each do |sid|
160
+ next if skill_ids.include?(sid)
161
+ issues << Issue.new(
162
+ ext: agent.ext_id, unit: agent.id, level: :warning, code: "ref.missing_skill",
163
+ message: "agent references skill #{sid.inspect} which is not contributed by this container",
164
+ file: File.join(agent.dir, "ext.yml"),
165
+ hint: "User-installed default skills resolve at runtime; this is a hint, not a hard error."
166
+ )
167
+ end
168
+ end
169
+
170
+ result.panels.each do |panel|
171
+ Array(panel.spec && panel.spec["attach"]).each do |target|
172
+ next if target == "*"
173
+ next if agent_ids.include?(target)
174
+ issues << Issue.new(
175
+ ext: panel.ext_id, unit: panel.id, level: :warning, code: "ref.missing_attach_agent",
176
+ message: "panel `attach` references agent #{target.inspect} not present in any container",
177
+ file: File.join(panel.dir, "ext.yml"),
178
+ hint: "User-defined agents resolve at runtime; verify the id spelling."
179
+ )
180
+ end
181
+ end
182
+
183
+ issues
184
+ end
185
+
186
+ private def read_manifest_safely(dir)
187
+ require "yaml"
188
+ path = File.join(dir, "ext.yml")
189
+ return nil unless File.file?(path)
190
+ YAML.safe_load(File.read(path), permitted_classes: [Symbol]) || {}
191
+ rescue StandardError
192
+ nil
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Clacky
7
+ # Identity stores the client's platform-account binding, separate from
8
+ # BrandConfig (white-label / license). It holds the long-lived device token
9
+ # issued by the RFC 8628 device-authorization flow, which proves creator
10
+ # identity when publishing extensions to the marketplace.
11
+ #
12
+ # ~/.clacky/identity.yml structure:
13
+ # device_token: "clacky-dt-..."
14
+ # user_id: 42
15
+ # bound_at: "2026-07-05T00:00:00Z"
16
+ class Identity
17
+ CONFIG_DIR = File.join(Dir.home, ".clacky")
18
+ IDENTITY_FILE = File.join(CONFIG_DIR, "identity.yml")
19
+
20
+ attr_reader :device_token, :user_id, :bound_at
21
+
22
+ def initialize(attrs = {})
23
+ @device_token = attrs["device_token"]
24
+ @user_id = attrs["user_id"]
25
+ @bound_at = attrs["bound_at"]
26
+ end
27
+
28
+ def self.load
29
+ data = File.exist?(IDENTITY_FILE) ? (YAML.safe_load(File.read(IDENTITY_FILE)) || {}) : {}
30
+ new(data)
31
+ rescue StandardError
32
+ new({})
33
+ end
34
+
35
+ # True when this device has a device token bound to a platform account.
36
+ def bound?
37
+ !@device_token.nil? && !@device_token.to_s.strip.empty?
38
+ end
39
+
40
+ # Persist a fresh binding from a device-authorization approval.
41
+ def bind!(device_token:, user_id:)
42
+ @device_token = device_token
43
+ @user_id = user_id
44
+ @bound_at = Time.now.utc.iso8601
45
+ save
46
+ self
47
+ end
48
+
49
+ def clear!
50
+ @device_token = nil
51
+ @user_id = nil
52
+ @bound_at = nil
53
+ FileUtils.rm_f(IDENTITY_FILE)
54
+ end
55
+
56
+ def save
57
+ FileUtils.mkdir_p(CONFIG_DIR)
58
+ File.write(IDENTITY_FILE, to_yaml)
59
+ FileUtils.chmod(0o600, IDENTITY_FILE)
60
+ end
61
+
62
+ private def to_yaml
63
+ {
64
+ "device_token" => @device_token,
65
+ "user_id" => @user_id,
66
+ "bound_at" => @bound_at
67
+ }.compact.to_yaml
68
+ end
69
+ end
70
+ end
@@ -20,7 +20,25 @@ module Clacky
20
20
  "llm.error.bad_request" => "Bad request: invalid parameters. Please check your model configuration",
21
21
  "llm.error.request_timeout" => "Request timed out after %<retries>d retries",
22
22
  "llm.error.network_failed" => "Network connection failed after %<retries>d retries",
23
- "llm.error.service_unavailable" => "Service unavailable after %<retries>d retries"
23
+ "llm.error.service_unavailable" => "Service unavailable after %<retries>d retries",
24
+ "platform.error.invalid_proof" => "Invalid license key — please check and try again.",
25
+ "platform.error.invalid_signature" => "Invalid request signature.",
26
+ "platform.error.nonce_replayed" => "Duplicate request detected. Please try again.",
27
+ "platform.error.timestamp_expired" => "System clock is out of sync. Please adjust your time settings.",
28
+ "platform.error.license_revoked" => "This license has been revoked. Please contact support.",
29
+ "platform.error.license_expired" => "This license has expired. Please renew to continue.",
30
+ "platform.error.device_limit_reached" => "Device limit reached for this license.",
31
+ "platform.error.device_revoked" => "This device has been revoked from the license.",
32
+ "platform.error.invalid_license" => "License key not found. Please verify the key.",
33
+ "platform.error.device_not_found" => "Device not registered. Please re-activate.",
34
+ "platform.error.contributor_required" => "Publishing extensions requires becoming a contributor. Sign in, open \"My Extensions\", and click \"Become a contributor\" (no review needed).",
35
+ "platform.error.missing_device_token" => "This device is not bound to a platform account. Authorize it before publishing.",
36
+ "platform.error.invalid_device_token" => "Device authorization is invalid. Please re-authorize this device.",
37
+ "platform.error.device_token_revoked" => "This device's authorization has been revoked. Please re-authorize before publishing.",
38
+ "platform.error.device_token_expired" => "This device's authorization has expired. Please re-authorize before publishing.",
39
+ "platform.error.owner_user_not_found" => "No account found for this device. Please re-authorize it.",
40
+ "platform.error.generic" => "Request failed (HTTP %<code>s). Please contact support.",
41
+ "platform.error.generic_with_code" => "Request failed (HTTP %<code>s, code: %<error_code>s). Please contact support."
24
42
  }.freeze
25
43
  end
26
44
  end
@@ -20,7 +20,25 @@ module Clacky
20
20
  "llm.error.bad_request" => "请求参数有误,请检查模型配置或重试",
21
21
  "llm.error.request_timeout" => "请求超时(已重试 %<retries>d 次)",
22
22
  "llm.error.network_failed" => "网络连接失败(已重试 %<retries>d 次)",
23
- "llm.error.service_unavailable" => "服务暂时不可用(已重试 %<retries>d 次)"
23
+ "llm.error.service_unavailable" => "服务暂时不可用(已重试 %<retries>d 次)",
24
+ "platform.error.invalid_proof" => "许可证密钥无效,请检查后重试。",
25
+ "platform.error.invalid_signature" => "请求签名无效。",
26
+ "platform.error.nonce_replayed" => "检测到重复请求,请重试。",
27
+ "platform.error.timestamp_expired" => "系统时钟不同步,请校准本机时间后重试。",
28
+ "platform.error.license_revoked" => "该许可证已被吊销,请联系客服。",
29
+ "platform.error.license_expired" => "该许可证已过期,请续订后继续。",
30
+ "platform.error.device_limit_reached" => "该许可证的设备数量已达上限。",
31
+ "platform.error.device_revoked" => "该设备已从许可证中移除。",
32
+ "platform.error.invalid_license" => "未找到许可证密钥,请核对后重试。",
33
+ "platform.error.device_not_found" => "设备未注册,请重新激活。",
34
+ "platform.error.contributor_required" => "发布扩展需要先成为扩展贡献者。请登录平台,打开「我的扩展」页面,点击「成为扩展贡献者」即可开通(无需审核)。",
35
+ "platform.error.missing_device_token" => "当前设备未绑定平台账户,请先授权此设备后再发布。",
36
+ "platform.error.invalid_device_token" => "设备授权已失效,请重新授权此设备。",
37
+ "platform.error.device_token_revoked" => "此设备的授权已被撤销,请重新授权后再发布。",
38
+ "platform.error.device_token_expired" => "此设备的授权已过期,请重新授权后再发布。",
39
+ "platform.error.owner_user_not_found" => "未找到该设备对应的账户,请重新授权此设备。",
40
+ "platform.error.generic" => "请求失败(HTTP %<code>s),请联系客服。",
41
+ "platform.error.generic_with_code" => "请求失败(HTTP %<code>s,错误码:%<error_code>s),请联系客服。"
24
42
  }.freeze
25
43
  end
26
44
  end