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
28 changes: 24 additions & 4 deletions apps/app/src/components/tools/ExtensionsDetailStates.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,16 @@ const CATALOG_PLUGIN = {
} satisfies PluginListItem;

const pluginUninstallItems = [
{ label: "Submit to marketplace", icon: "Github" as const, onSelect: noop },
{
label: "Uninstall",
icon: "Trash2" as const,
tone: "destructive" as const,
onSelect: noop,
},
];

const pluginCatalogItems = [
{
label: "Uninstall",
icon: "Trash2" as const,
Expand All @@ -1112,7 +1122,7 @@ const pluginUninstallItems = [
const pluginLocalItems = [
{ label: "Edit", icon: "Edit" as const, onSelect: noop },
{ label: "Open source", icon: "ExternalLink" as const, onSelect: noop },
{ kind: "separator" as const },
{ label: "Submit to marketplace", icon: "Github" as const, onSelect: noop },
{
label: "Remove from bb",
icon: "Trash2" as const,
Expand Down Expand Up @@ -1315,14 +1325,24 @@ export function ResourceControlStates() {
meaning="A plugin mutation is in flight, so the lifecycle switch cannot race it."
/>
<ControlRow
state="Installed actions"
state="Direct installed actions"
control={
<ResourceOverflowMenu
label="Direct plugin actions"
items={pluginUninstallItems}
/>
}
meaning="Direct and catalog installs can be uninstalled from the ownership menu."
meaning="Direct installs can be submitted to the marketplace or uninstalled."
/>
<ControlRow
state="Catalog installed actions"
control={
<ResourceOverflowMenu
label="Catalog plugin actions"
items={pluginCatalogItems}
/>
}
meaning="Official catalog installs are already published, so their ownership menu only offers uninstall."
/>
<ControlRow
state="Local actions"
Expand All @@ -1332,7 +1352,7 @@ export function ResourceControlStates() {
items={pluginLocalItems}
/>
}
meaning="Local sources can be edited, opened, or removed from bb without deleting the source directory."
meaning="Local sources can be edited, opened, submitted to the marketplace, or removed from bb without deleting the source directory."
/>
<ControlRow
state="BB Official built-in actions"
Expand Down
19 changes: 19 additions & 0 deletions apps/app/src/components/tools/PluginDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
ResourceOverflowMenu,
type ResourceOverflowMenuItem,
} from "@bb/shared-ui/resource-list";
import { PLUGIN_SUBMISSION_FORM_URL } from "@bb/domain";
import { Switch } from "@bb/shared-ui/switch";
import {
Tooltip,
Expand Down Expand Up @@ -42,7 +43,10 @@ import {
} from "@/components/tools/plugin-detail-table";
import { PluginBannerBar } from "@/components/tools/plugin-detail-banner";
import { ProvenancePill } from "@/components/tools/ProvenancePill";
import { isOfficialProvenance } from "@/components/plugin/plugin-provenance";

import { appToast } from "@/components/ui/app-toast";
import { openUrlInExternalBrowser } from "@/lib/url-open-routing";
import {
usePluginSource,
type PluginCatalogSearchEntry,
Expand Down Expand Up @@ -344,6 +348,21 @@ export function PluginDetail({
},
]
: []),
// An ownership action like Edit: you submit your own plugin, so it only
// renders on user-provenance plugins — official ones are already in the
// marketplace. The intake form is the whole submission UI for now, and it
// opens in the external browser like every other Tools-route link: the
// in-app browser is a thread-panel surface, so UrlOpenRoutingProvider is
// never mounted here and the preference cannot apply.
...(isOfficialProvenance(plugin.provenance)
? []
: [
{
label: "Submit to marketplace",
icon: "Github" as const,
onSelect: () => openUrlInExternalBrowser(PLUGIN_SUBMISSION_FORM_URL),
},
]),
{
label: pluginRemovalLabel(plugin),
icon: "Trash2" as const,
Expand Down
61 changes: 61 additions & 0 deletions apps/app/src/views/ToolsView.plugin-detail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,67 @@ describe("PluginDetail official catalog lifecycle", () => {
expect(container.textContent).toBe("");
});

it("offers Submit to marketplace only on user-provenance plugins", async () => {
// Submission is an ownership action: you submit your own plugin, and
// official (builtin/catalog) plugins are already in the marketplace.
const harness = createQueryClientTestHarness();
const directPlugin: PluginListItem = {
...GITHUB_PLUGIN,
source: "path:/Users/you/Code/github-plugin",
provenance: "direct",
catalogEntryId: null,
};
const detail = (plugin: PluginListItem) => (
<MemoryRouter>
<harness.wrapper>
<PluginDetail
isLoading={false}
plugin={plugin}
pending={false}
openSourceDisabled
onToggle={() => {}}
onEdit={() => {}}
onOpenSource={() => {}}
onDelete={() => {}}
/>
</harness.wrapper>
</MemoryRouter>
);

// The in-app browser is a thread-panel surface, so even with the in-app
// link preference ON, this Tools-route action must open externally.
window.localStorage.setItem("bb.openLinksInAppBrowser", "true");
const openSpy = vi
.spyOn(window, "open")
.mockImplementation(() => null);

render(detail(directPlugin));
fireEvent.pointerDown(
screen.getByRole("button", { name: "GitHub actions" }),
);
fireEvent.click(
await screen.findByRole("menuitem", { name: "Submit to marketplace" }),
);
expect(openSpy).toHaveBeenCalledWith(
"https://docs.google.com/forms/d/e/1FAIpQLScRTABhHwCjuZWYn0lJJd0aZT2cYvGk2KaZ2GF-1GsXoLMLSQ/viewform",
"_blank",
"noopener,noreferrer",
);
window.localStorage.removeItem("bb.openLinksInAppBrowser");
cleanup();

render(detail(GITHUB_PLUGIN));
fireEvent.pointerDown(
screen.getByRole("button", { name: "GitHub actions" }),
);
expect(
await screen.findByRole("menuitem", { name: "Uninstall" }),
).toBeTruthy();
expect(
screen.queryByRole("menuitem", { name: "Submit to marketplace" }),
).toBeNull();
});

it("keeps catalog provenance and release management in the unified detail taxonomy", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, { clipboard: { writeText } });
Expand Down
4 changes: 3 additions & 1 deletion apps/cli/src/__tests__/command-output/plugin-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,9 @@ describe("bb plugin catalog", () => {
it("no longer advertises the remote catalog command group", async () => {
const pluginHelp = await getHelpOutput(["plugin"], register);
expect(pluginHelp).not.toContain("catalog");
expect(pluginHelp).not.toContain("marketplace");
// No `marketplace` command may come back; the word itself is fine —
// `submit`'s description legitimately points at BB's marketplace intake.
expect(pluginHelp).not.toMatch(/^\s+marketplace/mu);
expect(pluginHelp).toContain("search");

const installHelp = await getHelpOutput(["plugin", "install"], register);
Expand Down
22 changes: 22 additions & 0 deletions apps/cli/src/commands/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
PluginUpdateCheckEntry as PluginUpdateResult,
} from "@bb/server-contract";
import { installedPluginSchema } from "@bb/server-contract";
import { PLUGIN_SUBMISSION_FORM_URL } from "@bb/domain";
import { BbHttpError } from "@bb/sdk";
import { parseDataDirEnvValue, resolveProdDataDir } from "@bb/config/runtime";
import { scaffoldPlugin, syncPluginTypes } from "@bb/templates/plugin-scaffold";
Expand Down Expand Up @@ -594,6 +595,27 @@ export function registerPluginCommands(
}),
);

plugin
.command("submit")
.description(
"Print the intake form link for submitting a plugin to BB's marketplace",
)
.option("--json", "Output JSON")
.action(
action(async (opts: JsonOutputOptions) => {
// The form is the entire submission UI for now — this links out
// rather than relaying, so submission itself happens in the browser.
if (opts.json) {
outputJson(opts, { url: PLUGIN_SUBMISSION_FORM_URL });
return;
}
console.log(
"Submit your plugin to BB's marketplace (public GitHub repo required):",
);
console.log(PLUGIN_SUBMISSION_FORM_URL);
}),
);

plugin
.command("list")
.description("List installed plugins and their status")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,10 @@ them by mixing ink into canvas), the `--primary` accent, the secondary text tier
- `bb plugin search <query> [--json]` — search the official plugins by id,
name, description, or category; status shows installed / compatible /
requires newer bb.
- `bb plugin submit [--json]` — print the link to BB's plugin marketplace intake
form (a public GitHub repo is required; submission happens in the browser,
and there is no status to poll afterwards). Give the link to the user —
the form asks for details only its author knows, including their email.
- Commands:
- `bb plugin install <src>` — official plugin name (github, docs, memory,
tasks), HTTP(S) Git repository URL, local path, `builtin:<name>`,
Expand Down
1 change: 1 addition & 0 deletions packages/domain/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export * from "./pending-interactions.js";
export * from "./plugin-id.js";
export * from "./plugin-manifest.js";
export * from "./plugin-sdk-version.js";
export * from "./plugin-submission.js";
export * from "./project-path.js";
export * from "./project.js";
export * from "./prompt-history.js";
Expand Down
13 changes: 13 additions & 0 deletions packages/domain/src/plugin-submission.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* BB's plugin marketplace intake form (GitHub URL, name, description, why it's
* useful, email). The form is the entire submission UI for now — the app's
* detail-page action and `bb plugin submit` both link out to it, so there is
* no schema, route, or field validation on the bb side to keep in sync with
* its questions.
*
* Lives in @bb/domain because the app and the CLI both need it and it is a
* product fact, not a wire contract. A module constant rather than a setting:
* this is BB's own form, not anything a user configures.
*/
export const PLUGIN_SUBMISSION_FORM_URL =
"https://docs.google.com/forms/d/e/1FAIpQLScRTABhHwCjuZWYn0lJJd0aZT2cYvGk2KaZ2GF-1GsXoLMLSQ/viewform";
6 changes: 6 additions & 0 deletions packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 15 additions & 1 deletion packages/sdk/src/areas/plugins.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { jsonValueSchema, type JsonValue } from "@bb/domain";
import {
jsonValueSchema,
PLUGIN_SUBMISSION_FORM_URL,
type JsonValue,
} from "@bb/domain";
import {
pluginCatalogInstallRequestSchema,
pluginCatalogSearchResponseSchema,
Expand Down Expand Up @@ -110,10 +114,17 @@ export type PluginApplyUpdateResult = PluginApplyUpdateContract;
export type PluginCatalogStatusResult = PluginCatalogStatusContract;
export type PluginCatalogSearchResult = PluginCatalogSearchContract[];

export interface PluginCatalogSubmissionResult {
/** BB's canonical browser form for proposing a plugin to the marketplace. */
url: string;
}

export interface PluginCatalogArea {
install(args: PluginCatalogInstallArgs): Promise<PluginInstallResult>;
search(args: PluginCatalogSearchArgs): Promise<PluginCatalogSearchResult>;
status(args?: PluginCatalogStatusArgs): Promise<PluginCatalogStatusResult>;
/** Return the canonical marketplace submission form; submitting stays browser-owned. */
submission(): PluginCatalogSubmissionResult;
}

export interface PluginsArea {
Expand Down Expand Up @@ -196,6 +207,9 @@ export function createPluginsArea(args: CreateSdkAreaArgs): PluginsArea {
);
return response.catalog;
},
submission() {
return { url: PLUGIN_SUBMISSION_FORM_URL };
},
};

return {
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/test/public-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ type ExpectedPluginsKey =
| "token"
| "updateSettings";

type ExpectedPluginCatalogKey = "install" | "search" | "status";
type ExpectedPluginCatalogKey = "install" | "search" | "status" | "submission";

type ExpectedProjectsKey =
| "attachments"
Expand Down
3 changes: 3 additions & 0 deletions packages/sdk/test/sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1405,6 +1405,9 @@ describe("@bb/sdk", () => {
).resolves.toMatchObject([
{ entryId: "notes", pluginId: "notes", compatible: true },
]);
expect(sdk.plugins.catalog.submission()).toEqual({
url: "https://docs.google.com/forms/d/e/1FAIpQLScRTABhHwCjuZWYn0lJJd0aZT2cYvGk2KaZ2GF-1GsXoLMLSQ/viewform",
});

expect(queue.requests).toEqual([
{
Expand Down

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/templates/src/generated/templates.generated.ts

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions packages/templates/src/templates/bb-guide-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ added/updated/unchanged counts.

bb plugin search <query> Search BB's official plugins (bundled with
the app)
bb plugin submit Print the intake form link for submitting a
plugin to BB's marketplace
bb plugin install <entry> Install a bundled official plugin by name
(github, docs, memory, tasks), a Git repository
URL, local path, builtin:<name>,
Expand Down Expand Up @@ -234,6 +236,10 @@ category across the bundled official plugins (status: installed / compatible
/ requires newer bb). Install an official plugin by its bare name. Direct
HTTP(S) Git repository URLs, `path:`, `npm:`, `git:`, and `builtin:`
sources—and path-like syntax—continue to bypass official-plugin resolution.
SDK clients can retrieve the same canonical browser form without a server
request through `sdk.plugins.catalog.submission()`. It returns `{ url }`;
submission itself remains browser-owned because the form asks the author for
their repository, description, rationale, and email.

Builds are automatic once installed. Git installs run `npm install`
(lifecycle scripts disabled), then compile both bundles — so a git plugin may
Expand Down