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
92 changes: 92 additions & 0 deletions apps/app/src/components/settings/CliSkillsSettingsSection.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// @vitest-environment jsdom

import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import {
CliSkillsSettingsSectionContent,
summarizeMachineStatuses,
} from "./CliSkillsSettingsSection";

afterEach(() => {
cleanup();
});

function installButton(): HTMLButtonElement {
const button = screen.getByRole("button", { name: "Install bb CLI skills" });
if (!(button instanceof HTMLButtonElement)) {
throw new Error("Install control is not a button");
}
return button;
}

describe("CliSkillsSettingsSectionContent", () => {
it("offers the picker when a machine is connected", () => {
render(
<CliSkillsSettingsSectionContent
hasConnectedMachine={true}
onOpenPicker={() => undefined}
pending={false}
statusBadge={null}
/>,
);

expect(installButton().disabled).toBe(false);
});

it("explains why the install is unavailable with no connected machine", () => {
render(
<CliSkillsSettingsSectionContent
hasConnectedMachine={false}
onOpenPicker={() => undefined}
pending={false}
statusBadge={null}
/>,
);

expect(installButton().disabled).toBe(true);
expect(
screen.getByText(
"Connect a machine to install them into ~/.agents/skills and ~/.claude/skills.",
),
).toBeDefined();
});

it("blocks reopening the picker while an install is running", () => {
render(
<CliSkillsSettingsSectionContent
hasConnectedMachine={true}
onOpenPicker={() => undefined}
pending={true}
statusBadge="Installed"
/>,
);

expect(installButton().disabled).toBe(true);
expect(installButton().textContent).toBe("Installing…");
});
});

describe("summarizeMachineStatuses", () => {
it("reports a single machine plainly", () => {
expect(summarizeMachineStatuses(["installed"])).toBe("Installed");
expect(summarizeMachineStatuses(["outdated"])).toBe("Out of date");
expect(summarizeMachineStatuses(["missing"])).toBe("Not installed");
});

it("counts a mixed fleet instead of claiming either extreme", () => {
expect(summarizeMachineStatuses(["installed", "outdated", "missing"])).toBe(
"Installed on 1 of 3 machines",
);
expect(summarizeMachineStatuses(["installed", "installed"])).toBe(
"Installed on 2 machines",
);
});

it("ignores machines it could not ask, and shows nothing if that is all of them", () => {
expect(summarizeMachineStatuses(["installed", "unknown"])).toBe(
"Installed",
);
expect(summarizeMachineStatuses(["unknown", "unknown"])).toBe(null);
expect(summarizeMachineStatuses([])).toBe(null);
});
});
161 changes: 161 additions & 0 deletions apps/app/src/components/settings/CliSkillsSettingsSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { useState } from "react";
import type { Host } from "@bb/domain";
import type {
CliSkillMachineStatus,
SystemCliSkillsStatusResponse,
SystemInstallCliSkillsResponse,
} from "@bb/server-contract";
import { Button } from "@bb/shared-ui/button";
import {
SettingsSection,
SettingsWithControl,
} from "@/components/ui/settings-section";
import { appToast } from "@/components/ui/app-toast";
import { InstallCliSkillsDialog } from "@/components/settings/InstallCliSkillsDialog";
import { useInstallCliSkills } from "@/hooks/mutations/settings-mutations";
import { useHosts } from "@/hooks/queries/host-queries";
import { useCliSkillsStatus } from "@/hooks/queries/system-queries";

const CLI_SKILLS_SETTING_LABEL = "bb CLI skills";

export interface CliSkillsSettingsSectionContentProps {
/** False while no machine is connected, so nothing could receive the files. */
hasConnectedMachine: boolean;
onOpenPicker: () => void;
pending: boolean;
/** Summary badge for the row; null while unknown or still loading. */
statusBadge: string | null;
}

function installDescription(hasConnectedMachine: boolean): string {
return hasConnectedMachine
? "Install them into ~/.agents/skills and ~/.claude/skills so agents outside bb can use the bb CLI."
: "Connect a machine to install them into ~/.agents/skills and ~/.claude/skills.";
}

/**
* One badge for the whole row. With several machines the interesting fact is
* how many are current, so a mixed fleet reports the shortfall rather than
* claiming either extreme.
*/
export function summarizeMachineStatuses(
statuses: readonly CliSkillMachineStatus[],
): string | null {
const known = statuses.filter((status) => status !== "unknown");
if (known.length === 0) return null;
const installed = known.filter((status) => status === "installed").length;
if (installed === known.length) {
return known.length > 1
? `Installed on ${known.length} machines`
: "Installed";
}
if (installed > 0) {
return `Installed on ${installed} of ${known.length} machines`;
}
return known.some((status) => status === "outdated")
? "Out of date"
: "Not installed";
}

export function CliSkillsSettingsSectionContent({
hasConnectedMachine,
onOpenPicker,
pending,
statusBadge,
}: CliSkillsSettingsSectionContentProps) {
return (
<SettingsSection title="Skills">
<SettingsWithControl
label={CLI_SKILLS_SETTING_LABEL}
{...(statusBadge === null ? {} : { labelBadge: statusBadge })}
description={installDescription(hasConnectedMachine)}
>
<Button
type="button"
variant="outline"
size="sm"
disabled={!hasConnectedMachine || pending}
onClick={onOpenPicker}
aria-label={`Install ${CLI_SKILLS_SETTING_LABEL}`}
>
{pending ? "Installing…" : "Install"}
</Button>
</SettingsWithControl>
</SettingsSection>
);
}

/**
* Report the per-machine outcome. The route installs machines independently,
* so a partial success is a real outcome and both halves get surfaced.
*/
export function reportInstallResults(
result: SystemInstallCliSkillsResponse,
): void {
const installed = result.results.filter((entry) => entry.ok);
const failed = result.results.filter((entry) => !entry.ok);
if (installed.length > 0) {
appToast.success(
`Installed the bb CLI skills on ${installed
.map((entry) => entry.hostName)
.join(", ")}`,
);
}
for (const entry of failed) {
appToast.error(`${entry.hostName}: ${entry.errorMessage}`);
}
}

function statusByHostId(
status: SystemCliSkillsStatusResponse | undefined,
): ReadonlyMap<string, CliSkillMachineStatus> {
return new Map(
(status?.machines ?? []).map((machine) => [machine.hostId, machine.status]),
);
}

/**
* Publish bb's built-in CLI skills to chosen machines' global agent skill roots
* so agents running outside bb can drive it. Gated on a connected machine, not
* on whether this browser can reach a daemon itself (it cannot when bb is open
* remotely) — the install runs server-side over each machine's daemon session.
*/
export function CliSkillsSettingsSection() {
const hostsQuery = useHosts();
const statusQuery = useCliSkillsStatus();
const installCliSkills = useInstallCliSkills();
const [pickerOpen, setPickerOpen] = useState(false);
const hosts: readonly Host[] = hostsQuery.data ?? [];
const statuses = statusByHostId(statusQuery.data);

return (
<>
<CliSkillsSettingsSectionContent
hasConnectedMachine={hosts.some((host) => host.status === "connected")}
pending={installCliSkills.isPending}
statusBadge={summarizeMachineStatuses([...statuses.values()])}
onOpenPicker={() => setPickerOpen(true)}
/>
<InstallCliSkillsDialog
open={pickerOpen}
onOpenChange={setPickerOpen}
hosts={hosts}
statusByHostId={statuses}
pending={installCliSkills.isPending}
onCancel={() => setPickerOpen(false)}
onInstall={(hostIds) =>
installCliSkills.mutate(
{ hostIds },
{
onSuccess: (result) => {
setPickerOpen(false);
reportInstallResults(result);
void statusQuery.refetch();
},
},
)
}
/>
</>
);
}
132 changes: 132 additions & 0 deletions apps/app/src/components/settings/InstallCliSkillsDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// @vitest-environment jsdom

import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { Host } from "@bb/domain";
import type { CliSkillMachineStatus } from "@bb/server-contract";
import { afterEach, describe, expect, it, vi } from "vitest";
import { InstallCliSkillsDialog } from "./InstallCliSkillsDialog";

afterEach(() => {
cleanup();
});

function host(overrides: Partial<Host> & Pick<Host, "id" | "name">): Host {
return {
type: "persistent",
status: "connected",
lastSeenAt: 1,
lastRejectedProtocolVersion: null,
createdAt: 1,
updatedAt: 1,
...overrides,
};
}

const hosts: Host[] = [
host({ id: "host-laptop", name: "Laptop" }),
host({ id: "host-studio", name: "Studio" }),
host({ id: "host-old", name: "Old Box", status: "disconnected" }),
];

const statuses = new Map<string, CliSkillMachineStatus>([
["host-laptop", "installed"],
["host-studio", "outdated"],
]);

function checkbox(name: string): HTMLInputElement {
const control = screen.getByRole("checkbox", { name });
if (!(control instanceof HTMLElement)) {
throw new Error(`Checkbox ${name} is missing`);
}
return control as HTMLInputElement;
}

describe("InstallCliSkillsDialog", () => {
it("preselects the connected machines and installs exactly those", () => {
const onInstall = vi.fn();
render(
<InstallCliSkillsDialog
open={true}
onOpenChange={() => undefined}
hosts={hosts}
statusByHostId={statuses}
onCancel={() => undefined}
onInstall={onInstall}
pending={false}
/>,
);

fireEvent.click(screen.getByRole("button", { name: "Install" }));

expect(onInstall).toHaveBeenCalledWith(["host-laptop", "host-studio"]);
});

it("installs only the machines left selected", () => {
const onInstall = vi.fn();
render(
<InstallCliSkillsDialog
open={true}
onOpenChange={() => undefined}
hosts={hosts}
statusByHostId={statuses}
onCancel={() => undefined}
onInstall={onInstall}
pending={false}
/>,
);

fireEvent.click(checkbox("Studio"));
fireEvent.click(screen.getByRole("button", { name: "Install" }));

expect(onInstall).toHaveBeenCalledWith(["host-laptop"]);
});

it("cannot select a disconnected machine or install with nothing selected", () => {
const onInstall = vi.fn();
render(
<InstallCliSkillsDialog
open={true}
onOpenChange={() => undefined}
hosts={hosts}
statusByHostId={statuses}
onCancel={() => undefined}
onInstall={onInstall}
pending={false}
/>,
);

expect(checkbox("Old Box").hasAttribute("disabled")).toBe(true);
fireEvent.click(checkbox("Laptop"));
fireEvent.click(checkbox("Studio"));

const install = screen.getByRole("button", { name: "Install" });
expect(install.hasAttribute("disabled")).toBe(true);
fireEvent.click(install);
expect(onInstall).not.toHaveBeenCalled();
});

it("drops the machine list entirely when there is nothing to choose", () => {
const onInstall = vi.fn();
render(
<InstallCliSkillsDialog
open={true}
onOpenChange={() => undefined}
hosts={[host({ id: "host-laptop", name: "Laptop" })]}
statusByHostId={statuses}
onCancel={() => undefined}
onInstall={onInstall}
pending={false}
/>,
);

expect(screen.queryAllByRole("checkbox")).toEqual([]);
expect(
screen.getByText(
"The skills go in ~/.agents/skills and ~/.claude/skills on Laptop, replacing any copy already there.",
),
).toBeDefined();

fireEvent.click(screen.getByRole("button", { name: "Install" }));
expect(onInstall).toHaveBeenCalledWith(["host-laptop"]);
});
});
Loading
Loading