Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 22 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Plugin Library (sidecar browse UI)

Three-pane explorer for the Plugin Catalog: plugins → skills / agents / rules → the full `SKILL.md` rendered in a reading pane, sourced live from the on-disk plugin cache. "Apply to a bot" picks a target from the live `gbot` roster and confirms before anything is queued.
Three-pane explorer for the Plugin Catalog: plugins → skills / agents / rules → the full `SKILL.md` rendered in a reading pane, sourced live from the on-disk plugin cache. "Send to a bot" picks a target from the live `gbot` roster and confirms before sending it directly.

## Tour

Expand All @@ -18,17 +18,13 @@ Click a skill and the full `SKILL.md` opens in the reading pane. Front matter be

![A reference file](screens/04-reference-tab.png)

"Apply to a bot" pulls the live roster from `gbot` (bots and groups, with their avatars) and ends in one sentence you confirm. Every `/api/apply` status is turned into plain language; an install that isn't there yet is its own explicit step.

![Pick a bot](screens/05-pick-a-bot.png)

![Confirm](screens/06-confirm.png)
"Send to a bot" pulls the live roster from `gbot` (bots and groups, with their avatars), asks for confirmation, then calls `gbot send` directly.

Every installed plugin gets the same treatment, not only pstack.

![Cursor Team Kit](screens/07-cursor-team-kit.png)

Marketplace listings show catalog copy and offer an install path.
Marketplace listings show catalog copy and can be sent to a bot.

![Marketplace](screens/08-marketplace.png)

Expand Down Expand Up @@ -61,6 +57,17 @@ Team Marketplaces require a Cursor Teams or Enterprise plan.
If you previously imported `main`, re-import the marketplace and select the
generated branch.

#### Without team marketplace

```sh
git clone --branch agent-bundle-artifact --depth 1 \
https://github.com/ScriptedAlchemy/plugin-library.git
cd plugin-library && node ./install.mjs
```

Reload Cursor (`Developer: Reload Window`). From that clone directory, verify
with `npx --no-install agent-bundle doctor --from . --host cursor`.

For a local proof from a checkout:

```bash
Expand Down Expand Up @@ -96,11 +103,11 @@ node artifact/scripts/plugin-library.mjs status --json
node artifact/scripts/plugin-library.mjs stop
```

The bundled skill also tells agents how to answer skill questions from the read-only API without opening a window, and forbids them from calling `POST /api/apply` — applying to a bot stays a user click.
The bundled skill also tells agents how to answer skill questions from the read-only API without opening a window. Sending to a bot stays a user-confirmed click.

`npm test` builds the generated artifact, then runs the `node --test` suite
(installer lifecycle, front matter, cache dedupe, path containment,
apply-status mapping, and an HTTP smoke test against a throwaway cache).
direct `gbot send`, and an HTTP smoke test against a throwaway cache).

## Browse API (read-only)

Expand All @@ -113,24 +120,17 @@ apply-status mapping, and an HTTP smoke test against a throwaway cache).

Override the cache root with `CURSOR_PLUGIN_CACHE`, the CLI with `GBOT_BIN`.

## Apply contract (Plugin Applier)
## Send contract

`POST /api/apply` body:
The UI calls `POST /api/send` only after the user confirms:

```json
{ "plugin_id": "…", "bot_ref": "…", "skill_id?": "…", "confirmed": true, "mode?": "profile_bake|nudge_send" }
{ "plugin_id": "…", "bot_ref": "…", "skill_id?": "…" }
```

| Result | When |
|--------|------|
| `needs_install_confirm` | not installed, `confirmed` not true |
| `install_queued` | not installed, `confirmed: true` → Applier drains → `InstallPlugin` (account only) |
| `missing_attach_api` | installed; no per-bot skill attach yet (default) |
| `profile_bake_queued` / `nudge_send_queued` | only if `mode` set + confirmed (opt-in; never silent) |

Also: `GET /api/apply/pending`, `POST /api/apply/ack` `{ "id" }`.

Explorer UI confirm counts as the user's Explorer confirm. No silent fleet bot writes.
The server verifies the plugin and optional skill against its catalog, then
runs `gbot send <target> <message> --json`. There is no queue, Applier, install
branch, or per-bot attach fallback.

## Data

Expand Down
1 change: 1 addition & 0 deletions agent-bundle.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default defineConfig({
plugin: {
description:
'Browse installed and marketplace plugins, read every skill in full, and hand one to a Grok Bot.',
logo: './assets/logo.svg',
name: 'plugin-library',
},
runtime: { node: '22.19.0' },
Expand Down
11 changes: 9 additions & 2 deletions lib/bots.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ function runGbot(args) {
const e = new Error(
err.code === "ENOENT"
? `\`${GBOT_BIN}\` not found on PATH`
: String(stderr || err.message).trim(),
: String(stderr || stdout || err.message).trim(),
);
e.code = err.code;
return reject(e);
Expand All @@ -33,6 +33,13 @@ function runGbot(args) {
});
}

/** Send one confirmed instruction directly through gbot. */
async function sendToBot(target, message) {
const result = await runGbot(["send", target, message]);
if (!result || typeof result !== "object") throw new Error("gbot send: expected an object");
return result;
}

/** Boundary check on gbot output: a shape change surfaces as a 502, not an empty roster. */
function shapeList(list, what) {
if (!Array.isArray(list)) throw new Error(`gbot ${what} list: expected an array`);
Expand Down Expand Up @@ -78,4 +85,4 @@ async function listBots({ force = false } = {}) {
}
}

module.exports = { listBots };
module.exports = { listBots, sendToBot };
10 changes: 5 additions & 5 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
els.readerStatus.textContent = msg;
els.readerStatus.className = `status ${cls}`;
}
function onApplyResult(msg, tone) {
function onSendResult(msg, tone) {
toast(msg, tone);
setStatus(msg, tone);
}
Expand Down Expand Up @@ -171,7 +171,7 @@
if (p.skill_count) chips.push(`<span class="chip"><b>${p.skill_count}</b> skills</span>`);
if (p.connector_count) chips.push(`<span class="chip"><b>${p.connector_count}</b> connectors</span>`);
}
chips.push(`<button class="chip link" id="applyPlugin">${p.installed ? "Apply whole plugin to a bot…" : "Install for a bot…"}</button>`);
chips.push(`<button class="chip link" id="sendPlugin">Send plugin to a bot…</button>`);

let html = `<div class="hero">
${logoHtml(p, "lg")}
Expand Down Expand Up @@ -210,7 +210,7 @@
els.main.scrollTop = 0;
wireLogos(els.main);
els.main.querySelectorAll(".card[data-doc]").forEach((c) => c.addEventListener("click", () => navigate(c.dataset.doc)));
$("applyPlugin").addEventListener("click", () => Picker.openPicker({ root: els.picker, plugin: p, skill: null, onResult: onApplyResult }));
$("sendPlugin").addEventListener("click", () => Picker.openPicker({ root: els.picker, plugin: p, skill: null, onResult: onSendResult }));
markCurrentCard();
}

Expand Down Expand Up @@ -288,8 +288,8 @@

const resolved = p.local ? docTabs(p, kind, id) : null;
const skill = resolved ? resolved.skill : null;
els.readerApply.textContent = skill ? `Apply “${skill.name}” to a bot…` : "Apply plugin to a bot…";
els.readerApply.onclick = () => Picker.openPicker({ root: els.picker, plugin: p, skill, onResult: onApplyResult });
els.readerApply.textContent = skill ? `Send “${skill.name}” to a bot…` : "Send plugin to a bot…";
els.readerApply.onclick = () => Picker.openPicker({ root: els.picker, plugin: p, skill, onResult: onSendResult });
if (!resolved) {
els.readerBody.innerHTML = `<div class="notice" style="margin-top:16px">Not found in the local plugin cache.</div>`;
return;
Expand Down
2 changes: 1 addition & 1 deletion public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ <h1>Plugin Library</h1>
<div class="reader-body" id="readerBody"></div>
<div class="reader-foot">
<span class="status" id="readerStatus"></span>
<button type="button" class="btn" id="readerApply">Apply to a bot…</button>
<button type="button" class="btn" id="readerApply">Send to a bot…</button>
</div>
</aside>
</div>
Expand Down
76 changes: 15 additions & 61 deletions public/picker.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
/* global Markdown */
/**
* Bot picker + apply flow. One state object, one render() that switches on
* `stage`, and one pure function that maps an /api/apply response to the
* next stage. `confirmed: true` is only ever sent from a stage whose button
* the user just clicked.
* Bot picker + direct send flow. One state object and one render() switch.
* The selected target receives a message only after the user confirms.
*/
(function (global) {
"use strict";
Expand Down Expand Up @@ -35,31 +33,10 @@
return `<span class="avatar ${esc(shape)}" style="${style}">${esc(initials(b.name))}</span>`;
}

/** /api/apply response → next stage. Pure; the only place the contract is interpreted. */
function nextStage(json) {
const status = json.status || json.error;
switch (status) {
case "needs_install_confirm":
return { stage: "confirm-install" };
case "needs_mode_confirm":
return { stage: "confirm-nudge" };
case "missing_attach_api":
return { stage: "offer-nudge" };
case "install_queued":
return { stage: "done", tone: "ok", message: "Install queued for the Plugin Applier." };
case "nudge_send_queued":
return { stage: "done", tone: "ok", message: "Nudge queued for the Plugin Applier." };
case "profile_bake_queued":
return { stage: "done", tone: "ok", message: "Profile bake queued for the Plugin Applier." };
default:
return { stage: "done", tone: "err", message: json.message || `Unexpected response: ${status}` };
}
}

/**
* @param {object} opts
* @param {HTMLElement} opts.root container to render into
* @param {object} opts.plugin { plugin_id, name, installed }
* @param {object} opts.plugin { plugin_id, name }
* @param {object|null} opts.skill { id, name } or null for the whole plugin
* @param {(msg: string, tone: string) => void} opts.onResult
*/
Expand Down Expand Up @@ -100,18 +77,20 @@
render();
}

async function submit(extra) {
async function submit() {
s.stage = "working";
render();
const payload = { plugin_id: plugin.plugin_id, bot_ref: s.selected.id, ...extra };
const payload = { plugin_id: plugin.plugin_id, bot_ref: s.selected.id };
if (skill) payload.skill_id = skill.id;
try {
const r = await fetch("/api/apply", {
const r = await fetch("/api/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
Object.assign(s, nextStage(await r.json()));
const json = await r.json();
if (!r.ok || !json.ok) throw new Error(json.message || json.error || `HTTP ${r.status}`);
Object.assign(s, { stage: "done", tone: "ok", message: json.message });
} catch (e) {
Object.assign(s, { stage: "done", tone: "err", message: String(e.message || e) });
}
Expand All @@ -135,39 +114,14 @@
}

function confirmHtml() {
const verb = plugin.installed ? "Apply" : "Install";
switch (s.stage) {
case "confirm":
return {
sentence: `${verb} ${thing} for ${target()}?`,
fine: "Nothing changes on the bot until the Plugin Applier confirms it.",
cancel: "Cancel",
go: verb,
action: () => submit({}),
};
case "confirm-install":
return {
sentence: `<b>${esc(plugin.name)}</b> isn't installed yet.`,
fine: `Queue an account-wide install for the Plugin Applier, then the apply for ${target()}?`,
cancel: "Cancel",
go: "Queue install",
action: () => submit({ confirmed: true }),
};
case "offer-nudge":
return {
sentence: "There's no per-bot attach API yet, so nothing was changed.",
fine: `You can instead <b>send ${target()} a message</b> pointing at ${thing}. This posts to the bot's thread.`,
cancel: "Done",
go: "Send nudge",
action: () => submit({ mode: "nudge_send", confirmed: true }),
};
case "confirm-nudge":
return {
sentence: `Send ${target()} a message about ${thing}?`,
fine: "This posts to the bot's thread once the Plugin Applier drains the queue.",
sentence: `Send ${thing} to ${target()}?`,
fine: "This sends a direct message through gbot.",
cancel: "Cancel",
go: "Send nudge",
action: () => submit({ mode: "nudge_send", confirmed: true }),
go: "Send",
action: submit,
};
case "working":
return { sentence: "Working…", fine: "", cancel: null, go: null, action: null };
Expand All @@ -194,7 +148,7 @@
root.innerHTML = `<div class="picker-backdrop" data-close></div>
<div class="picker" role="dialog" aria-modal="true" aria-label="Choose a bot">
<div class="picker-head">
<div class="what">${plugin.installed ? "Apply" : "Install"} ${thing}</div>
<div class="what">Send ${thing}</div>
<h3>Which bot?</h3>
<input type="search" data-search placeholder="Search bots and groups…" autocomplete="off" value="${esc(s.query)}" ${showRoster ? "" : "disabled"} />
</div>
Expand Down Expand Up @@ -253,5 +207,5 @@
return { close };
}

global.Picker = { openPicker, nextStage };
global.Picker = { openPicker };
})(window);
Binary file removed screens/05-pick-a-bot.png
Binary file not shown.
Binary file removed screens/06-confirm.png
Binary file not shown.
Loading