Skills (#50)
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
@@ -211,7 +211,7 @@ export const AgentSkillsSetupModal = ({
|
||||
disabled={anySkillBusy}
|
||||
onClick={() => {
|
||||
const approved = window.confirm(
|
||||
`Remove ${skill.name} from the gateway? This affects all agents.`
|
||||
`Remove ${skill.name} from the gateway for all agents?`
|
||||
);
|
||||
if (!approved) {
|
||||
return;
|
||||
@@ -224,7 +224,7 @@ export const AgentSkillsSetupModal = ({
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Remove skill from gateway
|
||||
Remove for all agents
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -932,6 +932,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
||||
label: `Remove ${normalizedSkillKey}`,
|
||||
run: async () => {
|
||||
const result = await removeSkillFromGateway({
|
||||
client: params.client,
|
||||
skillKey: normalizedSkillKey,
|
||||
source: normalizedSource,
|
||||
baseDir: skill.baseDir,
|
||||
|
||||
@@ -6,7 +6,6 @@ export type HQSidebarTab =
|
||||
| "inbox"
|
||||
| "history"
|
||||
| "playbooks"
|
||||
| "marketplace"
|
||||
| "analytics";
|
||||
|
||||
type HQSidebarProps = {
|
||||
@@ -15,10 +14,10 @@ type HQSidebarProps = {
|
||||
inboxCount: number;
|
||||
onToggle: () => void;
|
||||
onTabChange: (tab: HQSidebarTab) => void;
|
||||
onOpenMarketplace: () => void;
|
||||
inboxPanel: ReactNode;
|
||||
historyPanel: ReactNode;
|
||||
playbooksPanel: ReactNode;
|
||||
marketplacePanel: ReactNode;
|
||||
analyticsPanel: ReactNode;
|
||||
};
|
||||
|
||||
@@ -26,7 +25,6 @@ const TAB_LABELS: Record<HQSidebarTab, string> = {
|
||||
inbox: "Inbox",
|
||||
history: "History",
|
||||
playbooks: "Playbooks",
|
||||
marketplace: "Marketplace",
|
||||
analytics: "Analytics",
|
||||
};
|
||||
|
||||
@@ -38,15 +36,14 @@ export function HQSidebar({
|
||||
inboxCount,
|
||||
onToggle,
|
||||
onTabChange,
|
||||
onOpenMarketplace,
|
||||
inboxPanel,
|
||||
historyPanel,
|
||||
playbooksPanel,
|
||||
marketplacePanel,
|
||||
analyticsPanel,
|
||||
}: HQSidebarProps) {
|
||||
const analyticsOnly = activeTab === "analytics";
|
||||
const marketplaceOnly = activeTab === "marketplace";
|
||||
const railOnly = analyticsOnly || marketplaceOnly;
|
||||
const railOnly = analyticsOnly;
|
||||
const activePanel =
|
||||
activeTab === "inbox"
|
||||
? inboxPanel
|
||||
@@ -54,9 +51,7 @@ export function HQSidebar({
|
||||
? historyPanel
|
||||
: activeTab === "playbooks"
|
||||
? playbooksPanel
|
||||
: activeTab === "marketplace"
|
||||
? marketplacePanel
|
||||
: analyticsPanel;
|
||||
: analyticsPanel;
|
||||
|
||||
return (
|
||||
<aside className="pointer-events-none fixed inset-y-0 right-0 z-20 flex justify-end">
|
||||
@@ -76,18 +71,10 @@ export function HQSidebar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onTabChange("marketplace");
|
||||
if (!open) {
|
||||
onToggle();
|
||||
}
|
||||
onOpenMarketplace();
|
||||
}}
|
||||
className={`rounded-l-md border border-r-0 px-1.5 py-2.5 font-mono text-[10px] font-semibold tracking-[0.2em] shadow-xl backdrop-blur transition-colors ${
|
||||
marketplaceOnly
|
||||
? "border-fuchsia-400/50 bg-[#16081b]/95 text-fuchsia-100"
|
||||
: "border-fuchsia-500/25 bg-[#100611]/90 text-fuchsia-300/80 hover:border-fuchsia-400/45 hover:text-fuchsia-100"
|
||||
}`}
|
||||
aria-pressed={marketplaceOnly}
|
||||
aria-label="Open marketplace sidebar"
|
||||
className="rounded-l-md border border-r-0 border-fuchsia-500/25 bg-[#100611]/90 px-1.5 py-2.5 font-mono text-[10px] font-semibold tracking-[0.2em] text-fuchsia-300/80 shadow-xl backdrop-blur transition-colors hover:border-fuchsia-400/45 hover:text-fuchsia-100"
|
||||
aria-label="Open marketplace"
|
||||
>
|
||||
<span className="block leading-none [writing-mode:vertical-rl]">
|
||||
MARKETPLACE
|
||||
@@ -120,14 +107,12 @@ export function HQSidebar({
|
||||
<div className="pointer-events-auto flex h-full w-56 flex-col border-l border-cyan-500/20 bg-black/85 shadow-2xl backdrop-blur">
|
||||
<div className="border-b border-cyan-500/15 px-4 py-3">
|
||||
<div className="font-mono text-[10px] font-semibold tracking-[0.32em] text-cyan-300/80">
|
||||
{analyticsOnly ? "ANALYTICS" : marketplaceOnly ? "MARKETPLACE" : "HEADQUARTERS"}
|
||||
{analyticsOnly ? "ANALYTICS" : "HEADQUARTERS"}
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[11px] text-white/45">
|
||||
{analyticsOnly
|
||||
? "Cost, budgets, and performance intelligence."
|
||||
: marketplaceOnly
|
||||
? "Discover, install, and enable new skills."
|
||||
: "Monitor outputs, runs, and schedules."}
|
||||
: "Monitor outputs, runs, and schedules."}
|
||||
</div>
|
||||
{railOnly ? (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import type { OfficeSkillsMarketplaceController } from "@/features/office/hooks/useOfficeSkillsMarketplace";
|
||||
|
||||
import { SkillsMarketplacePanel } from "./SkillsMarketplacePanel";
|
||||
|
||||
type SkillsMarketplaceModalProps = {
|
||||
open: boolean;
|
||||
marketplace: OfficeSkillsMarketplaceController;
|
||||
onClose: () => void;
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
onOpenAgentSettings: (agentId: string) => void;
|
||||
};
|
||||
|
||||
export function SkillsMarketplaceModal({
|
||||
open,
|
||||
marketplace,
|
||||
onClose,
|
||||
onSelectAgent,
|
||||
onOpenAgentSettings,
|
||||
}: SkillsMarketplaceModalProps) {
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [onClose, open]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[125] flex items-center justify-center bg-black/80 p-4 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Skills marketplace"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="flex h-[min(90vh,960px)] w-full max-w-6xl flex-col overflow-hidden rounded-xl border border-cyan-500/20 bg-[#050607]/95 shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-cyan-500/10 px-5 py-4">
|
||||
<div>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-cyan-300/80">
|
||||
Skills Marketplace
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[11px] text-white/45">
|
||||
Discover, install, and enable gateway skills in a wider workspace.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex items-center gap-1 rounded border border-white/10 bg-white/5 px-2 py-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-white/75 transition-colors hover:bg-white/10"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<SkillsMarketplacePanel
|
||||
marketplace={marketplace}
|
||||
onSelectAgent={onSelectAgent}
|
||||
onOpenAgentSettings={onOpenAgentSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { buildAgentSkillsAllowlistSet, deriveAgentSkillsAccessMode } from "@/lib
|
||||
type MarketplaceFilter = "all" | SkillMarketplaceCollectionId;
|
||||
|
||||
const FILTER_LABELS: Record<MarketplaceFilter, string> = {
|
||||
claw3d: "Claw3D",
|
||||
all: "All",
|
||||
featured: "Featured",
|
||||
installed: "Installed",
|
||||
@@ -99,10 +100,13 @@ export function SkillsMarketplacePanel({
|
||||
onOpenAgentSettings: (agentId: string) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeFilter, setActiveFilter] = useState<MarketplaceFilter>("all");
|
||||
const [activeFilter, setActiveFilter] = useState<MarketplaceFilter>("claw3d");
|
||||
const [detailSkillKey, setDetailSkillKey] = useState<string | null>(null);
|
||||
|
||||
const entries = useMemo(() => marketplace.skillsReport?.skills ?? [], [marketplace.skillsReport]);
|
||||
const entries = useMemo(
|
||||
() => marketplace.marketplaceSkills ?? marketplace.skillsReport?.skills ?? [],
|
||||
[marketplace.marketplaceSkills, marketplace.skillsReport]
|
||||
);
|
||||
const collections = useMemo(() => buildSkillMarketplaceCollections(entries), [entries]);
|
||||
const accessMode = useMemo(
|
||||
() => deriveAgentSkillsAccessMode(marketplace.skillsAllowlist),
|
||||
@@ -117,7 +121,7 @@ export function SkillsMarketplacePanel({
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const visibleCollectionIds: SkillMarketplaceCollectionId[] =
|
||||
activeFilter === "all"
|
||||
? ["built-in", "installed", "workspace", "extra", "other"]
|
||||
? ["claw3d", "built-in", "installed", "workspace", "extra", "other"]
|
||||
: [activeFilter];
|
||||
return collections
|
||||
.filter((collection) => visibleCollectionIds.includes(collection.id))
|
||||
@@ -151,6 +155,7 @@ export function SkillsMarketplacePanel({
|
||||
|
||||
const filterCounts = useMemo(() => {
|
||||
const counts: Record<MarketplaceFilter, number> = {
|
||||
claw3d: 0,
|
||||
all: entries.length,
|
||||
featured: 0,
|
||||
installed: 0,
|
||||
@@ -196,8 +201,8 @@ export function SkillsMarketplacePanel({
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
|
||||
<div className="rounded border border-amber-500/20 bg-amber-500/10 px-3 py-2 font-mono text-[10px] text-amber-100">
|
||||
Install and global setup actions affect the whole gateway. Agent access controls below apply only
|
||||
to the selected agent.
|
||||
Packaged skill installs target the selected agent workspace. Global setup actions still affect
|
||||
the whole gateway. Agent access controls below apply only to the selected agent.
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded border border-cyan-500/15 bg-white/[0.03] px-3 py-3">
|
||||
@@ -291,6 +296,11 @@ export function SkillsMarketplacePanel({
|
||||
}`}
|
||||
>
|
||||
{marketplace.message.text}
|
||||
{marketplace.message.kind === "success" ? (
|
||||
<div className="mt-1 font-mono text-[10px] text-emerald-100/80">
|
||||
Check the `CLAW3D` filter below to find the installed skill quickly.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -327,14 +337,32 @@ export function SkillsMarketplacePanel({
|
||||
{entry.metadata.editorBadge ?? "Featured"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-3 font-mono text-[10px] text-white/55">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Star className="h-3 w-3 text-amber-300" />
|
||||
{formatRating(entry.metadata.rating)}
|
||||
</span>
|
||||
<span>{formatInstalls(entry.metadata.installs)} installs</span>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 font-mono text-[10px] text-white/55">
|
||||
{!entry.metadata.hideStats ? (
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Star className="h-3 w-3 text-amber-300" />
|
||||
{formatRating(entry.metadata.rating)}
|
||||
</span>
|
||||
<span>{formatInstalls(entry.metadata.installs)} installs</span>
|
||||
</>
|
||||
) : null}
|
||||
<span>{entry.metadata.category}</span>
|
||||
</div>
|
||||
{entry.metadata.poweredByName && entry.metadata.poweredByUrl ? (
|
||||
<div className="mt-2 font-mono text-[10px] text-white/55">
|
||||
Powered by{" "}
|
||||
<a
|
||||
href={entry.metadata.poweredByUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-cyan-200 underline decoration-cyan-500/40 underline-offset-2 transition-colors hover:text-cyan-100"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{entry.metadata.poweredByName}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -355,9 +383,17 @@ export function SkillsMarketplacePanel({
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{collection.entries.map((entry) => {
|
||||
const packagedSkill = marketplace.packagedSkillsByKey.get(entry.skill.skillKey);
|
||||
const packageOnly = Boolean(packagedSkill && !entry.skill.baseDir.trim());
|
||||
const isEnabledForAgent = getAgentSkillEnabled(entry.skill.name, accessMode, allowlistSet);
|
||||
const primaryAction =
|
||||
entry.readiness === "needs-setup" && entry.installable
|
||||
packageOnly
|
||||
? {
|
||||
label: "Install skill",
|
||||
run: () => void marketplace.handleInstallPackagedSkill(entry.skill.skillKey),
|
||||
icon: Download,
|
||||
}
|
||||
: entry.readiness === "needs-setup" && entry.installable
|
||||
? {
|
||||
label: "Install deps",
|
||||
run: () => void marketplace.handleInstallSkill(entry.skill),
|
||||
@@ -411,13 +447,30 @@ export function SkillsMarketplacePanel({
|
||||
<Shield className="h-3 w-3 text-cyan-300" />
|
||||
{entry.metadata.trustLabel}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Star className="h-3 w-3 text-amber-300" />
|
||||
{formatRating(entry.metadata.rating)}
|
||||
</span>
|
||||
<span>{formatInstalls(entry.metadata.installs)} installs</span>
|
||||
{!entry.metadata.hideStats ? (
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Star className="h-3 w-3 text-amber-300" />
|
||||
{formatRating(entry.metadata.rating)}
|
||||
</span>
|
||||
<span>{formatInstalls(entry.metadata.installs)} installs</span>
|
||||
</>
|
||||
) : null}
|
||||
<span>{entry.skill.source}</span>
|
||||
</div>
|
||||
{entry.metadata.poweredByName && entry.metadata.poweredByUrl ? (
|
||||
<div className="mt-2 font-mono text-[10px] text-white/55">
|
||||
Powered by{" "}
|
||||
<a
|
||||
href={entry.metadata.poweredByUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-cyan-200 underline decoration-cyan-500/40 underline-offset-2 transition-colors hover:text-cyan-100"
|
||||
>
|
||||
{entry.metadata.poweredByName}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{entry.missingDetails.length > 0 ? (
|
||||
<div className="mt-2 font-mono text-[10px] text-amber-100/85">
|
||||
{entry.missingDetails[0]}
|
||||
@@ -430,6 +483,7 @@ export function SkillsMarketplacePanel({
|
||||
type="button"
|
||||
onClick={() => void marketplace.handleSetSkillEnabled(entry.skill.name, !isEnabledForAgent)}
|
||||
disabled={
|
||||
packageOnly ||
|
||||
entry.readiness === "unavailable" ||
|
||||
!marketplace.selectedAgentId ||
|
||||
marketplace.busySkillKey === entry.skill.skillKey
|
||||
@@ -440,7 +494,7 @@ export function SkillsMarketplacePanel({
|
||||
: "border-white/10 bg-white/5 text-white/75 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
{isEnabledForAgent ? "Enabled for agent" : "Enable for agent"}
|
||||
{isEnabledForAgent ? "Disable for agent" : "Enable for agent"}
|
||||
</button>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
@@ -464,7 +518,7 @@ export function SkillsMarketplacePanel({
|
||||
className="inline-flex items-center gap-1 rounded border border-rose-500/25 bg-rose-500/10 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-rose-100 transition-colors hover:border-rose-400/40 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Remove
|
||||
Remove for all agents
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
@@ -478,6 +532,16 @@ export function SkillsMarketplacePanel({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-2 font-mono text-[10px] text-white/35">
|
||||
<div>
|
||||
{isEnabledForAgent
|
||||
? "This skill is currently enabled for the selected agent."
|
||||
: "This skill is currently disabled for the selected agent."}
|
||||
</div>
|
||||
{entry.removable ? (
|
||||
<div>Removing from the gateway deletes the installed skill for every agent.</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -523,15 +587,36 @@ export function SkillsMarketplacePanel({
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 font-mono text-[11px] text-white/75">{detailEntry.metadata.tagline}</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 font-mono text-[10px] text-white/55">
|
||||
<div className="rounded border border-white/8 bg-black/30 px-2 py-2">
|
||||
<div className="text-white/35">Rating</div>
|
||||
<div className="mt-1 text-white/90">{formatRating(detailEntry.metadata.rating)}</div>
|
||||
</div>
|
||||
<div className="rounded border border-white/8 bg-black/30 px-2 py-2">
|
||||
<div className="text-white/35">Installs</div>
|
||||
<div className="mt-1 text-white/90">{formatInstalls(detailEntry.metadata.installs)}</div>
|
||||
{detailEntry.metadata.poweredByName && detailEntry.metadata.poweredByUrl ? (
|
||||
<div className="mt-3 font-mono text-[10px] text-white/60">
|
||||
Powered by{" "}
|
||||
<a
|
||||
href={detailEntry.metadata.poweredByUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-cyan-200 underline decoration-cyan-500/40 underline-offset-2 transition-colors hover:text-cyan-100"
|
||||
>
|
||||
{detailEntry.metadata.poweredByName}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={`mt-3 grid gap-2 font-mono text-[10px] text-white/55 ${
|
||||
detailEntry.metadata.hideStats ? "grid-cols-1" : "grid-cols-3"
|
||||
}`}
|
||||
>
|
||||
{!detailEntry.metadata.hideStats ? (
|
||||
<>
|
||||
<div className="rounded border border-white/8 bg-black/30 px-2 py-2">
|
||||
<div className="text-white/35">Rating</div>
|
||||
<div className="mt-1 text-white/90">{formatRating(detailEntry.metadata.rating)}</div>
|
||||
</div>
|
||||
<div className="rounded border border-white/8 bg-black/30 px-2 py-2">
|
||||
<div className="text-white/35">Installs</div>
|
||||
<div className="mt-1 text-white/90">{formatInstalls(detailEntry.metadata.installs)}</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className="rounded border border-white/8 bg-black/30 px-2 py-2">
|
||||
<div className="text-white/35">Source</div>
|
||||
<div className="mt-1 text-white/90">{detailEntry.skill.source}</div>
|
||||
@@ -574,11 +659,23 @@ export function SkillsMarketplacePanel({
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 rounded border border-cyan-500/15 bg-cyan-500/10 px-3 py-3 font-mono text-[10px] text-cyan-100">
|
||||
Gateway setup changes apply to every agent. Agent enablement still depends on the selected
|
||||
agent's allowlist.
|
||||
Packaged installs land in the selected workspace. Gateway setup changes still apply to every
|
||||
agent, and agent enablement depends on the selected agent's allowlist.
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{marketplace.packagedSkillsByKey.get(detailEntry.skill.skillKey) &&
|
||||
!detailEntry.skill.baseDir.trim() ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void marketplace.handleInstallPackagedSkill(detailEntry.skill.skillKey)}
|
||||
disabled={marketplace.busySkillKey === detailEntry.skill.skillKey}
|
||||
className="inline-flex items-center gap-1 rounded border border-cyan-500/25 bg-cyan-500/10 px-2 py-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-cyan-100 transition-colors hover:border-cyan-400/40 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Install skill
|
||||
</button>
|
||||
) : null}
|
||||
{detailEntry.readiness === "needs-setup" && detailEntry.installable ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -628,6 +725,10 @@ export function SkillsMarketplacePanel({
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-4 rounded border border-white/8 bg-white/[0.03] px-3 py-3 font-mono text-[10px] text-white/60">
|
||||
`Enable/Disable for agent` only changes access for the selected agent. `Remove for all agents`
|
||||
deletes the installed skill from the gateway workspace.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { readGatewayAgentSkillsAllowlist } from "@/lib/gateway/agentConfig";
|
||||
import type { GatewayClient, GatewayStatus } from "@/lib/gateway/GatewayClient";
|
||||
import type { OfficeSkillTriggerMovementTarget } from "@/lib/office/places";
|
||||
import {
|
||||
buildAgentSkillsAllowlistSet,
|
||||
deriveAgentSkillsAccessMode,
|
||||
deriveSkillReadinessState,
|
||||
} from "@/lib/skills/presentation";
|
||||
import {
|
||||
listPackagedSkillTriggerDefinitions,
|
||||
resolveTriggeredSkillDefinition,
|
||||
type SkillTriggerDefinition,
|
||||
} from "@/lib/skills/triggers";
|
||||
import { loadAgentSkillStatus } from "@/lib/skills/types";
|
||||
|
||||
const isSkillEnabledForAgent = (params: {
|
||||
allowlist: string[] | undefined;
|
||||
skillName: string;
|
||||
}): boolean => {
|
||||
const accessMode = deriveAgentSkillsAccessMode(params.allowlist);
|
||||
if (accessMode === "all") {
|
||||
return true;
|
||||
}
|
||||
if (accessMode === "none") {
|
||||
return false;
|
||||
}
|
||||
return buildAgentSkillsAllowlistSet(params.allowlist).has(params.skillName.trim());
|
||||
};
|
||||
|
||||
export const useOfficeSkillTriggers = ({
|
||||
client,
|
||||
status,
|
||||
agents,
|
||||
}: {
|
||||
client: GatewayClient;
|
||||
status: GatewayStatus;
|
||||
agents: AgentState[];
|
||||
}) => {
|
||||
const requestIdRef = useRef(0);
|
||||
const [enabledTriggersByAgentId, setEnabledTriggersByAgentId] = useState<
|
||||
Record<string, SkillTriggerDefinition[]>
|
||||
>({});
|
||||
const packagedTriggers = useMemo(() => listPackagedSkillTriggerDefinitions(), []);
|
||||
const agentIdsKey = useMemo(
|
||||
() => agents.map((agent) => agent.agentId).sort().join("|"),
|
||||
[agents],
|
||||
);
|
||||
const stableAgentIds = useMemo(
|
||||
() => (agentIdsKey ? agentIdsKey.split("|").filter((value) => value.length > 0) : []),
|
||||
[agentIdsKey],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "connected" || agents.length === 0 || packagedTriggers.length === 0) {
|
||||
setEnabledTriggersByAgentId({});
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
const requestId = requestIdRef.current + 1;
|
||||
requestIdRef.current = requestId;
|
||||
try {
|
||||
const triggerBySkillKey = new Map(
|
||||
packagedTriggers.map((trigger) => [trigger.skillKey, trigger]),
|
||||
);
|
||||
const results = await Promise.all(
|
||||
stableAgentIds.map(async (agentId) => {
|
||||
const [report, allowlist] = await Promise.all([
|
||||
loadAgentSkillStatus(client, agentId),
|
||||
readGatewayAgentSkillsAllowlist({ client, agentId }),
|
||||
]);
|
||||
const enabledTriggers = report.skills
|
||||
.filter((skill) => deriveSkillReadinessState(skill) === "ready")
|
||||
.filter((skill) => isSkillEnabledForAgent({ allowlist, skillName: skill.name }))
|
||||
.map((skill) => triggerBySkillKey.get(skill.skillKey))
|
||||
.filter((trigger): trigger is SkillTriggerDefinition => Boolean(trigger));
|
||||
return [agentId, enabledTriggers] as const;
|
||||
}),
|
||||
);
|
||||
|
||||
if (cancelled || requestId !== requestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEnabledTriggersByAgentId(Object.fromEntries(results));
|
||||
} catch {
|
||||
if (cancelled || requestId !== requestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
setEnabledTriggersByAgentId({});
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
const intervalId = window.setInterval(() => {
|
||||
void load();
|
||||
}, 30_000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [agentIdsKey, client, packagedTriggers, stableAgentIds, status]);
|
||||
|
||||
const movementTargetByAgentId = useMemo<Record<string, OfficeSkillTriggerMovementTarget>>(() => {
|
||||
const next: Record<string, OfficeSkillTriggerMovementTarget> = {};
|
||||
for (const agent of agents) {
|
||||
const trigger = resolveTriggeredSkillDefinition({
|
||||
isAgentRunning: agent.status === "running" || Boolean(agent.runId),
|
||||
lastUserMessage: agent.lastUserMessage,
|
||||
transcriptEntries: agent.transcriptEntries,
|
||||
triggers: enabledTriggersByAgentId[agent.agentId] ?? [],
|
||||
});
|
||||
if (trigger) {
|
||||
next[agent.agentId] = trigger.movementTarget;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}, [agents, enabledTriggersByAgentId]);
|
||||
|
||||
return {
|
||||
enabledTriggersByAgentId,
|
||||
movementTargetByAgentId,
|
||||
};
|
||||
};
|
||||
@@ -6,6 +6,12 @@ import type { GatewayClient, GatewayStatus } from "@/lib/gateway/GatewayClient";
|
||||
import { readGatewayAgentSkillsAllowlist } from "@/lib/gateway/agentConfig";
|
||||
import { isGatewayDisconnectLikeError } from "@/lib/gateway/GatewayClient";
|
||||
import { setAgentSkillEnabled } from "@/lib/skills/agentAccess";
|
||||
import {
|
||||
appendPackagedSkillsToMarketplace,
|
||||
getPackagedSkillBySkillKey,
|
||||
listPackagedSkills,
|
||||
} from "@/lib/skills/catalog";
|
||||
import { installPackagedSkillViaGatewayAgent } from "@/lib/skills/install-gateway";
|
||||
import { resolvePreferredInstallOption } from "@/lib/skills/presentation";
|
||||
import { removeSkillFromGateway } from "@/lib/skills/remove";
|
||||
import {
|
||||
@@ -50,11 +56,19 @@ export const useOfficeSkillsMarketplace = ({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busySkillKey, setBusySkillKey] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<MarketplaceMessage | null>(null);
|
||||
const packagedSkillsByKey = useMemo(
|
||||
() => new Map(listPackagedSkills().map((skill) => [skill.skillKey, skill])),
|
||||
[]
|
||||
);
|
||||
|
||||
const selectedAgent = useMemo(
|
||||
() => agents.find((agent) => agent.agentId === selectedAgentId) ?? null,
|
||||
[agents, selectedAgentId],
|
||||
);
|
||||
const marketplaceSkills = useMemo(
|
||||
() => appendPackagedSkillsToMarketplace(skillsReport?.skills ?? []),
|
||||
[skillsReport]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const preferred = (preferredAgentId ?? "").trim();
|
||||
@@ -240,6 +254,36 @@ export const useOfficeSkillsMarketplace = ({
|
||||
[client, runSkillMutation],
|
||||
);
|
||||
|
||||
const handleInstallPackagedSkill = useCallback(
|
||||
async (skillKey: string) => {
|
||||
const packagedSkill = getPackagedSkillBySkillKey(skillKey);
|
||||
if (!packagedSkill) {
|
||||
setMessage({
|
||||
kind: "error",
|
||||
text: `No packaged marketplace skill was found for ${skillKey.trim() || "that entry"}.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await runSkillMutation({
|
||||
skillKey: packagedSkill.skillKey,
|
||||
successMessage: `Successfully installed ${packagedSkill.name.trim()} in the selected workspace. Enable it for the agent from the CLAW3D tab.`,
|
||||
run: async (_agentId, report) => {
|
||||
await installPackagedSkillViaGatewayAgent({
|
||||
client,
|
||||
request: {
|
||||
packageId: packagedSkill.packageId,
|
||||
source: packagedSkill.installSource,
|
||||
workspaceDir: report.workspaceDir,
|
||||
managedSkillsDir: report.managedSkillsDir,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
[client, runSkillMutation]
|
||||
);
|
||||
|
||||
const handleSetSkillGlobalEnabled = useCallback(
|
||||
async (skillKey: string, enabled: boolean) => {
|
||||
await runSkillMutation({
|
||||
@@ -262,6 +306,7 @@ export const useOfficeSkillsMarketplace = ({
|
||||
successMessage: `${skill.name.trim()} removed from gateway files.`,
|
||||
run: async (_agentId, report) => {
|
||||
await removeSkillFromGateway({
|
||||
client,
|
||||
skillKey: skill.skillKey,
|
||||
source: skill.source as
|
||||
| "openclaw-managed"
|
||||
@@ -282,6 +327,8 @@ export const useOfficeSkillsMarketplace = ({
|
||||
selectedAgentId,
|
||||
setSelectedAgentId,
|
||||
skillsReport,
|
||||
marketplaceSkills,
|
||||
packagedSkillsByKey,
|
||||
skillsAllowlist,
|
||||
loading,
|
||||
error,
|
||||
@@ -290,6 +337,7 @@ export const useOfficeSkillsMarketplace = ({
|
||||
refresh,
|
||||
handleSetSkillEnabled,
|
||||
handleInstallSkill,
|
||||
handleInstallPackagedSkill,
|
||||
handleSetSkillGlobalEnabled,
|
||||
handleRemoveSkill,
|
||||
};
|
||||
|
||||
@@ -81,7 +81,8 @@ import { AnalyticsPanel } from "@/features/office/components/panels/AnalyticsPan
|
||||
import { HistoryPanel } from "@/features/office/components/panels/HistoryPanel";
|
||||
import { InboxPanel } from "@/features/office/components/panels/InboxPanel";
|
||||
import { PlaybooksPanel } from "@/features/office/components/panels/PlaybooksPanel";
|
||||
import { SkillsMarketplacePanel } from "@/features/office/components/panels/SkillsMarketplacePanel";
|
||||
import { SkillsMarketplaceModal } from "@/features/office/components/panels/SkillsMarketplaceModal";
|
||||
import { useOfficeSkillTriggers } from "@/features/office/hooks/useOfficeSkillTriggers";
|
||||
import { useOfficeSkillsMarketplace } from "@/features/office/hooks/useOfficeSkillsMarketplace";
|
||||
import { useOfficeStandupController } from "@/features/office/hooks/useOfficeStandupController";
|
||||
import { useRunLog } from "@/features/office/hooks/useRunLog";
|
||||
@@ -106,6 +107,7 @@ import {
|
||||
type OfficePhoneCallRequest,
|
||||
type OfficeTextMessageRequest,
|
||||
} from "@/lib/office/eventTriggers";
|
||||
import { buildOfficeSkillTriggerHoldMaps } from "@/lib/office/places";
|
||||
import type { MockPhoneCallScenario } from "@/lib/office/call/types";
|
||||
import type { MockTextMessageScenario } from "@/lib/office/text/types";
|
||||
import {
|
||||
@@ -759,6 +761,7 @@ export function OfficeScreen({
|
||||
>({});
|
||||
const [gatewayModels, setGatewayModels] = useState<GatewayModelChoice[]>([]);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [marketplaceOpen, setMarketplaceOpen] = useState(false);
|
||||
const [activeSidebarTab, setActiveSidebarTab] =
|
||||
useState<HQSidebarTab>("inbox");
|
||||
const router = useRouter();
|
||||
@@ -1699,22 +1702,53 @@ export function OfficeScreen({
|
||||
onSkillActivityStart: handleMarketplaceGymStart,
|
||||
onSkillActivityEnd: handleMarketplaceGymEnd,
|
||||
});
|
||||
const skillTriggers = useOfficeSkillTriggers({
|
||||
client,
|
||||
status,
|
||||
agents: state.agents,
|
||||
});
|
||||
const animationNowMs = Date.now();
|
||||
const officeAnimationState = useMemo(
|
||||
() =>
|
||||
buildOfficeAnimationState({
|
||||
state: officeTriggerState,
|
||||
agents: state.agents,
|
||||
marketplaceGymHoldByAgentId,
|
||||
nowMs: animationNowMs,
|
||||
}),
|
||||
[
|
||||
animationNowMs,
|
||||
const officeAnimationState = useMemo(() => {
|
||||
const base = buildOfficeAnimationState({
|
||||
state: officeTriggerState,
|
||||
agents: state.agents,
|
||||
marketplaceGymHoldByAgentId,
|
||||
officeTriggerState,
|
||||
state.agents,
|
||||
],
|
||||
);
|
||||
nowMs: animationNowMs,
|
||||
});
|
||||
const skillTriggerHoldMaps = buildOfficeSkillTriggerHoldMaps(
|
||||
skillTriggers.movementTargetByAgentId,
|
||||
);
|
||||
|
||||
return {
|
||||
...base,
|
||||
deskHoldByAgentId: {
|
||||
...base.deskHoldByAgentId,
|
||||
...skillTriggerHoldMaps.deskHoldByAgentId,
|
||||
},
|
||||
githubHoldByAgentId: {
|
||||
...base.githubHoldByAgentId,
|
||||
...skillTriggerHoldMaps.githubHoldByAgentId,
|
||||
},
|
||||
gymHoldByAgentId: {
|
||||
...base.gymHoldByAgentId,
|
||||
...skillTriggerHoldMaps.gymHoldByAgentId,
|
||||
},
|
||||
qaHoldByAgentId: {
|
||||
...base.qaHoldByAgentId,
|
||||
...skillTriggerHoldMaps.qaHoldByAgentId,
|
||||
},
|
||||
skillGymHoldByAgentId: {
|
||||
...base.skillGymHoldByAgentId,
|
||||
...skillTriggerHoldMaps.skillGymHoldByAgentId,
|
||||
},
|
||||
};
|
||||
}, [
|
||||
animationNowMs,
|
||||
marketplaceGymHoldByAgentId,
|
||||
officeTriggerState,
|
||||
skillTriggers.movementTargetByAgentId,
|
||||
state.agents,
|
||||
]);
|
||||
const {
|
||||
deskHoldByAgentId,
|
||||
githubHoldByAgentId,
|
||||
@@ -2851,8 +2885,7 @@ export function OfficeScreen({
|
||||
onPhoneCallComplete={handlePhoneCallComplete}
|
||||
onTextMessageComplete={handleTextMessageComplete}
|
||||
onOpenGithubSkillSetup={() => {
|
||||
setSidebarOpen(true);
|
||||
setActiveSidebarTab("marketplace");
|
||||
setMarketplaceOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -2887,6 +2920,7 @@ export function OfficeScreen({
|
||||
inboxCount={unseenInboxCount}
|
||||
onToggle={() => setSidebarOpen((prev) => !prev)}
|
||||
onTabChange={setActiveSidebarTab}
|
||||
onOpenMarketplace={() => setMarketplaceOpen(true)}
|
||||
inboxPanel={
|
||||
<InboxPanel
|
||||
agents={state.agents}
|
||||
@@ -2914,16 +2948,6 @@ export function OfficeScreen({
|
||||
standup={standupController}
|
||||
/>
|
||||
}
|
||||
marketplacePanel={
|
||||
<SkillsMarketplacePanel
|
||||
marketplace={marketplace}
|
||||
onSelectAgent={handleOpenAgentChat}
|
||||
onOpenAgentSettings={(agentId) => {
|
||||
handleOpenAgentChat(agentId);
|
||||
router.push("/office");
|
||||
}}
|
||||
/>
|
||||
}
|
||||
analyticsPanel={
|
||||
<AnalyticsPanel
|
||||
client={client}
|
||||
@@ -2941,6 +2965,21 @@ export function OfficeScreen({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<SkillsMarketplaceModal
|
||||
open={marketplaceOpen}
|
||||
marketplace={marketplace}
|
||||
onClose={() => setMarketplaceOpen(false)}
|
||||
onSelectAgent={(agentId) => {
|
||||
handleOpenAgentChat(agentId);
|
||||
setMarketplaceOpen(false);
|
||||
}}
|
||||
onOpenAgentSettings={(agentId) => {
|
||||
handleOpenAgentChat(agentId);
|
||||
setMarketplaceOpen(false);
|
||||
router.push("/office");
|
||||
}}
|
||||
/>
|
||||
|
||||
{showOnboardingWizard ? (
|
||||
<OnboardingWizard
|
||||
gatewayConnected={status === "connected"}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
|
||||
import type { OfficeInteractionTargetId } from "@/lib/office/places";
|
||||
|
||||
export type OfficeAgent = {
|
||||
id: string;
|
||||
@@ -50,14 +51,7 @@ export type RenderAgent = SceneActor & {
|
||||
pingPongTableUid?: string;
|
||||
pingPongSide?: 0 | 1;
|
||||
pingPongPreviousWalkSpeed?: number;
|
||||
interactionTarget?:
|
||||
| "desk"
|
||||
| "server_room"
|
||||
| "meeting_room"
|
||||
| "gym"
|
||||
| "qa_lab"
|
||||
| "sms_booth"
|
||||
| "phone_booth";
|
||||
interactionTarget?: OfficeInteractionTargetId;
|
||||
smsBoothStage?: "door_outer" | "door_inner" | "typing";
|
||||
phoneBoothStage?: "door_outer" | "door_inner" | "receiver";
|
||||
serverRoomStage?: "door_outer" | "door_inner" | "terminal";
|
||||
|
||||
@@ -430,6 +430,32 @@ export const deleteGatewayAgent = async (params: {
|
||||
}
|
||||
};
|
||||
|
||||
export const removeGatewayAgentFromConfigOnly = async (params: {
|
||||
client: GatewayClient;
|
||||
agentId: string;
|
||||
}): Promise<{ removed: boolean }> => {
|
||||
const agentId = params.agentId.trim();
|
||||
if (!agentId) {
|
||||
throw new Error("Agent id is required.");
|
||||
}
|
||||
|
||||
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
|
||||
const baseConfig = isRecord(snapshot.config) ? snapshot.config : {};
|
||||
const list = readConfigAgentList(baseConfig);
|
||||
const nextList = list.filter((entry) => entry.id !== agentId);
|
||||
if (nextList.length === list.length) {
|
||||
return { removed: false };
|
||||
}
|
||||
|
||||
await applyGatewayConfigPatch({
|
||||
client: params.client,
|
||||
patch: { agents: { list: nextList } },
|
||||
baseHash: snapshot.hash ?? undefined,
|
||||
exists: snapshot.exists,
|
||||
});
|
||||
return { removed: true };
|
||||
};
|
||||
|
||||
export const updateGatewayHeartbeat = async (params: {
|
||||
client: GatewayClient;
|
||||
agentId: string;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
export const OFFICE_INTERACTION_TARGETS = [
|
||||
"desk",
|
||||
"server_room",
|
||||
"meeting_room",
|
||||
"gym",
|
||||
"qa_lab",
|
||||
"sms_booth",
|
||||
"phone_booth",
|
||||
] as const;
|
||||
|
||||
export type OfficeInteractionTargetId = (typeof OFFICE_INTERACTION_TARGETS)[number];
|
||||
|
||||
export const OFFICE_SKILL_TRIGGER_MOVEMENT_TARGETS = [
|
||||
"desk",
|
||||
"github",
|
||||
"gym",
|
||||
"qa_lab",
|
||||
] as const;
|
||||
|
||||
export type OfficeSkillTriggerMovementTarget =
|
||||
(typeof OFFICE_SKILL_TRIGGER_MOVEMENT_TARGETS)[number];
|
||||
|
||||
type OfficeSkillTriggerAnimationHoldKey =
|
||||
| "deskHoldByAgentId"
|
||||
| "githubHoldByAgentId"
|
||||
| "gymHoldByAgentId"
|
||||
| "qaHoldByAgentId";
|
||||
|
||||
export const OFFICE_SKILL_TRIGGER_PLACE_REGISTRY: Record<
|
||||
OfficeSkillTriggerMovementTarget,
|
||||
{
|
||||
label: string;
|
||||
interactionTarget: OfficeInteractionTargetId;
|
||||
animationHoldKey: OfficeSkillTriggerAnimationHoldKey;
|
||||
alsoSetsSkillGymHold?: boolean;
|
||||
}
|
||||
> = {
|
||||
desk: {
|
||||
label: "Desk",
|
||||
interactionTarget: "desk",
|
||||
animationHoldKey: "deskHoldByAgentId",
|
||||
},
|
||||
github: {
|
||||
label: "GitHub / Server Room",
|
||||
interactionTarget: "server_room",
|
||||
animationHoldKey: "githubHoldByAgentId",
|
||||
},
|
||||
gym: {
|
||||
label: "Gym",
|
||||
interactionTarget: "gym",
|
||||
animationHoldKey: "gymHoldByAgentId",
|
||||
alsoSetsSkillGymHold: true,
|
||||
},
|
||||
qa_lab: {
|
||||
label: "QA Lab",
|
||||
interactionTarget: "qa_lab",
|
||||
animationHoldKey: "qaHoldByAgentId",
|
||||
},
|
||||
};
|
||||
|
||||
export const isOfficeSkillTriggerMovementTarget = (
|
||||
value: unknown,
|
||||
): value is OfficeSkillTriggerMovementTarget =>
|
||||
typeof value === "string" && value in OFFICE_SKILL_TRIGGER_PLACE_REGISTRY;
|
||||
|
||||
export type DefaultSkillTriggerFallback = {
|
||||
anyPhrases: string[];
|
||||
movementTarget: OfficeSkillTriggerMovementTarget;
|
||||
skipIfAlreadyThere?: boolean;
|
||||
};
|
||||
|
||||
export const DEFAULT_SKILL_TRIGGER_FALLBACKS_BY_SKILL_KEY: Record<
|
||||
string,
|
||||
DefaultSkillTriggerFallback
|
||||
> = {
|
||||
"todo-board": {
|
||||
anyPhrases: [
|
||||
"todo",
|
||||
"todo list",
|
||||
"blocked task",
|
||||
"blocked tasks",
|
||||
"add to my todo",
|
||||
"show my todo",
|
||||
],
|
||||
movementTarget: "desk",
|
||||
skipIfAlreadyThere: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const buildOfficeSkillTriggerHoldMaps = (
|
||||
movementTargetByAgentId: Record<string, OfficeSkillTriggerMovementTarget>,
|
||||
): {
|
||||
deskHoldByAgentId: Record<string, boolean>;
|
||||
githubHoldByAgentId: Record<string, boolean>;
|
||||
gymHoldByAgentId: Record<string, boolean>;
|
||||
qaHoldByAgentId: Record<string, boolean>;
|
||||
skillGymHoldByAgentId: Record<string, boolean>;
|
||||
} => {
|
||||
const next = {
|
||||
deskHoldByAgentId: {} as Record<string, boolean>,
|
||||
githubHoldByAgentId: {} as Record<string, boolean>,
|
||||
gymHoldByAgentId: {} as Record<string, boolean>,
|
||||
qaHoldByAgentId: {} as Record<string, boolean>,
|
||||
skillGymHoldByAgentId: {} as Record<string, boolean>,
|
||||
};
|
||||
|
||||
for (const [agentId, target] of Object.entries(movementTargetByAgentId)) {
|
||||
const place = OFFICE_SKILL_TRIGGER_PLACE_REGISTRY[target];
|
||||
next[place.animationHoldKey][agentId] = true;
|
||||
if (place.alsoSetsSkillGymHold) {
|
||||
next.skillGymHoldByAgentId[agentId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { RemovableSkillSource, SkillStatusEntry } from "@/lib/skills/types";
|
||||
|
||||
export type PackagedSkillId = "todo-board";
|
||||
|
||||
export type PackagedSkillDefinition = {
|
||||
packageId: PackagedSkillId;
|
||||
skillKey: string;
|
||||
name: string;
|
||||
description: string;
|
||||
installSource: RemovableSkillSource;
|
||||
creatorName?: string;
|
||||
creatorUrl?: string;
|
||||
};
|
||||
|
||||
const EMPTY_REQUIREMENTS = {
|
||||
bins: [],
|
||||
anyBins: [],
|
||||
env: [],
|
||||
config: [],
|
||||
os: [],
|
||||
};
|
||||
|
||||
const PACKAGED_SKILLS: PackagedSkillDefinition[] = [
|
||||
{
|
||||
packageId: "todo-board",
|
||||
skillKey: "todo-board",
|
||||
name: "todo",
|
||||
description: "Maintain a shared workspace TODO list with blocked tasks.",
|
||||
installSource: "openclaw-workspace",
|
||||
creatorName: "iamlukethedev",
|
||||
creatorUrl: "http://x.com/iamlukethedev/",
|
||||
},
|
||||
];
|
||||
|
||||
export const listPackagedSkills = (): PackagedSkillDefinition[] => [...PACKAGED_SKILLS];
|
||||
|
||||
export const getPackagedSkillById = (packageId: string): PackagedSkillDefinition | null =>
|
||||
PACKAGED_SKILLS.find((skill) => skill.packageId === packageId) ?? null;
|
||||
|
||||
export const getPackagedSkillBySkillKey = (skillKey: string): PackagedSkillDefinition | null => {
|
||||
const normalized = skillKey.trim();
|
||||
return PACKAGED_SKILLS.find((skill) => skill.skillKey === normalized) ?? null;
|
||||
};
|
||||
|
||||
export const buildPackagedSkillStatusEntry = (
|
||||
skill: PackagedSkillDefinition
|
||||
): SkillStatusEntry => ({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
source: "openclaw-extra",
|
||||
bundled: false,
|
||||
filePath: "",
|
||||
baseDir: "",
|
||||
skillKey: skill.skillKey,
|
||||
always: false,
|
||||
disabled: false,
|
||||
blockedByAllowlist: false,
|
||||
eligible: false,
|
||||
requirements: { ...EMPTY_REQUIREMENTS },
|
||||
missing: { ...EMPTY_REQUIREMENTS },
|
||||
configChecks: [],
|
||||
install: [],
|
||||
});
|
||||
|
||||
export const appendPackagedSkillsToMarketplace = (skills: SkillStatusEntry[]): SkillStatusEntry[] => {
|
||||
const presentKeys = new Set(skills.map((skill) => skill.skillKey.trim()));
|
||||
const additions = PACKAGED_SKILLS.filter((skill) => !presentKeys.has(skill.skillKey)).map(
|
||||
buildPackagedSkillStatusEntry
|
||||
);
|
||||
if (additions.length === 0) {
|
||||
return skills;
|
||||
}
|
||||
return [...additions, ...skills];
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import { buildAgentMainSessionKey, type GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import {
|
||||
removeGatewayAgentFromConfigOnly,
|
||||
updateGatewayAgentOverrides,
|
||||
} from "@/lib/gateway/agentConfig";
|
||||
import { getPackagedSkillById } from "@/lib/skills/catalog";
|
||||
import { readPackagedSkillFiles } from "@/lib/skills/packaged";
|
||||
import type { PackagedSkillInstallRequest, PackagedSkillInstallResult } from "@/lib/skills/types";
|
||||
|
||||
const normalizeRequired = (value: string, field: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error(`${field} is required.`);
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const escapeForJsonString = (value: string) => JSON.stringify(value);
|
||||
|
||||
const buildInstallerMessage = (params: {
|
||||
skillKey: string;
|
||||
files: Array<{ relativePath: string; content: string }>;
|
||||
}) => {
|
||||
const fileEntries = params.files
|
||||
.map(
|
||||
(file) =>
|
||||
`- path: ${escapeForJsonString(`skills/${params.skillKey}/${file.relativePath}`)}\n content: ${escapeForJsonString(file.content)}`
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return [
|
||||
"Create these exact skill files inside the current workspace.",
|
||||
"You must use the file tools and write the files exactly as provided.",
|
||||
"Do not modify filenames, frontmatter, spacing, or content.",
|
||||
"Create parent directories if they do not exist.",
|
||||
"After writing the files, verify they exist and then reply only with: INSTALLED",
|
||||
"",
|
||||
"Files:",
|
||||
fileEntries,
|
||||
].join("\n");
|
||||
};
|
||||
|
||||
const resolveRunId = (payload: unknown): string => {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
throw new Error("Gateway returned an invalid chat.send response.");
|
||||
}
|
||||
const record = payload as Record<string, unknown>;
|
||||
const runId = typeof record.runId === "string" ? record.runId.trim() : "";
|
||||
if (!runId) {
|
||||
throw new Error("Gateway returned an invalid chat.send response (missing runId).");
|
||||
}
|
||||
return runId;
|
||||
};
|
||||
|
||||
const resolveMainKey = async (client: GatewayClient): Promise<string> => {
|
||||
const result = (await client.call("agents.list", {})) as { mainKey?: unknown };
|
||||
return typeof result?.mainKey === "string" && result.mainKey.trim() ? result.mainKey.trim() : "main";
|
||||
};
|
||||
|
||||
export const installPackagedSkillViaGatewayAgent = async (params: {
|
||||
client: GatewayClient;
|
||||
request: PackagedSkillInstallRequest;
|
||||
}): Promise<PackagedSkillInstallResult> => {
|
||||
const packageId = normalizeRequired(params.request.packageId, "packageId");
|
||||
const packagedSkill = getPackagedSkillById(packageId);
|
||||
if (!packagedSkill) {
|
||||
throw new Error(`Unknown packaged skill: ${packageId}`);
|
||||
}
|
||||
if (params.request.source !== "openclaw-workspace") {
|
||||
throw new Error("Gateway-native packaged install currently supports workspace skills only.");
|
||||
}
|
||||
|
||||
const workspaceDir = normalizeRequired(params.request.workspaceDir, "workspaceDir");
|
||||
const files = readPackagedSkillFiles(packagedSkill.packageId);
|
||||
const installerName = `Skill Installer ${Date.now()}`;
|
||||
|
||||
let installerAgentId: string | null = null;
|
||||
try {
|
||||
const created = (await params.client.call("agents.create", {
|
||||
name: installerName,
|
||||
workspace: workspaceDir,
|
||||
})) as { agentId?: unknown };
|
||||
installerAgentId =
|
||||
typeof created?.agentId === "string" ? created.agentId.trim() : "";
|
||||
if (!installerAgentId) {
|
||||
throw new Error("Gateway returned an invalid agents.create response (missing agentId).");
|
||||
}
|
||||
|
||||
await updateGatewayAgentOverrides({
|
||||
client: params.client,
|
||||
agentId: installerAgentId,
|
||||
overrides: {
|
||||
tools: {
|
||||
alsoAllow: ["group:runtime", "group:fs"],
|
||||
deny: ["group:web"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mainKey = await resolveMainKey(params.client);
|
||||
const sessionKey = buildAgentMainSessionKey(installerAgentId, mainKey);
|
||||
const sendResult = await params.client.call("chat.send", {
|
||||
sessionKey,
|
||||
message: buildInstallerMessage({ skillKey: packagedSkill.skillKey, files }),
|
||||
deliver: false,
|
||||
idempotencyKey: `skill-install:${packagedSkill.skillKey}:${Date.now()}`,
|
||||
});
|
||||
const runId = resolveRunId(sendResult);
|
||||
await params.client.call("agent.wait", { runId, timeoutMs: 60_000 });
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
installedPath: `${workspaceDir.replace(/\/+$/, "")}/skills/${packagedSkill.skillKey}`,
|
||||
source: "openclaw-workspace",
|
||||
skillKey: packagedSkill.skillKey,
|
||||
};
|
||||
} finally {
|
||||
if (installerAgentId) {
|
||||
try {
|
||||
await removeGatewayAgentFromConfigOnly({
|
||||
client: params.client,
|
||||
agentId: installerAgentId,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup for temporary installer agents.
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
hasInstallableMissingBinary,
|
||||
type SkillReadinessState,
|
||||
} from "@/lib/skills/presentation";
|
||||
import { getPackagedSkillBySkillKey } from "@/lib/skills/catalog";
|
||||
import type { SkillStatusEntry } from "@/lib/skills/types";
|
||||
|
||||
export type SkillMarketplaceCollectionId =
|
||||
| "claw3d"
|
||||
| "featured"
|
||||
| "installed"
|
||||
| "setup-required"
|
||||
@@ -26,6 +28,9 @@ export type SkillMarketplaceMetadata = {
|
||||
editorBadge?: string;
|
||||
rating?: number;
|
||||
installs?: number;
|
||||
poweredByName?: string;
|
||||
poweredByUrl?: string;
|
||||
hideStats?: boolean;
|
||||
};
|
||||
|
||||
export type SkillMarketplaceEntry = {
|
||||
@@ -72,6 +77,14 @@ const SKILL_MARKETPLACE_OVERRIDES: Record<string, Partial<SkillMarketplaceMetada
|
||||
rating: 4.7,
|
||||
installs: 11980,
|
||||
},
|
||||
"todo-board": {
|
||||
category: "Productivity",
|
||||
tagline: "Gives agents a shared workspace TODO board with blocked-task tracking.",
|
||||
capabilities: ["Task capture", "Blocked tracking", "Shared workspace state"],
|
||||
featured: true,
|
||||
editorBadge: "Claw3D test",
|
||||
hideStats: true,
|
||||
},
|
||||
};
|
||||
|
||||
const hashString = (value: string): number => {
|
||||
@@ -146,24 +159,38 @@ export const resolveSkillMarketplaceMetadata = (skill: SkillStatusEntry): SkillM
|
||||
const normalizedKey = skill.skillKey.trim().toLowerCase();
|
||||
const fallback = buildFallbackMetadata(skill);
|
||||
const override = SKILL_MARKETPLACE_OVERRIDES[normalizedKey];
|
||||
const packagedSkill = getPackagedSkillBySkillKey(skill.skillKey);
|
||||
if (!override) {
|
||||
return fallback;
|
||||
return {
|
||||
...fallback,
|
||||
poweredByName: packagedSkill?.creatorName,
|
||||
poweredByUrl: packagedSkill?.creatorUrl,
|
||||
hideStats: Boolean(packagedSkill),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...fallback,
|
||||
...override,
|
||||
capabilities: override.capabilities ?? fallback.capabilities,
|
||||
poweredByName: packagedSkill?.creatorName,
|
||||
poweredByUrl: packagedSkill?.creatorUrl,
|
||||
hideStats: override.hideStats ?? Boolean(packagedSkill),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildSkillMarketplaceEntry = (skill: SkillStatusEntry): SkillMarketplaceEntry => {
|
||||
const packagedSkill = getPackagedSkillBySkillKey(skill.skillKey);
|
||||
const missingDetails = buildSkillMissingDetails(skill);
|
||||
if (packagedSkill && !skill.baseDir.trim()) {
|
||||
missingDetails.unshift("Install this packaged Claw3D skill to make it available on the gateway.");
|
||||
}
|
||||
return {
|
||||
skill,
|
||||
readiness: deriveSkillReadinessState(skill),
|
||||
metadata: resolveSkillMarketplaceMetadata(skill),
|
||||
installable: hasInstallableMissingBinary(skill),
|
||||
removable: canRemoveSkill(skill),
|
||||
missingDetails: buildSkillMissingDetails(skill),
|
||||
missingDetails,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -187,6 +214,11 @@ export const buildSkillMarketplaceCollections = (
|
||||
collections.push({ id: "featured", label: "Featured", entries: featured });
|
||||
}
|
||||
|
||||
const claw3d = entries.filter((entry) => getPackagedSkillBySkillKey(entry.skill.skillKey));
|
||||
if (claw3d.length > 0) {
|
||||
collections.push({ id: "claw3d", label: "Claw3D", entries: claw3d });
|
||||
}
|
||||
|
||||
const installed = entries.filter((entry) => entry.readiness === "ready" || entry.skill.disabled);
|
||||
if (installed.length > 0) {
|
||||
collections.push({ id: "installed", label: "Installed", entries: installed });
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
type PackagedSkillFile = {
|
||||
relativePath: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
// Keep this string synchronized with assets/skills/todo-board/SKILL.md.
|
||||
const TODO_BOARD_SKILL_MD = `---
|
||||
name: todo
|
||||
description: Maintain a shared workspace TODO list with blocked tasks.
|
||||
metadata: {"openclaw":{"skillKey":"todo-board"}}
|
||||
---
|
||||
|
||||
# TODO Board
|
||||
|
||||
Use this skill when the user wants to manage a shared task list for the current workspace.
|
||||
|
||||
## Trigger
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"activation": {
|
||||
"anyPhrases": [
|
||||
"todo",
|
||||
"todo list",
|
||||
"blocked task",
|
||||
"blocked tasks",
|
||||
"add to my todo",
|
||||
"show my todo"
|
||||
]
|
||||
},
|
||||
"movement": {
|
||||
"target": "desk",
|
||||
"skipIfAlreadyThere": true
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
When this skill is activated, the agent should return to its assigned desk before handling the request.
|
||||
|
||||
- If the user asks from Telegram or any other external surface to add, block, unblock, remove, or read TODO items, treat that as a trigger for this skill.
|
||||
- The physical behavior for this skill is: go sit at the assigned desk, then perform the TODO board workflow.
|
||||
- If the agent is already at the desk, continue without adding extra movement narration.
|
||||
|
||||
## Storage location
|
||||
|
||||
The authoritative task file is \`todo-skill/todo-list.json\` in the workspace root.
|
||||
|
||||
- Always treat that file as the source of truth.
|
||||
- Never rely on chat memory alone for the latest task state.
|
||||
- Create the \`todo-skill\` directory and \`todo-list.json\` file if they do not exist.
|
||||
|
||||
## Required workflow
|
||||
|
||||
1. Read \`todo-skill/todo-list.json\` before answering any task-management request.
|
||||
2. If the file does not exist, create it with the schema in this document before continuing.
|
||||
3. After every add, remove, block, or unblock action, write the full updated JSON back to disk.
|
||||
4. If the file exists but is invalid JSON or does not match the schema, repair it into a valid structure, preserve any recoverable items, and mention that repair in your response.
|
||||
5. If the user request is ambiguous, ask a clarifying question instead of guessing.
|
||||
|
||||
## Supported actions
|
||||
|
||||
- Add a task.
|
||||
Create a new item unless an equivalent active item already exists.
|
||||
- Block a task.
|
||||
Change the matching item to \`status: "blocked"\`. If the task does not exist and the request is clear, create it directly as blocked.
|
||||
- Unblock a task.
|
||||
Change the matching item back to \`status: "todo"\` and clear \`blockReason\`.
|
||||
- Remove a task.
|
||||
Delete only the matching item. If multiple items could match, ask for clarification.
|
||||
- Read the list.
|
||||
Summarize tasks grouped into \`TODO\` and \`BLOCKED\`.
|
||||
|
||||
## File format
|
||||
|
||||
Use this JSON shape:
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"version": 1,
|
||||
"updatedAt": "2026-03-22T00:00:00.000Z",
|
||||
"items": [
|
||||
{
|
||||
"id": "task-1",
|
||||
"title": "Example task",
|
||||
"status": "todo",
|
||||
"createdAt": "2026-03-22T00:00:00.000Z",
|
||||
"updatedAt": "2026-03-22T00:00:00.000Z",
|
||||
"blockReason": null
|
||||
}
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## Field rules
|
||||
|
||||
- Keep \`version\` at \`1\`.
|
||||
- Generate stable, human-readable IDs such as \`prepare-demo\` or \`task-2\`.
|
||||
- Keep titles concise and preserve the user's intent.
|
||||
- Use only \`todo\` or \`blocked\` for \`status\`.
|
||||
- Use ISO timestamps for \`createdAt\`, item \`updatedAt\`, and top-level \`updatedAt\`.
|
||||
- Keep \`blockReason\` as \`null\` unless the user gave a reason or a short precise reason is clearly implied.
|
||||
|
||||
## Mutation rules
|
||||
|
||||
- Avoid duplicate active items that describe the same work.
|
||||
- Preserve existing IDs and \`createdAt\` values for unchanged items.
|
||||
- Update the touched item's \`updatedAt\` whenever you modify it.
|
||||
- Update the top-level \`updatedAt\` on every write.
|
||||
- Keep untouched items in their original order unless there is a strong reason to reorder them.
|
||||
|
||||
## Response style
|
||||
|
||||
- After each mutation, say what changed.
|
||||
- When showing the list, group tasks into \`TODO\` and \`BLOCKED\`.
|
||||
- Include each blocked task's reason when one exists.
|
||||
`;
|
||||
|
||||
// Keep this string synchronized with assets/skills/todo-board/todo-list.example.json.
|
||||
const TODO_BOARD_EXAMPLE_JSON = `{
|
||||
"version": 1,
|
||||
"updatedAt": "2026-03-22T00:00:00.000Z",
|
||||
"items": [
|
||||
{
|
||||
"id": "draft-roadmap",
|
||||
"title": "Draft the TODO skill roadmap",
|
||||
"status": "todo",
|
||||
"createdAt": "2026-03-22T00:00:00.000Z",
|
||||
"updatedAt": "2026-03-22T00:00:00.000Z",
|
||||
"blockReason": null
|
||||
},
|
||||
{
|
||||
"id": "gateway-access",
|
||||
"title": "Confirm gateway install access",
|
||||
"status": "blocked",
|
||||
"createdAt": "2026-03-22T00:00:00.000Z",
|
||||
"updatedAt": "2026-03-22T00:00:00.000Z",
|
||||
"blockReason": "Waiting for gateway credentials"
|
||||
}
|
||||
]
|
||||
}
|
||||
`;
|
||||
|
||||
const PACKAGED_SKILL_FILES: Record<string, PackagedSkillFile[]> = {
|
||||
"todo-board": [
|
||||
{
|
||||
relativePath: "SKILL.md",
|
||||
content: TODO_BOARD_SKILL_MD,
|
||||
},
|
||||
{
|
||||
relativePath: "todo-list.example.json",
|
||||
content: TODO_BOARD_EXAMPLE_JSON,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const readPackagedSkillFiles = (packageId: string): PackagedSkillFile[] => {
|
||||
const files = PACKAGED_SKILL_FILES[packageId];
|
||||
if (!files || files.length === 0) {
|
||||
throw new Error(`Packaged skill assets are missing: ${packageId}`);
|
||||
}
|
||||
if (!files.some((file) => file.relativePath === "SKILL.md")) {
|
||||
throw new Error(`Packaged skill is missing SKILL.md: ${packageId}`);
|
||||
}
|
||||
return files.map((file) => ({ ...file }));
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import { buildAgentMainSessionKey, type GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import {
|
||||
removeGatewayAgentFromConfigOnly,
|
||||
updateGatewayAgentOverrides,
|
||||
} from "@/lib/gateway/agentConfig";
|
||||
import type { SkillRemoveRequest, SkillRemoveResult } from "@/lib/skills/types";
|
||||
|
||||
const normalizeRequired = (value: string, field: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error(`${field} is required.`);
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const escapeForJsonString = (value: string) => JSON.stringify(value);
|
||||
|
||||
const resolveRunId = (payload: unknown): string => {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
throw new Error("Gateway returned an invalid chat.send response.");
|
||||
}
|
||||
const record = payload as Record<string, unknown>;
|
||||
const runId = typeof record.runId === "string" ? record.runId.trim() : "";
|
||||
if (!runId) {
|
||||
throw new Error("Gateway returned an invalid chat.send response (missing runId).");
|
||||
}
|
||||
return runId;
|
||||
};
|
||||
|
||||
const resolveMainKey = async (client: GatewayClient): Promise<string> => {
|
||||
const result = (await client.call("agents.list", {})) as { mainKey?: unknown };
|
||||
return typeof result?.mainKey === "string" && result.mainKey.trim() ? result.mainKey.trim() : "main";
|
||||
};
|
||||
|
||||
const buildSkillRemovalMessage = (params: {
|
||||
baseDir: string;
|
||||
allowedRoot: string;
|
||||
}) => {
|
||||
return [
|
||||
"Delete exactly one installed skill directory from the current workspace context.",
|
||||
"You may use the runtime tools or file tools.",
|
||||
`Target directory: ${escapeForJsonString(params.baseDir)}`,
|
||||
`Allowed root: ${escapeForJsonString(params.allowedRoot)}`,
|
||||
"",
|
||||
"Rules:",
|
||||
"1. Refuse to operate outside the allowed root.",
|
||||
"2. Refuse to delete the allowed root directory itself.",
|
||||
"3. If the target directory exists, verify it contains SKILL.md before deleting it.",
|
||||
"4. If the target directory does not exist, reply only with: REMOVED_ALREADY",
|
||||
"5. If deletion succeeds, reply only with: REMOVED",
|
||||
"6. Do not modify any other files or directories.",
|
||||
].join("\n");
|
||||
};
|
||||
|
||||
const resolveRemovalWorkspace = (request: SkillRemoveRequest): string => {
|
||||
return request.source === "openclaw-managed" ? request.managedSkillsDir : request.workspaceDir;
|
||||
};
|
||||
|
||||
const resolveAllowedRoot = (request: SkillRemoveRequest): string => {
|
||||
return request.source === "openclaw-managed"
|
||||
? request.managedSkillsDir
|
||||
: `${request.workspaceDir.replace(/[\\/]+$/, "")}/skills`;
|
||||
};
|
||||
|
||||
export const removeSkillViaGatewayAgent = async (params: {
|
||||
client: GatewayClient;
|
||||
request: SkillRemoveRequest;
|
||||
}): Promise<SkillRemoveResult> => {
|
||||
const skillKey = normalizeRequired(params.request.skillKey, "skillKey");
|
||||
const source = params.request.source;
|
||||
const baseDir = normalizeRequired(params.request.baseDir, "baseDir");
|
||||
const workspaceDir = normalizeRequired(params.request.workspaceDir, "workspaceDir");
|
||||
const managedSkillsDir = normalizeRequired(params.request.managedSkillsDir, "managedSkillsDir");
|
||||
const workspace = resolveRemovalWorkspace({
|
||||
...params.request,
|
||||
skillKey,
|
||||
baseDir,
|
||||
workspaceDir,
|
||||
managedSkillsDir,
|
||||
});
|
||||
const allowedRoot = resolveAllowedRoot({
|
||||
...params.request,
|
||||
skillKey,
|
||||
baseDir,
|
||||
workspaceDir,
|
||||
managedSkillsDir,
|
||||
});
|
||||
const removerName = `Skill Remover ${Date.now()}`;
|
||||
|
||||
let removerAgentId: string | null = null;
|
||||
try {
|
||||
const created = (await params.client.call("agents.create", {
|
||||
name: removerName,
|
||||
workspace,
|
||||
})) as { agentId?: unknown };
|
||||
removerAgentId = typeof created?.agentId === "string" ? created.agentId.trim() : "";
|
||||
if (!removerAgentId) {
|
||||
throw new Error("Gateway returned an invalid agents.create response (missing agentId).");
|
||||
}
|
||||
|
||||
await updateGatewayAgentOverrides({
|
||||
client: params.client,
|
||||
agentId: removerAgentId,
|
||||
overrides: {
|
||||
tools: {
|
||||
alsoAllow: ["group:runtime", "group:fs"],
|
||||
deny: ["group:web"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mainKey = await resolveMainKey(params.client);
|
||||
const sessionKey = buildAgentMainSessionKey(removerAgentId, mainKey);
|
||||
const sendResult = await params.client.call("chat.send", {
|
||||
sessionKey,
|
||||
message: buildSkillRemovalMessage({ baseDir, allowedRoot }),
|
||||
deliver: false,
|
||||
idempotencyKey: `skill-remove:${skillKey}:${Date.now()}`,
|
||||
});
|
||||
const runId = resolveRunId(sendResult);
|
||||
await params.client.call("agent.wait", { runId, timeoutMs: 60_000 });
|
||||
|
||||
return {
|
||||
removed: true,
|
||||
removedPath: baseDir,
|
||||
source,
|
||||
};
|
||||
} finally {
|
||||
if (removerAgentId) {
|
||||
try {
|
||||
await removeGatewayAgentFromConfigOnly({
|
||||
client: params.client,
|
||||
agentId: removerAgentId,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup for temporary remover agents.
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
+11
-13
@@ -1,4 +1,5 @@
|
||||
import { fetchJson } from "@/lib/http";
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import { removeSkillViaGatewayAgent } from "@/lib/skills/remove-gateway";
|
||||
import type { SkillRemoveRequest, SkillRemoveResult } from "@/lib/skills/types";
|
||||
|
||||
const normalizeRequired = (value: string, field: string): string => {
|
||||
@@ -10,20 +11,17 @@ const normalizeRequired = (value: string, field: string): string => {
|
||||
};
|
||||
|
||||
export const removeSkillFromGateway = async (
|
||||
request: SkillRemoveRequest
|
||||
params: { client: GatewayClient } & SkillRemoveRequest
|
||||
): Promise<SkillRemoveResult> => {
|
||||
const payload: SkillRemoveRequest = {
|
||||
skillKey: normalizeRequired(request.skillKey, "skillKey"),
|
||||
source: request.source,
|
||||
baseDir: normalizeRequired(request.baseDir, "baseDir"),
|
||||
workspaceDir: normalizeRequired(request.workspaceDir, "workspaceDir"),
|
||||
managedSkillsDir: normalizeRequired(request.managedSkillsDir, "managedSkillsDir"),
|
||||
skillKey: normalizeRequired(params.skillKey, "skillKey"),
|
||||
source: params.source,
|
||||
baseDir: normalizeRequired(params.baseDir, "baseDir"),
|
||||
workspaceDir: normalizeRequired(params.workspaceDir, "workspaceDir"),
|
||||
managedSkillsDir: normalizeRequired(params.managedSkillsDir, "managedSkillsDir"),
|
||||
};
|
||||
|
||||
const response = await fetchJson<{ result: SkillRemoveResult }>("/api/gateway/skills/remove", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
return removeSkillViaGatewayAgent({
|
||||
client: params.client,
|
||||
request: payload,
|
||||
});
|
||||
return response.result;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { TranscriptEntry } from "@/features/agents/state/transcript";
|
||||
import {
|
||||
DEFAULT_SKILL_TRIGGER_FALLBACKS_BY_SKILL_KEY,
|
||||
isOfficeSkillTriggerMovementTarget,
|
||||
type OfficeSkillTriggerMovementTarget,
|
||||
} from "@/lib/office/places";
|
||||
import { listPackagedSkills } from "@/lib/skills/catalog";
|
||||
import { readPackagedSkillFiles } from "@/lib/skills/packaged";
|
||||
|
||||
type SkillTriggerJsonShape = {
|
||||
activation?: {
|
||||
anyPhrases?: unknown;
|
||||
};
|
||||
movement?: {
|
||||
target?: unknown;
|
||||
skipIfAlreadyThere?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type SkillTriggerDefinition = {
|
||||
packageId: string;
|
||||
skillKey: string;
|
||||
skillName: string;
|
||||
activationPhrases: string[];
|
||||
movementTarget: OfficeSkillTriggerMovementTarget;
|
||||
skipIfAlreadyThere: boolean;
|
||||
};
|
||||
|
||||
const TRIGGER_SECTION_RE = /##\s+Trigger\s*([\s\S]*?)(?:\n##\s+|\s*$)/i;
|
||||
const JSON_CODE_BLOCK_RE = /```json\s*([\s\S]*?)```/i;
|
||||
|
||||
const normalizePhrase = (value: string): string => value.trim().toLowerCase().replace(/\s+/g, " ");
|
||||
|
||||
const normalizeMessage = (value: string | null | undefined): string =>
|
||||
normalizePhrase(value ?? "");
|
||||
|
||||
const extractTriggerJson = (markdown: string): SkillTriggerJsonShape | null => {
|
||||
const triggerSection = markdown.match(TRIGGER_SECTION_RE)?.[1] ?? "";
|
||||
if (!triggerSection) {
|
||||
return null;
|
||||
}
|
||||
const jsonBlock = triggerSection.match(JSON_CODE_BLOCK_RE)?.[1]?.trim() ?? "";
|
||||
if (!jsonBlock) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(jsonBlock) as SkillTriggerJsonShape;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const parseSkillTriggerDefinition = (params: {
|
||||
packageId: string;
|
||||
skillKey: string;
|
||||
skillName: string;
|
||||
markdown: string;
|
||||
}): SkillTriggerDefinition | null => {
|
||||
const parsed = extractTriggerJson(params.markdown);
|
||||
const fallback = DEFAULT_SKILL_TRIGGER_FALLBACKS_BY_SKILL_KEY[params.skillKey];
|
||||
if (!parsed && !fallback) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activationPhrases = Array.isArray(parsed?.activation?.anyPhrases)
|
||||
? Array.from(
|
||||
new Set(
|
||||
parsed.activation!.anyPhrases
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
.map(normalizePhrase)
|
||||
.filter((value) => value.length > 0),
|
||||
),
|
||||
)
|
||||
: fallback?.anyPhrases.map(normalizePhrase) ?? [];
|
||||
const movementTarget = parsed?.movement?.target;
|
||||
const resolvedMovementTarget = isOfficeSkillTriggerMovementTarget(movementTarget)
|
||||
? movementTarget
|
||||
: fallback?.movementTarget;
|
||||
if (activationPhrases.length === 0 || !resolvedMovementTarget) {
|
||||
return null;
|
||||
}
|
||||
const skipIfAlreadyThere =
|
||||
typeof parsed?.movement?.skipIfAlreadyThere === "boolean"
|
||||
? parsed.movement.skipIfAlreadyThere
|
||||
: fallback?.skipIfAlreadyThere ?? true;
|
||||
|
||||
return {
|
||||
packageId: params.packageId,
|
||||
skillKey: params.skillKey,
|
||||
skillName: params.skillName,
|
||||
activationPhrases,
|
||||
movementTarget: resolvedMovementTarget,
|
||||
skipIfAlreadyThere,
|
||||
};
|
||||
};
|
||||
|
||||
let packagedSkillTriggerCache: SkillTriggerDefinition[] | null = null;
|
||||
|
||||
export const listPackagedSkillTriggerDefinitions = (): SkillTriggerDefinition[] => {
|
||||
if (packagedSkillTriggerCache) {
|
||||
return packagedSkillTriggerCache.map((entry) => ({
|
||||
...entry,
|
||||
activationPhrases: [...entry.activationPhrases],
|
||||
}));
|
||||
}
|
||||
|
||||
const triggers: SkillTriggerDefinition[] = [];
|
||||
for (const skill of listPackagedSkills()) {
|
||||
const skillFile = readPackagedSkillFiles(skill.packageId).find(
|
||||
(file) => file.relativePath === "SKILL.md",
|
||||
);
|
||||
if (!skillFile) {
|
||||
continue;
|
||||
}
|
||||
const trigger = parseSkillTriggerDefinition({
|
||||
packageId: skill.packageId,
|
||||
skillKey: skill.skillKey,
|
||||
skillName: skill.name,
|
||||
markdown: skillFile.content,
|
||||
});
|
||||
if (trigger) {
|
||||
triggers.push(trigger);
|
||||
}
|
||||
}
|
||||
|
||||
packagedSkillTriggerCache = triggers;
|
||||
return triggers.map((entry) => ({
|
||||
...entry,
|
||||
activationPhrases: [...entry.activationPhrases],
|
||||
}));
|
||||
};
|
||||
|
||||
export const resolveTriggeredSkillDefinition = (params: {
|
||||
isAgentRunning: boolean;
|
||||
lastUserMessage: string | null | undefined;
|
||||
transcriptEntries: TranscriptEntry[] | undefined;
|
||||
triggers: SkillTriggerDefinition[];
|
||||
}): SkillTriggerDefinition | null => {
|
||||
if (!params.isAgentRunning || params.triggers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates: string[] = [];
|
||||
const latestMessage = params.lastUserMessage?.trim() ?? "";
|
||||
if (latestMessage) {
|
||||
candidates.push(latestMessage);
|
||||
}
|
||||
if (Array.isArray(params.transcriptEntries)) {
|
||||
for (let index = params.transcriptEntries.length - 1; index >= 0; index -= 1) {
|
||||
const entry = params.transcriptEntries[index];
|
||||
if (!entry || entry.role !== "user") {
|
||||
continue;
|
||||
}
|
||||
const text = entry.text.trim();
|
||||
if (text) {
|
||||
candidates.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const normalizedCandidate = normalizeMessage(candidate);
|
||||
let bestMatch: { trigger: SkillTriggerDefinition; phraseLength: number } | null = null;
|
||||
for (const trigger of params.triggers) {
|
||||
for (const phrase of trigger.activationPhrases) {
|
||||
if (!normalizedCandidate.includes(phrase)) {
|
||||
continue;
|
||||
}
|
||||
if (!bestMatch || phrase.length > bestMatch.phraseLength) {
|
||||
bestMatch = {
|
||||
trigger,
|
||||
phraseLength: phrase.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bestMatch) {
|
||||
return bestMatch.trigger;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -90,6 +90,20 @@ export type SkillRemoveResult = {
|
||||
source: RemovableSkillSource;
|
||||
};
|
||||
|
||||
export type PackagedSkillInstallRequest = {
|
||||
packageId: string;
|
||||
source: RemovableSkillSource;
|
||||
workspaceDir: string;
|
||||
managedSkillsDir: string;
|
||||
};
|
||||
|
||||
export type PackagedSkillInstallResult = {
|
||||
installed: boolean;
|
||||
installedPath: string;
|
||||
source: RemovableSkillSource;
|
||||
skillKey: string;
|
||||
};
|
||||
|
||||
const resolveAgentId = (agentId: string): string => {
|
||||
const trimmed = agentId.trim();
|
||||
if (!trimmed) {
|
||||
|
||||
@@ -3,6 +3,8 @@ import * as childProcess from "node:child_process";
|
||||
|
||||
const SSH_TARGET_ENV = "OPENCLAW_GATEWAY_SSH_TARGET";
|
||||
const SSH_USER_ENV = "OPENCLAW_GATEWAY_SSH_USER";
|
||||
const SSH_PORT_ENV = "OPENCLAW_GATEWAY_SSH_PORT";
|
||||
const SSH_STRICT_HOST_KEY_ENV = "OPENCLAW_GATEWAY_SSH_STRICT_HOST_KEY_CHECKING";
|
||||
|
||||
export const resolveConfiguredSshTarget = (env: NodeJS.ProcessEnv = process.env): string | null => {
|
||||
const configuredTarget = env[SSH_TARGET_ENV]?.trim() ?? "";
|
||||
@@ -17,6 +19,50 @@ export const resolveConfiguredSshTarget = (env: NodeJS.ProcessEnv = process.env)
|
||||
return null;
|
||||
};
|
||||
|
||||
export const resolveConfiguredSshPort = (env: NodeJS.ProcessEnv = process.env): number | null => {
|
||||
const rawPort = env[SSH_PORT_ENV]?.trim() ?? "";
|
||||
if (!rawPort) {
|
||||
return null;
|
||||
}
|
||||
const port = Number.parseInt(rawPort, 10);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
||||
throw new Error(`${SSH_PORT_ENV} must be a valid port.`);
|
||||
}
|
||||
return port;
|
||||
};
|
||||
|
||||
export const resolveConfiguredSshStrictHostKeyChecking = (
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): "accept-new" | "yes" | "no" => {
|
||||
const rawValue = env[SSH_STRICT_HOST_KEY_ENV]?.trim().toLowerCase() ?? "";
|
||||
if (!rawValue) {
|
||||
return "accept-new";
|
||||
}
|
||||
if (rawValue === "accept-new" || rawValue === "yes" || rawValue === "no") {
|
||||
return rawValue;
|
||||
}
|
||||
throw new Error(
|
||||
`${SSH_STRICT_HOST_KEY_ENV} must be one of: accept-new, yes, no.`
|
||||
);
|
||||
};
|
||||
|
||||
export const resolvePortFromGatewayUrl = (gatewayUrl: string): number | null => {
|
||||
const trimmed = gatewayUrl.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (!parsed.port) {
|
||||
return null;
|
||||
}
|
||||
const port = Number.parseInt(parsed.port, 10);
|
||||
return Number.isInteger(port) && port >= 1 && port <= 65_535 ? port : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveGatewaySshTargetFromGatewayUrl = (
|
||||
gatewayUrl: string,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
@@ -86,6 +132,8 @@ export const parseJsonOutput = (raw: string, label: string): unknown => {
|
||||
|
||||
export const runSshJson = (params: {
|
||||
sshTarget: string;
|
||||
sshPort?: number | null;
|
||||
strictHostKeyChecking?: "accept-new" | "yes" | "no";
|
||||
argv: string[];
|
||||
label: string;
|
||||
input?: string;
|
||||
@@ -100,9 +148,18 @@ export const runSshJson = (params: {
|
||||
options.maxBuffer = params.maxBuffer;
|
||||
}
|
||||
|
||||
const result = childProcess.spawnSync("ssh", ["-o", "BatchMode=yes", params.sshTarget, ...params.argv], {
|
||||
...options,
|
||||
});
|
||||
const sshArgs = [
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
`StrictHostKeyChecking=${params.strictHostKeyChecking ?? resolveConfiguredSshStrictHostKeyChecking()}`,
|
||||
];
|
||||
if (typeof params.sshPort === "number") {
|
||||
sshArgs.push("-p", String(params.sshPort));
|
||||
}
|
||||
sshArgs.push(params.sshTarget, ...params.argv);
|
||||
|
||||
const result = childProcess.spawnSync("ssh", sshArgs, { ...options });
|
||||
if (result.error) {
|
||||
throw new Error(`Failed to execute ssh: ${result.error.message}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user