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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -0
- data/lib/clacky/agent.rb +8 -1
- data/lib/clacky/agent_profile.rb +121 -53
- data/lib/clacky/brand_config.rb +309 -6
- data/lib/clacky/cli.rb +4 -57
- data/lib/clacky/default_extensions/coding/ext.yml +18 -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 +32 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +1269 -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/ext.yml +18 -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} +1 -2
- 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 -89
- 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 +483 -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 +313 -144
- data/lib/clacky/server/session_registry.rb +9 -2
- data/lib/clacky/skill_loader.rb +28 -1
- data/lib/clacky/ui2/ui_controller.rb +9 -6
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +748 -573
- 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 +316 -79
- 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 +389 -0
- data/lib/clacky/web/features/new-session/store.js +186 -0
- data/lib/clacky/web/features/new-session/view.js +319 -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 +46 -9
- 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 +118 -106
- data/lib/clacky/web/index.html +138 -188
- data/lib/clacky/web/sessions.js +64 -269
- data/lib/clacky/web/settings.js +1 -1
- data/lib/clacky/web/skills.js +2 -0
- data/lib/clacky/web/theme.js +2 -0
- data/lib/clacky/web/ws-dispatcher.js +1 -0
- data/lib/clacky/web/ws.js +2 -0
- data/lib/clacky.rb +21 -10
- metadata +55 -24
- 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
|
@@ -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,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
|
data/lib/clacky/locales/en.rb
CHANGED
|
@@ -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
|
data/lib/clacky/locales/zh.rb
CHANGED
|
@@ -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
|