mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-06 14:33:11 +08:00
chore: bump @openhands/extensions to 0.18.0 (#16754)
This commit is contained in:
@@ -65,8 +65,10 @@ describe("RecommendedAutomationsRail", () => {
|
||||
);
|
||||
|
||||
expect(cardIds).toEqual([
|
||||
"github-repo-monitor",
|
||||
"github-issue-to-pr",
|
||||
"slack-channel-monitor",
|
||||
"github-agents-md-maintainer",
|
||||
"news-digest",
|
||||
"slack-standup-digest",
|
||||
"linear-triage-assistant",
|
||||
"jira-issue-to-pr",
|
||||
@@ -115,8 +117,10 @@ describe("RecommendedAutomationsRail", () => {
|
||||
<RecommendedAutomationsRail
|
||||
installedAutomations={[
|
||||
{ name: "GitHub Code Review Agent" },
|
||||
{ name: "GitHub repository monitor" },
|
||||
{ name: "GitHub Issue to PR Agent" },
|
||||
{ name: "Slack channel monitor" },
|
||||
{ name: "AGENTS.md Maintainer" },
|
||||
{ name: "Daily news digest" },
|
||||
{ name: "Slack standup digest" },
|
||||
{ name: "Linear issue triage assistant" },
|
||||
{ name: "Jira issue to GitHub PR" },
|
||||
|
||||
@@ -206,8 +206,11 @@ describe("recommended automations", () => {
|
||||
|
||||
expect(cardIds).toEqual([
|
||||
"github-pr-reviewer",
|
||||
"github-repo-monitor",
|
||||
"github-issue-to-pr",
|
||||
"slack-channel-monitor",
|
||||
"github-agents-md-maintainer",
|
||||
"news-digest",
|
||||
"github-repo-monitor",
|
||||
"slack-standup-digest",
|
||||
"linear-triage-assistant",
|
||||
"jira-issue-to-pr",
|
||||
@@ -229,7 +232,7 @@ describe("recommended automations", () => {
|
||||
const provenHeading = screen.getByText(
|
||||
I18nKey.RECOMMENDED_AUTOMATIONS$SECTION_TITLE,
|
||||
).parentElement!;
|
||||
expect(within(provenHeading).getByText("3")).toBeInTheDocument();
|
||||
expect(within(provenHeading).getByText("5")).toBeInTheDocument();
|
||||
|
||||
const betaHeading = screen.getByTestId(
|
||||
"recommended-automations-beta-heading",
|
||||
@@ -237,7 +240,7 @@ describe("recommended automations", () => {
|
||||
expect(betaHeading).toHaveTextContent(
|
||||
I18nKey.RECOMMENDED_AUTOMATIONS$BETA_LABEL,
|
||||
);
|
||||
expect(within(betaHeading).getByText("6")).toBeInTheDocument();
|
||||
expect(within(betaHeading).getByText("7")).toBeInTheDocument();
|
||||
|
||||
const betaSection = screen.getByTestId(
|
||||
"recommended-automations-beta-section",
|
||||
@@ -316,6 +319,24 @@ describe("recommended automations", () => {
|
||||
).toHaveAttribute("data-layout", "quadrants");
|
||||
});
|
||||
|
||||
it("shows the declared glyph instead of a logo stack when an entry names one", () => {
|
||||
render(
|
||||
<RecommendedAutomationsSection
|
||||
backendKind="local"
|
||||
installedServers={[]}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// `news-digest` connects to nothing, so there are no logos to stack; it
|
||||
// names its own glyph, and the badge must render that rather than the
|
||||
// generic placeholder a bare empty stack would give.
|
||||
const badge = screen.getByTestId("recommended-automation-icon-news-digest");
|
||||
expect(badge).not.toHaveAttribute("data-layout");
|
||||
expect(badge.querySelector("svg")).toBeInTheDocument();
|
||||
expect(badge.querySelector("img")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders missing MCP connect copy as a pill on the same row", () => {
|
||||
const offsetWidthDescriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
|
||||
@@ -72,9 +72,17 @@ describe("OpenHands extensions catalogs", () => {
|
||||
const knownMcpIds = new Set(INTEGRATION_CATALOG.map((entry) => entry.id));
|
||||
for (const automation of AUTOMATION_CATALOG) {
|
||||
const integrationIds = getIntegrationIds(automation);
|
||||
expect(integrationIds.length).toBeGreaterThan(0);
|
||||
expect(integrationIds.every((id) => knownMcpIds.has(id))).toBe(true);
|
||||
}
|
||||
|
||||
// Declaring none is legitimate — `news-digest` connects to nothing — so the
|
||||
// resolution above is only worth asserting while some entry still declares
|
||||
// one. Without this the loop above would pass over an empty catalog.
|
||||
expect(
|
||||
AUTOMATION_CATALOG.some(
|
||||
(automation) => getIntegrationIds(automation).length > 0,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("admits every setup experience the automation catalog ships", () => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AUTOMATION_CATALOG } from "@openhands/extensions/automations";
|
||||
import { getAutomationLaunchPrompt } from "#/utils/automation-catalog";
|
||||
import { Activity, Bot } from "lucide-react";
|
||||
import {
|
||||
getAutomationIcon,
|
||||
getAutomationLaunchPrompt,
|
||||
} from "#/utils/automation-catalog";
|
||||
|
||||
const automationById = (id: string) =>
|
||||
AUTOMATION_CATALOG.find((automation) => automation.id === id)!;
|
||||
@@ -27,3 +31,30 @@ describe("getAutomationLaunchPrompt", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAutomationIcon", () => {
|
||||
it("resolves the glyph an entry declares", () => {
|
||||
// Arrange / Act / Assert — the two entries that name one, each a
|
||||
// different slug, so the lookup is not passing by returning a constant.
|
||||
expect(getAutomationIcon(automationById("news-digest"))).toBe(Activity);
|
||||
expect(
|
||||
getAutomationIcon(automationById("github-agents-md-maintainer")),
|
||||
).toBe(Bot);
|
||||
});
|
||||
|
||||
it("returns null for an entry that declares none", () => {
|
||||
// Its integrations' logos identify it instead.
|
||||
expect(getAutomationIcon(automationById("github-pr-reviewer"))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a slug this host has no artwork for", () => {
|
||||
// The catalog is published elsewhere, so an unknown slug must fall back to
|
||||
// the logos rather than render nothing.
|
||||
expect(
|
||||
getAutomationIcon({
|
||||
...automationById("news-digest"),
|
||||
icon: "not-a-real-slug",
|
||||
} as unknown as Parameters<typeof getAutomationIcon>[0]),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,8 +49,10 @@ describe("recommended automation rail", () => {
|
||||
]);
|
||||
|
||||
expect(groups.proven.map((entry) => entry.id)).toEqual([
|
||||
"github-repo-monitor",
|
||||
"github-issue-to-pr",
|
||||
"slack-channel-monitor",
|
||||
"github-agents-md-maintainer",
|
||||
"news-digest",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -60,8 +62,10 @@ describe("recommended automation rail", () => {
|
||||
|
||||
expect(groups.proven.map((entry) => entry.id)).toEqual([
|
||||
"github-pr-reviewer",
|
||||
"github-repo-monitor",
|
||||
"github-issue-to-pr",
|
||||
"slack-channel-monitor",
|
||||
"github-agents-md-maintainer",
|
||||
"news-digest",
|
||||
]);
|
||||
expect(conversationIds).toEqual([
|
||||
"slack-standup-digest",
|
||||
@@ -71,6 +75,9 @@ describe("recommended automation rail", () => {
|
||||
]);
|
||||
expect(conversationIds).not.toContain("upstream-fork-sync");
|
||||
expect(conversationIds).not.toContain("incident-retrospective-drafter");
|
||||
// Beta as of extensions 0.18.0, and set up by a host form rather than a
|
||||
// conversation, so it is in neither group.
|
||||
expect(conversationIds).not.toContain("github-repo-monitor");
|
||||
expect(isConversationLaunchAutomation(slackStandup)).toBe(true);
|
||||
expect(isConversationLaunchAutomation(upstreamFork)).toBe(false);
|
||||
expect(SETUP_REGISTRY.findById(upstreamFork.id)).not.toBeNull();
|
||||
@@ -79,8 +86,10 @@ describe("recommended automation rail", () => {
|
||||
it("returns an empty rail when every recommended automation has been added", () => {
|
||||
const groups = getRecommendedRailGroups([
|
||||
{ name: "GitHub Code Review Agent" },
|
||||
{ name: "GitHub repository monitor" },
|
||||
{ name: "GitHub Issue to PR Agent" },
|
||||
{ name: "Slack channel monitor" },
|
||||
{ name: "AGENTS.md Maintainer" },
|
||||
{ name: "Daily news digest" },
|
||||
{ name: "Slack standup digest" },
|
||||
{ name: "Linear issue triage assistant" },
|
||||
{ name: "Jira issue to GitHub PR" },
|
||||
|
||||
Generated
+4
-4
@@ -13,7 +13,7 @@
|
||||
"@heroui/react": "2.8.10",
|
||||
"@microlink/react-json-view": "1.31.25",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@openhands/extensions": "0.17.0",
|
||||
"@openhands/extensions": "0.18.0",
|
||||
"@openhands/typescript-client": "1.38.0",
|
||||
"@react-router/node": "7.18.2",
|
||||
"@react-router/serve": "7.18.2",
|
||||
@@ -4164,9 +4164,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@openhands/extensions": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@openhands/extensions/-/extensions-0.17.0.tgz",
|
||||
"integrity": "sha512-y3+OSHsMWN1sZVT1NERVdCnZVBgHg3F5E4tlYea6bb/nLzX750SuvJpfUJNitJqxN7ls+jMYVgBOgee6KoeLqA==",
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@openhands/extensions/-/extensions-0.18.0.tgz",
|
||||
"integrity": "sha512-0cbVueh8wk5OLq+kbEgI5k1sFUaNgahmxXwvOR5+4fpxwc2YnVivdyDzxjZ+SiagUmYi/EgADFVa/5X38e9BzQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.20.0"
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@
|
||||
"@heroui/react": "2.8.10",
|
||||
"@microlink/react-json-view": "1.31.25",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@openhands/extensions": "0.17.0",
|
||||
"@openhands/extensions": "0.18.0",
|
||||
"@openhands/typescript-client": "1.38.0",
|
||||
"@react-router/node": "7.18.2",
|
||||
"@react-router/serve": "7.18.2",
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||
import {
|
||||
createElement,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { RecommendedAutomation } from "@openhands/extensions/automations";
|
||||
import {
|
||||
@@ -8,7 +14,10 @@ import {
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { McpLogoBadge } from "#/components/features/mcp-logo-badge";
|
||||
import { getMarketplaceEntryById } from "#/utils/mcp-marketplace-utils";
|
||||
import { getIntegrationIds } from "#/utils/automation-catalog";
|
||||
import {
|
||||
getAutomationIcon,
|
||||
getIntegrationIds,
|
||||
} from "#/utils/automation-catalog";
|
||||
import {
|
||||
flattenRecommendedRailGroups,
|
||||
getRecommendedRailGroups,
|
||||
@@ -42,23 +51,40 @@ function integrationEntries(
|
||||
}
|
||||
|
||||
function RailIntegrationIcons({
|
||||
automation,
|
||||
entries,
|
||||
testId,
|
||||
}: {
|
||||
automation: RecommendedAutomation;
|
||||
entries: MarketplaceEntry[];
|
||||
testId: string;
|
||||
}) {
|
||||
const visibleEntries = entries.slice(0, 4);
|
||||
const isOverlap = visibleEntries.length > 1;
|
||||
// A declared glyph replaces the logos rather than joining them: it is the
|
||||
// whole identity of an entry that names one.
|
||||
const Icon = getAutomationIcon(automation);
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-testid={testId}
|
||||
data-layout={isOverlap ? "overlap" : undefined}
|
||||
className={cn(RAIL_ICON_ROW_CLASS_NAME, isOverlap && "-space-x-2")}
|
||||
data-layout={isOverlap && !Icon ? "overlap" : undefined}
|
||||
className={cn(
|
||||
RAIL_ICON_ROW_CLASS_NAME,
|
||||
isOverlap && !Icon && "-space-x-2",
|
||||
)}
|
||||
>
|
||||
{visibleEntries.length === 0 ? (
|
||||
{Icon ? (
|
||||
<McpLogoBadge
|
||||
entry={null}
|
||||
size="base"
|
||||
fallback={createElement(Icon, {
|
||||
className: "h-5 w-5",
|
||||
strokeWidth: 2.25,
|
||||
})}
|
||||
/>
|
||||
) : visibleEntries.length === 0 ? (
|
||||
<McpLogoBadge entry={null} size="base" />
|
||||
) : (
|
||||
visibleEntries.map((entry) => (
|
||||
@@ -157,6 +183,7 @@ export function RecommendedAutomationsRail({
|
||||
)}
|
||||
>
|
||||
<RailIntegrationIcons
|
||||
automation={automation}
|
||||
entries={integrationEntries(automation)}
|
||||
testId={`recommended-automation-rail-icon-${automation.id}`}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
} from "#/utils/mcp-marketplace-utils";
|
||||
import { getFeaturedAutomationIds } from "#/manifests/automation-interface";
|
||||
import {
|
||||
getAutomationIcon,
|
||||
getAutomationLaunchPrompt,
|
||||
getIntegrationIds,
|
||||
} from "#/utils/automation-catalog";
|
||||
@@ -109,15 +111,6 @@ function automationMatchesQuery(
|
||||
return haystack.includes(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep any automation with declared integrations visible, including when a
|
||||
* catalog entry is missing. This makes catalog drift actionable instead of
|
||||
* silently hiding the automation.
|
||||
*/
|
||||
function isAutomationAvailable(automation: RecommendedAutomation) {
|
||||
return getIntegrationIds(automation).length > 0;
|
||||
}
|
||||
|
||||
function buildRecommendedAutomationPills(
|
||||
integrations: AutomationIntegration[],
|
||||
installedServers: MCPServerConfig[],
|
||||
@@ -177,6 +170,44 @@ function buildRecommendedAutomationPills(
|
||||
return pills;
|
||||
}
|
||||
|
||||
/**
|
||||
* A card's badge: the declared glyph when the entry names one, and its
|
||||
* integration logos otherwise. Both render into the same slot at the same
|
||||
* size, so a card is laid out the same either way.
|
||||
*/
|
||||
function AutomationCardIcon({
|
||||
automation,
|
||||
integrations,
|
||||
size,
|
||||
testId,
|
||||
}: {
|
||||
automation: RecommendedAutomation;
|
||||
integrations: AutomationIntegration[];
|
||||
size: "base" | "md";
|
||||
testId: string;
|
||||
}) {
|
||||
const Icon = getAutomationIcon(automation);
|
||||
if (Icon) {
|
||||
return (
|
||||
<McpLogoBadge
|
||||
entry={null}
|
||||
size={size}
|
||||
testId={testId}
|
||||
fallback={createElement(Icon, {
|
||||
className: "h-5 w-5",
|
||||
strokeWidth: 2.25,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<McpLogoStackBadge
|
||||
entries={integrations.flatMap(({ entry }) => (entry ? [entry] : []))}
|
||||
testId={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface AutomationCardGridProps {
|
||||
automations: RecommendedAutomation[];
|
||||
installedServers: MCPServerConfig[];
|
||||
@@ -217,10 +248,10 @@ function AutomationCardGrid({
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-start gap-3">
|
||||
<McpLogoStackBadge
|
||||
entries={integrations.flatMap(({ entry }) =>
|
||||
entry ? [entry] : [],
|
||||
)}
|
||||
<AutomationCardIcon
|
||||
automation={automation}
|
||||
integrations={integrations}
|
||||
size="md"
|
||||
testId={`recommended-automation-icon-${automation.id}`}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
||||
@@ -268,13 +299,17 @@ export function RecommendedAutomationsSection({
|
||||
}: RecommendedAutomationsSectionProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
|
||||
const visibleAutomations = RECOMMENDED_AUTOMATIONS.filter((automation) => {
|
||||
const integrationEntries = getIntegrationEntries(automation);
|
||||
return (
|
||||
isAutomationAvailable(automation) &&
|
||||
automationMatchesQuery(automation, integrationEntries, query)
|
||||
);
|
||||
});
|
||||
// Only the query narrows the grid. An automation that declares no
|
||||
// integration needs nothing connected, and one naming an integration this
|
||||
// host cannot resolve is shown with that gap on its pill, so neither is a
|
||||
// reason to hide a card the catalog ships.
|
||||
const visibleAutomations = RECOMMENDED_AUTOMATIONS.filter((automation) =>
|
||||
automationMatchesQuery(
|
||||
automation,
|
||||
getIntegrationEntries(automation),
|
||||
query,
|
||||
),
|
||||
);
|
||||
|
||||
if (visibleAutomations.length === 0) return null;
|
||||
|
||||
|
||||
@@ -218,9 +218,33 @@ export function buildCreatePayload(
|
||||
const trigger = buildTrigger(entry, values);
|
||||
if (trigger) payload.trigger = trigger;
|
||||
|
||||
const template = buildTemplate(entry, values);
|
||||
if (template) payload.template = template;
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `template` provenance a prompt entry sends, or undefined for one that
|
||||
* publishes no version.
|
||||
*
|
||||
* It is what lets the service recognise a second setup of the same entry as
|
||||
* the automation it already holds. The bundle path derives the same block from
|
||||
* the config the bundle declares; a prompt entry declares none, so the answer
|
||||
* to "what was this created from" is the form as submitted.
|
||||
*/
|
||||
function buildTemplate(
|
||||
entry: SetupEntry,
|
||||
values: SetupFormValues,
|
||||
): SetupRequestBody | undefined {
|
||||
if (!entry.version) return undefined;
|
||||
return {
|
||||
id: entry.id,
|
||||
version: entry.version,
|
||||
config: { ...values },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The `trigger` object, read off the key and fields under `form.triggers`.
|
||||
*
|
||||
|
||||
@@ -26,8 +26,11 @@ const ENTRY_ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
const FIELD_NAME_PATTERN = /^[a-z][A-Za-z0-9]*$/;
|
||||
/** Copy must never be able to inject markup into the host. */
|
||||
const MARKUP_PATTERN = /<[A-Za-z/!]/;
|
||||
/** Every `{{` must open a known namespace and close immediately. */
|
||||
const BUNDLE_VERSION_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+$/;
|
||||
/**
|
||||
* A template version, declared as `version` by a prompt entry and as
|
||||
* `setup.bundle.version` by a bundle one.
|
||||
*/
|
||||
const TEMPLATE_VERSION_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+$/;
|
||||
// The characters a command of plain words and paths is made of. No shell
|
||||
// metacharacters, which the service rejects anyway and a bundle has no reason
|
||||
// to need; where those words may point is `isPlainCommand`'s to say.
|
||||
@@ -35,6 +38,7 @@ const BUNDLE_COMMAND_PATTERN = /^[A-Za-z0-9 ._/-]+$/;
|
||||
const BUNDLE_PATH_PATTERN = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
|
||||
const BUNDLE_SOURCE_PATTERN =
|
||||
/^(skills|automations)\/[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
|
||||
/** Every `{{` must open a known namespace and close immediately. */
|
||||
const UNKNOWN_PLACEHOLDER_PATTERN = new RegExp(
|
||||
`\\{\\{(?!(?:${SETUP_PLACEHOLDER_NAMESPACES.join("|")})\\.[A-Za-z0-9_.]+\\}\\})`,
|
||||
);
|
||||
@@ -175,8 +179,11 @@ function checkRequires(check: SetupChecker, requires: unknown): void {
|
||||
}
|
||||
|
||||
const { integrations, features } = requires;
|
||||
if (!isRecord(integrations) || Object.keys(integrations).length === 0) {
|
||||
check.fail("requires.integrations", "must be a non-empty object");
|
||||
// Empty is meaningful rather than malformed: an automation that needs no
|
||||
// credential connects to nothing, and the key stays required so that saying
|
||||
// so is a deliberate statement instead of an omission.
|
||||
if (!isRecord(integrations)) {
|
||||
check.fail("requires.integrations", "must be an object");
|
||||
} else {
|
||||
Object.entries(integrations).forEach(([id, requirement]) => {
|
||||
const path = `requires.integrations.${id}`;
|
||||
@@ -400,7 +407,7 @@ function checkBundle(check: SetupChecker, bundle: unknown): void {
|
||||
|
||||
if (
|
||||
typeof bundle.version !== "string" ||
|
||||
!BUNDLE_VERSION_PATTERN.test(bundle.version)
|
||||
!TEMPLATE_VERSION_PATTERN.test(bundle.version)
|
||||
) {
|
||||
check.fail("setup.bundle.version", "must be a semantic version");
|
||||
}
|
||||
@@ -587,6 +594,13 @@ export function validateSetupEntry(candidate: unknown): SetupValidationResult {
|
||||
}
|
||||
check.copy(candidate.name, "name");
|
||||
check.copy(candidate.description, "description");
|
||||
if (
|
||||
candidate.version !== undefined &&
|
||||
(typeof candidate.version !== "string" ||
|
||||
!TEMPLATE_VERSION_PATTERN.test(candidate.version))
|
||||
) {
|
||||
check.fail("version", "must be a semantic version");
|
||||
}
|
||||
if (
|
||||
candidate.skill !== undefined &&
|
||||
(typeof candidate.skill !== "string" ||
|
||||
|
||||
@@ -152,6 +152,13 @@ export interface SetupEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
/**
|
||||
* Version of the template this entry publishes, sent with the create request
|
||||
* as provenance. A bundle declares its own at `setup.bundle.version`; a
|
||||
* prompt entry declares it here. Absent means the entry sends none, so the
|
||||
* service records no template for what it creates.
|
||||
*/
|
||||
version?: string;
|
||||
requires: SetupPrerequisites;
|
||||
/** The skill that owns the launch command. Defaults to `id`. */
|
||||
skill?: string;
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
SKILLS_CATALOG,
|
||||
type SkillCatalogEntry,
|
||||
} from "@openhands/extensions/skills";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { MANIFEST_ICON_BY_SLUG } from "#/components/features/manifest/manifest-icons";
|
||||
|
||||
/**
|
||||
* Reading a published automation catalog entry.
|
||||
@@ -18,6 +20,27 @@ const SKILL_BY_NAME = new Map<string, SkillCatalogEntry>(
|
||||
SKILLS_CATALOG.map((skill) => [skill.name, skill]),
|
||||
);
|
||||
|
||||
/**
|
||||
* The glyph a card shows in place of its integration logos, or null when it
|
||||
* has none to show and the logos speak for it.
|
||||
*
|
||||
* An entry naming the service it talks to is recognised by that service's
|
||||
* logo; one whose identity is the work itself declares a slug instead. The
|
||||
* lookup is a guard as much as a mapping — the catalog is published elsewhere,
|
||||
* so a slug this host has no artwork for falls back rather than rendering
|
||||
* nothing.
|
||||
*/
|
||||
export function getAutomationIcon(
|
||||
automation: RecommendedAutomation,
|
||||
): LucideIcon | null {
|
||||
const slug = automation.icon;
|
||||
if (!slug) return null;
|
||||
return (
|
||||
(MANIFEST_ICON_BY_SLUG as Record<string, LucideIcon | undefined>)[slug] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/** Every integration the entry declares, in declaration order. */
|
||||
export function getIntegrationIds(automation: RecommendedAutomation): string[] {
|
||||
return Object.keys(automation.requires.integrations);
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
import { getFeaturedAutomationIds } from "#/manifests/automation-interface";
|
||||
import { SETUP_REGISTRY } from "#/manifests/manifest-sources";
|
||||
import type { Automation } from "#/types/automation";
|
||||
import { getIntegrationIds } from "#/utils/automation-catalog";
|
||||
|
||||
export function getAutomationsByPopularity(
|
||||
catalog: RecommendedAutomation[],
|
||||
@@ -49,10 +48,6 @@ export function isCatalogAutomationAdded(
|
||||
return catalogMatchKeys(entry).some((key) => installedKeys.has(key));
|
||||
}
|
||||
|
||||
function isAvailableCatalogEntry(entry: RecommendedAutomation): boolean {
|
||||
return getIntegrationIds(entry).length > 0;
|
||||
}
|
||||
|
||||
function isProvenAutomation(entry: RecommendedAutomation): boolean {
|
||||
return getFeaturedAutomationIds().includes(entry.id);
|
||||
}
|
||||
@@ -77,10 +72,11 @@ export interface RecommendedRailGroups {
|
||||
export function getRecommendedRailGroups(
|
||||
installed: readonly Pick<Automation, "name">[],
|
||||
): RecommendedRailGroups {
|
||||
// Every catalog entry is offerable: one that declares no integration needs
|
||||
// nothing connected, and one that names an integration this host cannot
|
||||
// resolve stays visible so the drift is actionable rather than hidden.
|
||||
const available = getAutomationsByPopularity(AUTOMATION_CATALOG).filter(
|
||||
(entry) =>
|
||||
isAvailableCatalogEntry(entry) &&
|
||||
!isCatalogAutomationAdded(entry, installed),
|
||||
(entry) => !isCatalogAutomationAdded(entry, installed),
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user