openclacky 1.4.0 → 1.5.0

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/lib/clacky/agent/llm_caller.rb +3 -3
  4. data/lib/clacky/agent/session_serializer.rb +50 -9
  5. data/lib/clacky/agent.rb +4 -5
  6. data/lib/clacky/brand_config.rb +102 -10
  7. data/lib/clacky/client.rb +22 -16
  8. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +135 -84
  9. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +144 -1
  10. data/lib/clacky/default_extensions/ext-studio/ext.yml +2 -4
  11. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +412 -173
  12. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +522 -0
  13. data/lib/clacky/extension/api_extension.rb +5 -6
  14. data/lib/clacky/extension/loader.rb +16 -2
  15. data/lib/clacky/extension/packager.rb +10 -1
  16. data/lib/clacky/extension/scaffold/templates/full/api/handler.rb.erb +16 -0
  17. data/lib/clacky/mcp/http_transport.rb +1 -1
  18. data/lib/clacky/platform_http_client.rb +9 -7
  19. data/lib/clacky/providers.rb +113 -5
  20. data/lib/clacky/server/http_server.rb +131 -38
  21. data/lib/clacky/server/session_registry.rb +9 -0
  22. data/lib/clacky/tools/grep.rb +6 -1
  23. data/lib/clacky/utils/environment_detector.rb +16 -9
  24. data/lib/clacky/utils/file_processor.rb +21 -24
  25. data/lib/clacky/utils/model_pricing.rb +85 -0
  26. data/lib/clacky/version.rb +1 -1
  27. data/lib/clacky/web/app.css +441 -48
  28. data/lib/clacky/web/app.js +29 -6
  29. data/lib/clacky/web/components/onboard.js +21 -7
  30. data/lib/clacky/web/features/backup/view.js +4 -3
  31. data/lib/clacky/web/features/extensions/store.js +56 -4
  32. data/lib/clacky/web/features/extensions/view.js +54 -24
  33. data/lib/clacky/web/features/mcp/view.js +5 -2
  34. data/lib/clacky/web/features/new-session/store.js +8 -4
  35. data/lib/clacky/web/features/version/view.js +5 -1
  36. data/lib/clacky/web/features/workspace/store.js +11 -0
  37. data/lib/clacky/web/features/workspace/view.js +10 -4
  38. data/lib/clacky/web/i18n.js +58 -36
  39. data/lib/clacky/web/index.html +74 -7
  40. data/lib/clacky/web/sessions.js +72 -15
  41. data/lib/clacky/web/settings.js +22 -3
  42. data/lib/clacky/web/theme.js +27 -58
  43. data/scripts/uninstall.sh +1 -1
  44. metadata +2 -5
  45. data/lib/clacky/default_extensions/ext-studio/skills/ext-debug/SKILL.md +0 -71
  46. data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +0 -73
  47. data/lib/clacky/default_extensions/ext-studio/skills/ext-scaffold/SKILL.md +0 -65
  48. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +0 -106
@@ -0,0 +1,522 @@
1
+ ---
2
+ name: ext-develop
3
+ description: Build, debug, or publish an OpenClacky extension — scaffold a new one from an idea, fix a broken/invisible panel/api/skill/agent, or ship it to the marketplace. Trigger on create/start extension, plugin, panel, ext verify error, "won't load", "not showing up", publish/ship/unpublish an extension.
4
+ agent: ext-developer
5
+ ---
6
+
7
+ # Extension Development
8
+
9
+ Build an OpenClacky extension end to end — scaffold, edit, verify, hot-reload, and
10
+ (only when asked) publish. Prefer editing real files and verifying over describing.
11
+
12
+ ## The extension model (ground truth)
13
+
14
+ An extension is one directory with a single `ext.yml` manifest declaring
15
+ `contributes:`. Nothing is nested — units reference each other by id. It survives
16
+ `gem update` and never requires editing gem source.
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 (use one, several, or all):
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
+ applies on the next load.
35
+
36
+ ## Hard rules — never break these
37
+
38
+ - ❌ **Never edit the gem source.** Do NOT `bundle show openclacky` and change files
39
+ in there. Everything lives in `~/.clacky/ext/local/<id>/` and survives `gem update`.
40
+ - ❌ **Never `restart the server` to apply a change.** Hot reload is per-request —
41
+ the user just reloads the WebUI page. If you're telling them to restart, you're wrong.
42
+ - ❌ **Never declare success on "it should work."** A task is done only when
43
+ `clacky ext verify` is clean AND the user reloaded and saw it work. Run verify —
44
+ don't imagine its output.
45
+ - ❌ **Never add `patches:` or `hooks:` unless the user explicitly asks.** They run
46
+ arbitrary Ruby and carry supply-chain risk. Default to `panels`/`api`/`skills`/`agents`.
47
+ - ❌ **Never publish on your own initiative.** Publishing is opt-in — see **Publish**.
48
+ - ❌ **Never write `window.Sessions` / `"Sessions" in window` in `view.js`.** Host
49
+ services are `const` bindings, not `window` properties — such checks return
50
+ `undefined`/`false` even when loaded. Always use `Clacky.Sessions.*` etc.
51
+ - ✅ **Always work in the `local` layer** (`~/.clacky/ext/local/<id>/`). `ext new` lands
52
+ there; that's the only layer you edit.
53
+
54
+ ## Which section do I need?
55
+
56
+ Pick exactly ONE and follow it top to bottom. Don't blend the three.
57
+
58
+ - Starting a new extension from an idea → **Scaffold**.
59
+ - Something is broken, `verify` errors, or a change didn't show up → **Debug & verify**.
60
+ - The user explicitly wants to share/ship it to others → **Publish** (optional; skip
61
+ it entirely for extensions the user only runs themselves).
62
+
63
+ **Reference: the contracts** is not a path — it's the field/slot/event/API ground truth
64
+ you consult from whichever path you're on.
65
+
66
+ ---
67
+
68
+ ## Reference: the contracts
69
+
70
+ Read the relevant reference doc with `web_fetch` before writing code — don't guess field
71
+ names, hook events, adapter methods, or the `Clacky.ext` WebUI contract. These docs are
72
+ long (well over the default cap); pass `max_length: 20000` so you get the whole page in one
73
+ fetch instead of a truncated head full of nav chrome.
74
+
75
+ ### Authoritative documentation
76
+
77
+ - Extension system overview → https://www.openclacky.com/docs/extension-system
78
+ - **ext.yml manifest — every field (names, avatar, title_zh, order, …)** → https://www.openclacky.com/docs/ext-manifest
79
+ - Panels (WebUI) → https://www.openclacky.com/docs/extend-webui
80
+ - API backends → https://www.openclacky.com/docs/extend-api
81
+ - **Calling the host's native APIs from a panel (sessions, trash/file-recovery, skills, memories, cron, billing, media)** → https://www.openclacky.com/docs/extend-host-api
82
+ - Agents (prompt, avatar, panels/skills wiring) → https://www.openclacky.com/docs/agent-config
83
+ - Channel adapters → https://www.openclacky.com/docs/extend-channel-adapter
84
+ - Patches → https://www.openclacky.com/docs/extend-patches
85
+ - Shell hooks → https://www.openclacky.com/docs/extend-shell-hooks
86
+
87
+ ### WebUI panels: the `Clacky.ext` contract
88
+
89
+ A panel is a plain `view.js` (no build step, no React, no iframe). It reaches the host
90
+ **only** through `window.Clacky` — everything else on the page is off-limits. There are
91
+ exactly three capabilities:
92
+
93
+ ```js
94
+ Clacky.ext.ui.mount(slot, spec, opts) // inject UI into a named slot
95
+ Clacky.ext.subscribe(event, handler) // observe host store events (read-only)
96
+ Clacky.ext.api.register(name, fn) // expose a named data source; api.resolve(name)
97
+ ```
98
+
99
+ **`ui.mount(slot, spec, opts)`** — `spec` is either `(container, ctx, runtime) => …` or
100
+ `{ create?, render }`. The render function:
101
+ - gets a host-owned `container` DOM element — append into it, **or** return a Node / HTML
102
+ string and the host appends for you;
103
+ - returning a **function** registers it as a teardown callback;
104
+ - returning **`null`/`undefined` renders nothing** — but returning `null` from a wrong
105
+ signature (e.g. `(ctx) => …` instead of `(container, ctx) => …`) is the #1 cause of a
106
+ red "crashed" placeholder. Match the signature exactly.
107
+
108
+ `ctx` carries `{ sessionId, agentProfile }`. `opts`: `order` (lower renders first,
109
+ default 100), `tab: { id, label, badge? }` (**required** for tabbed slots — `session.aside`
110
+ is tabbed), `agents: [profile]` (override auto scope), `workspace: id` (for nav items).
111
+
112
+ **Valid slot names** (mounting into any other name silently renders nothing, warned once):
113
+
114
+ ```
115
+ header.left header.right
116
+ sidebar.nav.top sidebar.nav sidebar.nav.bottom sidebar.footer
117
+ main.workspace
118
+ session.banner session.composer session.aside (session.aside is tabbed)
119
+ settings.tabs settings.body
120
+ ```
121
+
122
+ Agent scope is automatic: mounts into `session.*` / `settings.*` slots only show for the
123
+ panel's owning agent(s); all other slots (`sidebar.*`, `header.*`, `main.workspace`) are
124
+ global chrome. You rarely set `agents:` by hand.
125
+
126
+ **Per-session state** — for `session.aside/banner/composer`, pass `{ create(ctx), render }`:
127
+ `create` runs once per session and returns a runtime (put timers/recorders/subscriptions
128
+ there), `render(container, ctx, runtime)` runs on each show, and `runtime.dispose()` runs
129
+ when the session leaves. State survives tab switches; use this instead of module globals.
130
+
131
+ **Full-page workspace** — `Clacky.ext.ui.registerWorkspace(id, { title, render })` takes
132
+ over the main area with its own `#ext/<id>` URL; open it with `Clacky.ext.ui.openWorkspace(id)`,
133
+ typically from a `sidebar.nav` item mounted with `opts.workspace: id`.
134
+
135
+ **Safe mode** — `?pure=true` makes the whole registry a no-op; never rely on side effects
136
+ outside these calls.
137
+
138
+ ### Other host services under `Clacky.*`
139
+
140
+ Beyond `Clacky.ext`, the host exposes stores as properties on `window.Clacky`. Use them
141
+ instead of bare globals:
142
+
143
+ ```js
144
+ Clacky.Sessions.on("switched", handler); // active session store
145
+ Clacky.Router.go("session"); // top-level view routing
146
+ Clacky.Router.navigate("session", { id }); // navigate with params
147
+ Clacky.I18n.t("some.key"); // translations
148
+ Clacky.Modal.confirm("Delete?"); // dialogs
149
+ Clacky.Notify.info("Saved"); // toasts
150
+ Clacky.Auth.passed; // auth state
151
+ Clacky.Workspace.list(dir); // working-directory files
152
+ Clacky.Skills.list(); // skill catalog
153
+ Clacky.WS.send({ type: "..." }); // send a WebSocket message to the agent
154
+ ```
155
+
156
+ - Prefer `Clacky.Xxx.method(...)` — the recommended, forward-stable form. Never test with
157
+ `window.Sessions` / `"Sessions" in window` (see Hard rules).
158
+
159
+ A panel can also `fetch("/api/...")` the host's own REST endpoints directly (same origin,
160
+ auth is automatic) — sessions, trash/**file-recovery**, skills, memories, cron, billing,
161
+ media, and more each have a ready-made endpoint. Before telling a user a feature "can't be
162
+ done" (e.g. "delete a file but keep it recoverable"), check whether the host already
163
+ exposes it — `web_fetch` https://www.openclacky.com/docs/extend-host-api for the callable
164
+ list. Don't rebuild what the host already provides.
165
+
166
+ ### API backend: the `Clacky::ApiExtension` contract
167
+
168
+ `api/handler.rb` subclasses `Clacky::ApiExtension`. Routes mount under
169
+ `/api/ext/<ext_id>/`. This base class already wires up auth, JSON envelopes, timeouts, and
170
+ path params — you only write business logic. Full surface:
171
+
172
+ ```ruby
173
+ class MyExt < Clacky::ApiExtension
174
+ timeout 30 # class-wide default (max 600s)
175
+
176
+ get "/summary" do
177
+ json(count: session_manager.list.size) # json(key: val) → 200 JSON
178
+ end
179
+
180
+ post "/items/:id" do # :id → params["id"]
181
+ body = json_body # parsed request JSON (Hash)
182
+ q = query["page"] # query string params
183
+ File.write(data_path("items", "#{params['id']}.json"), body.to_json) # persistence
184
+ json({ ok: true }, status: 201)
185
+ end
186
+
187
+ get "/export", timeout: 60 do
188
+ send_data(bytes, content_type: "text/csv", filename: "out.csv")
189
+ end
190
+ end
191
+ ```
192
+
193
+ Response helpers: `json` / `text(str)` / `send_data(bytes, content_type:, filename:)` /
194
+ `error!(msg, status:)`. Request: `params` (path), `query`, `json_body`, `req`.
195
+
196
+ - **`data_path(*parts)`** is the **official way to persist user data** — it returns a path
197
+ under `~/.clacky/ext-data/<id>/`, **outside** the package tree, so it survives reloads,
198
+ `gem update`, and even uninstall/reinstall (uninstall keeps it by default; the user opts
199
+ in to deleting it via a checkbox). **Never** write user data into the extension's code
200
+ dir (`ext_dir` / `File.join(ext_dir, ...)`) — uninstall deletes the whole package, so
201
+ anything there is lost. Package-internal writes are only for disposable caches.
202
+ - Host context (white-listed): `session_manager`, `registry`, `agent_config`, `config`
203
+ (from ext.yml), `logger`, `ext_id`, `ext_dir`.
204
+ - Drive sessions from the backend: `create_session(prompt:, profile:, …)`,
205
+ `submit_task(session_id, prompt)`, `dispatch_to_session(session_id, prompt)` (runs a
206
+ side task on a fork and returns its reply without touching the conversation).
207
+ - Public (no-auth) endpoints: call `public_endpoint("/path")` in the class **and** set
208
+ `public: true` at ext.yml top level — both are required.
209
+
210
+ ### Patches & hooks (advanced — only when asked)
211
+
212
+ - **Patch** (`contributes.patches: [{ target, file, fingerprint?, on_mismatch }]`):
213
+ overrides a method via `Module#prepend` without editing gem source. `target` is
214
+ `"Clacky::Tools::WebSearch#execute"` (`#` = instance, `.` = class). `fingerprint` is a
215
+ SHA of the original method source; on drift the patch is disabled (`on_mismatch: disable`,
216
+ default) or warned (`warn`).
217
+ - **Hook** (`contributes.hooks: [{ event, file }]`): registers a lifecycle callback. Valid
218
+ `event` values (exactly these): `before_tool_use after_tool_use on_tool_error on_start
219
+ on_complete on_iteration session_rollback`. A `before_tool_use` hook returning
220
+ `{ action: :deny, reason: "…" }` **blocks** the tool call — this is how you audit or
221
+ gate dangerous commands.
222
+
223
+ ## Scaffold
224
+
225
+ Turn a plain-language idea into a working skeleton, then read the generated files.
226
+
227
+ ### 1 — Understand the idea
228
+
229
+ Figure out what it should DO and which contributes types it needs. Ask one clarifying
230
+ question only if genuinely ambiguous. Common mappings:
231
+
232
+ | User wants to… | contributes: field |
233
+ |---|---|
234
+ | Show X in a side panel / add a button / dashboard | `panels:` (+ `api:` if it needs a backend or an external service) |
235
+ | A capability the AI can invoke (summarize, translate, format) | `skills:` |
236
+ | A specialized assistant with its own personality/tools | `agents:` (usually bundling its own panels/skills) |
237
+ | Connect to Slack / an in-house IM | `channels:` |
238
+ | Change behavior of a built-in method | `patches:` |
239
+ | Audit / block / observe tool calls | `hooks:` |
240
+
241
+ Keep it minimal — most useful extensions are one panel + one handler, or one skill.
242
+ Do NOT add `patches` or `hooks` unless the user explicitly asks; they run arbitrary
243
+ Ruby and carry supply-chain risk.
244
+
245
+ **Appearance & naming are manifest fields, not separate features.** When the user wants a
246
+ custom logo/avatar for an agent, a Chinese (or other-language) display name, a panel tab
247
+ label, or ordering, those are optional keys in `ext.yml` — e.g. agent `avatar:` (image
248
+ path), `title` / `title_zh`, `description` / `description_zh`, `order`. Never say it can't
249
+ be done; set the field and check the full list in the ext.yml manifest doc.
250
+
251
+ ### 2 — Generate the skeleton
252
+
253
+ Pick a lowercase, hyphenated id derived from the idea (e.g. `weather-panel`).
254
+
255
+ ```
256
+ clacky ext new <id>
257
+ ```
258
+
259
+ This creates `~/.clacky/ext/local/<id>/` with a working hello panel + handler:
260
+ - `ext.yml` — the manifest
261
+ - `panels/hello/view.js` — a panel that pings the backend
262
+ - `api/handler.rb` — a `Clacky::ApiExtension` subclass mounted at `/api/ext/<id>/`
263
+
264
+ Use `--full` only when the user needs the kitchen-sink reference exercising all seven
265
+ contributes types — it's a lot to read, so prefer the plain scaffold otherwise.
266
+
267
+ ### 3 — Read what was generated
268
+
269
+ Always read the generated `ext.yml`, `view.js`, and `handler.rb` before editing. This
270
+ is your starting point; you'll reshape it to match the idea.
271
+
272
+ ### 4 — Reshape to the idea
273
+
274
+ Before editing, re-read the contract for whatever the idea needs (panel / API / patch /
275
+ hook) in **Reference: the contracts** above — don't guess field names, slot names, hook
276
+ events, or the `Clacky.ext` surface.
277
+
278
+ The scaffold ships a working "hello" panel that pings its backend. Turn it into the real
279
+ feature by editing those three files. Below is a concrete before → after for a tiny
280
+ "add a note" panel — use it as the shape to copy, not the literal content.
281
+
282
+ **`ext.yml`** — rename the panel id/view to the feature; add `skills:`/`agents:` only if needed:
283
+
284
+ ```yaml
285
+ contributes:
286
+ api: api/handler.rb
287
+ panels:
288
+ - id: notes # was: hello
289
+ view: panels/notes/view.js # was: panels/hello/view.js
290
+ attach: ["*"]
291
+ ```
292
+
293
+ **`panels/notes/view.js`** — keep the `Clacky.ext.ui.mount(...)` wrapper and host CSS
294
+ classes; swap the body for the real UI, POST to your own route:
295
+
296
+ ```js
297
+ Clacky.ext.ui.mount("session.aside", function (ctx) {
298
+ var el = document.createElement("div");
299
+ el.style.padding = "16px";
300
+ var input = document.createElement("input");
301
+ input.className = "form-input"; // reuse host theme
302
+ var btn = document.createElement("button");
303
+ btn.className = "btn-primary";
304
+ btn.textContent = "Save note";
305
+ btn.addEventListener("click", async function () {
306
+ await fetch("/api/ext/<id>/notes", { // relative to your mount
307
+ method: "POST",
308
+ headers: { "Content-Type": "application/json" },
309
+ body: JSON.stringify({ text: input.value }),
310
+ });
311
+ Clacky.Notify.info("Saved"); // host toast, not window.alert
312
+ });
313
+ el.append(input, btn);
314
+ return el;
315
+ }, { tab: { id: "notes", label: () => "Notes" }, order: 500 });
316
+ ```
317
+
318
+ **`api/handler.rb`** — stay a `Clacky::ApiExtension` subclass; add the route the panel
319
+ calls. Persist user data with `data_path` (lands in `~/.clacky/ext-data/<id>/`, survives
320
+ reloads, `gem update`, and uninstall/reinstall), never into the code dir:
321
+
322
+ ```ruby
323
+ class <Prefix>Ext < Clacky::ApiExtension
324
+ post "/notes" do # matches /api/ext/<id>/notes
325
+ text = json_body["text"].to_s
326
+ File.write(data_path("notes.txt"), "#{text}\n", mode: "a")
327
+ json(saved: true)
328
+ end
329
+ end
330
+ ```
331
+
332
+ Rules while reshaping:
333
+ - Keep the panel `view:` path and the on-disk `view.js` path in sync — mismatched paths
334
+ are the #1 cause of a `loader.error`.
335
+ - Routes in `handler.rb` are **relative** to `/api/ext/<id>/`; the `view.js` `fetch` must
336
+ match. A mismatch is a silent 404, not a verify error.
337
+ - Persist state with `data_path(...)`, never by writing into the extension's code dir.
338
+ - The `ui.mount` render signature is `(container, ctx, runtime)` — `container` is the first
339
+ argument, not `ctx`. Writing a shorter `(ctx) => ...` signature shifts every argument, so
340
+ session checks misbehave. Returning `null` is safe (renders nothing); only a **thrown
341
+ exception** shows the red crashed-panel box.
342
+ - Reuse host CSS classes (`btn-primary`, `btn-secondary`, `form-input`, `form-textarea`,
343
+ `form-label`) and host services (`Clacky.Notify`, `Clacky.Modal`) instead of raw
344
+ `alert`/`confirm`, so the panel inherits the theme.
345
+ - A skill is a `SKILL.md` under `skills/<id>/`; an agent is a `system_prompt.md` that can
346
+ reference `panels: [id]` and `skills: [id]`. Add those blocks to `ext.yml` only if the
347
+ idea needs them.
348
+
349
+ ### 5 — Confirm it loads
350
+
351
+ Run `clacky ext verify` and confirm the new units resolve with no errors, then have the
352
+ user reload the WebUI page. If verify reports problems, go to **Debug & verify**.
353
+
354
+ ## When NOT to build an extension
355
+
356
+ - The user is building features in their own app that just *use* openclacky — that's
357
+ normal coding, no extension container needed.
358
+ - The user wants a tool/skill for *their own* project — use `.clacky/skills/` or
359
+ `.clacky/tools/` in their project, not a gem-level container.
360
+ - The change can be made via `clacky config set ...` — prefer config over patches.
361
+
362
+ ---
363
+
364
+ ## Debug & verify
365
+
366
+ Your primary instrument is `clacky ext verify` — a compiler for extensions: every issue
367
+ is structured with a `code`, `message`, the offending `file`, and a `hint`.
368
+
369
+ **Top 5 things that break — check these first:**
370
+
371
+ | Symptom | Almost always | Fix |
372
+ |---|---|---|
373
+ | Red error box where the panel should be | `ui.mount` render signature is wrong / returned `null` | signature is `(container, ctx, runtime)` — not `(ctx)` |
374
+ | Panel doesn't appear at all | `slot` name typo (silent) or no `attach:` | use a valid slot; set `attach: ["*"]` or an agent id |
375
+ | Frontend `fetch` gets 404 | route in `handler.rb` ≠ path in `view.js` fetch | routes are relative to `/api/ext/<id>/` |
376
+ | `loader.error` on verify | `ext.yml` `view:` path ≠ the on-disk `view.js` path | make the two match exactly |
377
+ | Edited a file, nothing changed | page not reloaded (or edited `ext.yml`) | reload the WebUI page — hot reload is per-request |
378
+
379
+
380
+ ### 1 — Run verify
381
+
382
+ ```
383
+ clacky ext verify
384
+ ```
385
+
386
+ Read the output line by line. `[OK]` confirms a resolved unit; `[ERR]` blocks a load;
387
+ `[WARN]` is advisory. Each issue looks like:
388
+
389
+ ```
390
+ [ERR] <ext> <unit> (<code>) — <message> [<file>]
391
+ hint: <how to fix>
392
+ ```
393
+
394
+ **Always trust the `hint` first.** The line below tells you the fix per code; do the
395
+ smallest change, re-run verify, repeat until clean — fix ONE issue at a time.
396
+
397
+ ### 2 — Fix by error code
398
+
399
+ - **`loader.error`** → a file the manifest points at is missing, or `ext.yml` isn't valid
400
+ YAML. **Do:** open the `file` path in the error; make sure it exists and the path in
401
+ `ext.yml` matches it exactly. (skill → `SKILL.md` under `skills/<id>/`; agent → its
402
+ `prompt` file; panel → its `view` file; api → `api/handler.rb`.)
403
+ - **`schema.unknown_contributes`** → a top-level key under `contributes:` is misspelled.
404
+ **Do:** fix the spelling to one of `panels api skills agents channels patches hooks`.
405
+ - **`schema.unknown_key`** → an unknown **top-level** key in `ext.yml`. **Do:** fix the
406
+ spelling. Allowed top-level keys: `id name title description version origin author
407
+ homepage license public license_required keywords contributes`.
408
+ - **`schema.unknown_field`** → a unit has a field not allowed for its type. **Do:** delete
409
+ or rename that field. Allowed fields per type (this is the authoritative list — do not
410
+ invent others):
411
+ - panel: `id title title_zh description description_zh view order attach entry_points`
412
+ - api: `id handler`
413
+ - skill: `id dir protected`
414
+ - agent: `id title title_zh description description_zh order prompt panels skills avatar`
415
+ - channel: `id platform adapter`
416
+ - patch: `target file fingerprint on_mismatch`
417
+ - hook: `event file`
418
+ - **`schema.bad_attach`** → a panel `attach:` entry isn't a valid token. **Do:** set it to
419
+ an agent id or `"*"` (all).
420
+ - **`ref.missing_panel`** → an agent's `panels: [id]` names a panel that doesn't exist.
421
+ **Do:** fix the id, or use `<ext_id>/<panel_id>` to point at another extension's panel.
422
+ - **`ref.missing_skill`** → an agent's `skills: [id]` names a skill that doesn't exist.
423
+ **Do:** fix the id, or add the `SKILL.md`.
424
+ - **`ref.missing_attach_agent`** → a panel's `attach:` names a nonexistent agent.
425
+ **Do:** fix the agent id.
426
+ - **`override`** (warning) → a higher layer is shadowing a lower one
427
+ (`local > installed > builtin`). **Do:** usually intentional — leave it; confirm with the
428
+ user only if the shadowing is a surprise.
429
+
430
+ Fix one issue, re-run verify, repeat until clean.
431
+
432
+ ### 3 — "It verifies but doesn't show up"
433
+
434
+ If verify is clean but a change isn't visible:
435
+ - **Hot reload is per-request.** After editing `view.js`, `handler.rb`, or a `SKILL.md`,
436
+ the user must **reload the WebUI page** — no restart, but a stale tab won't update on
437
+ its own. Editing `ext.yml` also applies on the next load.
438
+ - **Panel not appearing?** In order: (1) the `slot` name in `ui.mount` must be one of the
439
+ valid slots — a typo like `session.aisde` silently renders nothing (check the browser
440
+ console for a "unknown slot" warning); (2) check the panel's `attach:` (or the agent
441
+ that references it via `panels: [id]`) — a panel with no `attach` and no referencing
442
+ agent has nothing to mount onto; (3) a red error box means the render function threw
443
+ or returned `null` from a wrong signature — open the console for the stack.
444
+ - **API 404?** Routes are relative to `/api/ext/<ext_id>/`. Confirm the handler subclasses
445
+ `Clacky::ApiExtension` and the route pattern matches what `view.js` fetches.
446
+ - **Skill not triggering?** The AI selects skills by their `description`. Make the
447
+ description concrete about WHEN to use it.
448
+
449
+ ### 4 — Confirm the fix
450
+
451
+ End with a clean `clacky ext verify` and have the user reload to confirm the behavior
452
+ actually works — don't declare success on "should work."
453
+
454
+ ---
455
+
456
+ ## Publish (optional)
457
+
458
+ Publishing is **not** a required step. Many extensions are built for the user's own use —
459
+ scaffold, verify, and reload is the whole job. Only publish when the user explicitly asks
460
+ to share, ship, or list the extension for others. Never publish on your own initiative or
461
+ as a "wrap up" of the build.
462
+
463
+ The **Extension & Creation panel** has a Publish button — prefer it for a
464
+ guided flow. Use the CLI below for scripted/CI publishing.
465
+
466
+ ### Before publishing
467
+
468
+ - The extension must live in the **local** layer (`~/.clacky/ext/local/<id>/`). Only local
469
+ containers can be packed; encrypted (`SKILL.md.enc`) containers are rejected.
470
+ - Publishing requires the device to be **bound to a platform account** (it attributes the
471
+ extension to that account). If it isn't bound, tell the user to authorize the device
472
+ first — don't try to work around it.
473
+ - Run `clacky ext verify` one last time and confirm no errors.
474
+ - **README check:** If `~/.clacky/ext/local/<id>/README.md` does not exist, ask the user
475
+ before proceeding: "No README.md found — would you like me to write usage instructions
476
+ first?" If yes, read the source files and write a concise README, then publish. If the
477
+ user asks to write a README / usage instructions at any point, do the same.
478
+
479
+ ### Publish (first time)
480
+
481
+ ```
482
+ clacky ext publish <id>
483
+ ```
484
+
485
+ Packs the local container into a zip and uploads it. On success: `Published <id>
486
+ v<version> → status=<status>`. Options:
487
+ - `--status draft` — publish as a draft (not visible on the public marketplace). Omit or
488
+ use `--status published` to go live.
489
+ - `--changelog "..."` — release notes for this version.
490
+
491
+ ### Publish a new version
492
+
493
+ If already published, a plain `publish` fails with `Error: <id> already published. Re-run
494
+ with --force to publish a new version.` Re-run with `--force` (and ideally a `--changelog`);
495
+ the patch version auto-increments on the platform side.
496
+
497
+ ```
498
+ clacky ext publish <id> --force --changelog "Fixed the weather refresh bug"
499
+ ```
500
+
501
+ ### List your published extensions
502
+
503
+ ```
504
+ clacky ext published
505
+ ```
506
+
507
+ Shows each extension with its latest version, status, and unit summary.
508
+
509
+ ### Unpublish
510
+
511
+ ```
512
+ clacky ext unpublish <id>
513
+ ```
514
+
515
+ Soft-deletes (takes down) one of your published extensions. Confirm with the user first —
516
+ it removes it from the marketplace.
517
+
518
+ ### Wrap up
519
+
520
+ After a successful publish, tell the user the version and status in plain terms, and
521
+ mention they can run `clacky ext published` to see it, or bump a new version anytime with
522
+ `--force`.
@@ -229,12 +229,11 @@ module Clacky
229
229
  @query ||= req.query || {}
230
230
  end
231
231
 
232
- def data_path(*parts)
233
- base = File.join(self.class.ext_dir, "data")
234
- FileUtils.mkdir_p(base)
235
- File.join(base, *parts.map(&:to_s))
236
- end
237
-
232
+ def data_path(*parts)
233
+ base = Clacky::ExtensionLoader.data_dir_for(self.class.ext_id)
234
+ FileUtils.mkdir_p(base)
235
+ File.join(base, *parts.map(&:to_s))
236
+ end
238
237
  def ext_dir
239
238
  self.class.ext_dir
240
239
  end
@@ -47,6 +47,11 @@ module Clacky
47
47
  DISABLED_FILE = File.expand_path("~/.clacky/ext/disabled.json")
48
48
  MANIFEST = "ext.yml"
49
49
 
50
+ # User data lives OUTSIDE the package tree so uninstalling (which deletes
51
+ # the package dir) never takes the data with it, and reinstalling the same
52
+ # extension transparently reconnects to it.
53
+ DATA_DIR = File.expand_path("~/.clacky/ext-data")
54
+
50
55
  # Layers in ascending priority; later entries override earlier ones by id.
51
56
  LAYERS = %i[builtin installed local].freeze
52
57
  ORIGINS = %w[self marketplace enterprise].freeze
@@ -75,6 +80,11 @@ module Clacky
75
80
  end
76
81
  end
77
82
 
83
+ # Package-external directory holding an extension's persisted user data.
84
+ def data_dir_for(id)
85
+ File.join(DATA_DIR, id.to_s)
86
+ end
87
+
78
88
  # Scan all layers, resolve overrides, return a structured Result.
79
89
  # `layers` maps layer name => root dir; defaults to the real three-layer
80
90
  # dirs. Tests inject temp dirs through it.
@@ -172,12 +182,16 @@ module Clacky
172
182
 
173
183
  # Remove an installed extension by deleting its installed-layer dir.
174
184
  # Only the installed layer is removable (builtin ships with the gem;
175
- # local is the author's own working copy). Returns true on removal.
176
- def uninstall!(id)
185
+ # local is the author's own working copy). Package-external user data is
186
+ # preserved by default so a reinstall reconnects to it; pass
187
+ # `purge_data: true` to also delete ~/.clacky/ext-data/<id>. Returns true
188
+ # on removal.
189
+ def uninstall!(id, purge_data: false)
177
190
  dir = File.join(INSTALLED_DIR, id.to_s)
178
191
  return false unless File.directory?(dir)
179
192
 
180
193
  FileUtils.rm_rf(dir)
194
+ FileUtils.rm_rf(data_dir_for(id)) if purge_data
181
195
  enable!(id) # clear any stale disabled flag
182
196
  invalidate_cache!
183
197
  true
@@ -195,7 +195,9 @@ module Clacky
195
195
  dirs = children.select { |c| File.directory?(File.join(root, c)) }
196
196
 
197
197
  if File.file?(File.join(root, MANIFEST))
198
- return [File.basename(File.expand_path(root)), root]
198
+ id = manifest_id(File.join(root, MANIFEST))
199
+ raise Error, "archive has no top-level container and #{MANIFEST} declares no id" if id.empty?
200
+ return [id, root]
199
201
  end
200
202
 
201
203
  if dirs.size == 1 && File.file?(File.join(root, dirs.first, MANIFEST))
@@ -205,6 +207,13 @@ module Clacky
205
207
  raise Error, "archive does not contain a single container with #{MANIFEST}"
206
208
  end
207
209
 
210
+ private def manifest_id(manifest_path)
211
+ data = YAMLCompat.load_file(manifest_path)
212
+ data.is_a?(Hash) ? data["id"].to_s.strip : ""
213
+ rescue StandardError
214
+ ""
215
+ end
216
+
208
217
  private def verify_installed(ext_id, installed_dir)
209
218
  layers =
210
219
  if installed_dir == Clacky::ExtensionLoader::INSTALLED_DIR
@@ -13,4 +13,20 @@ class <%= const_prefix %>Ext < Clacky::ApiExtension
13
13
  get "/stats" do
14
14
  json(uptime: Process.clock_gettime(Process::CLOCK_MONOTONIC).round(2))
15
15
  end
16
+
17
+ # ── Persisted user data example — /api/ext/<%= slug %>/notes
18
+ # `data_path` returns a path under ~/.clacky/ext-data/<%= slug %>/, OUTSIDE the
19
+ # package, so it survives uninstall + reinstall. Store user data here — never
20
+ # in the package dir (ext_dir), which is deleted on uninstall.
21
+ # Uncomment and adapt when you need to persist data:
22
+ #
23
+ # get "/notes" do
24
+ # file = data_path("notes.json")
25
+ # json(notes: File.exist?(file) ? JSON.parse(File.read(file)) : [])
26
+ # end
27
+ #
28
+ # post "/notes" do
29
+ # File.write(data_path("notes.json"), JSON.generate(json_body["notes"] || []))
30
+ # json(ok: true)
31
+ # end
16
32
  end
@@ -126,7 +126,7 @@ module Clacky
126
126
  private def consume_sse(res)
127
127
  buffer = String.new
128
128
  res.read_body do |chunk|
129
- buffer << chunk
129
+ buffer << chunk.gsub("\r\n", "\n").gsub("\r", "\n")
130
130
  while (idx = buffer.index("\n\n"))
131
131
  event = buffer.slice!(0, idx + 2)
132
132
  data_lines = event.each_line.map(&:chomp).select { |l| l.start_with?("data:") }