Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
Luke The Dev
2026-03-23 11:44:25 -05:00
committed by GitHub
parent c2cbdeec44
commit 5e7812c352
30 changed files with 2213 additions and 128 deletions
+5
View File
@@ -10,6 +10,11 @@ DEBUG=true
# Optional: required only for public/remote deployments
# STUDIO_ACCESS_TOKEN=
# Advanced only: some gateway-host operations can still use SSH, but marketplace skill install does not require it.
# OPENCLAW_GATEWAY_SSH_TARGET=
# OPENCLAW_GATEWAY_SSH_USER=
# OPENCLAW_GATEWAY_SSH_PORT=
# OPENCLAW_GATEWAY_SSH_STRICT_HOST_KEY_CHECKING=accept-new
# Optional: voice features
# ELEVENLABS_API_KEY=
+244
View File
@@ -0,0 +1,244 @@
# Creating Skills
This repository ships developer-created marketplace skills as packaged assets that Claw3D can install into an OpenClaw workspace through the gateway.
Use the existing `todo-board` skill as the reference implementation.
## Mental model
- `assets/skills/<package-id>/` is the human-friendly source layout for each packaged skill.
- `src/lib/skills/packaged.ts` contains the client-safe embedded copy of those files used by the marketplace install flow.
- `src/lib/skills/catalog.ts` registers the skill so it appears in the marketplace.
- `src/lib/skills/install-gateway.ts` installs the packaged files into the selected workspace by creating a temporary gateway agent and asking it to write the files.
## Folder structure
Follow this structure for every packaged skill:
```text
assets/
skills/
<package-id>/
SKILL.md
<optional companion files>
```
Current example:
```text
assets/
skills/
todo-board/
SKILL.md
todo-list.example.json
```
## 1. Create the skill files
Create a new folder under `assets/skills/<package-id>/`.
Required file:
- `SKILL.md`
Optional files:
- Example JSON files.
- Templates.
- Any additional files the installed skill should include.
### `SKILL.md` requirements
Your `SKILL.md` should include frontmatter, a trigger section, and clear operating instructions for the agent.
Use this pattern:
```md
---
name: my-skill
description: Short description of what the skill does.
metadata: {"openclaw":{"skillKey":"my-skill-key"}}
---
# My Skill
Explain when the skill should be used, where it stores state, how it reads and writes files, and the exact workflow rules the agent must follow.
```
Notes:
- `name` is the user-facing skill name.
- `metadata.openclaw.skillKey` must stay stable and should match the installed folder name.
- Every skill must define a `## Trigger` section that explains what activates the skill and what the agent should physically do in Claw3D when it activates.
- Write instructions as if the model will follow them directly.
- If the skill stores state, define the exact file path and schema.
- Be explicit about read-before-write, validation, ambiguity handling, and response behavior.
### Trigger requirements
Every skill must contain a trigger.
At minimum, the trigger section should define:
- What kind of user request or external event activates the skill.
- What physical behavior the agent should perform in the office when the skill starts.
- Whether that movement should be skipped when the agent is already at the right location.
Example:
````md
## Trigger
```json
{
"activation": {
"anyPhrases": [
"todo",
"todo list",
"blocked task"
]
},
"movement": {
"target": "desk",
"skipIfAlreadyThere": true
}
}
```
When this skill is activated, the agent should go to its assigned desk before handling the request.
- Treat requests from Telegram or other external channels as valid triggers when they match this skill.
- If the agent is already at the desk, continue immediately.
````
Current runtime support for `movement.target` values:
- Defined in one source of truth: `src/lib/office/places.ts`.
- Current values:
- `desk`
- `github`
- `gym`
- `qa_lab`
Important:
- The JSON block is what Claw3D parses at runtime.
- Keep the prose explanation too, but do not rely on prose alone for the trigger behavior.
- `activation.anyPhrases` should contain short, stable phrases that are likely to appear in the user request.
- If a skill has no trigger block, Claw3D can fall back to the central default trigger registry in `src/lib/office/places.ts`.
## 2. Mirror the files into `src/lib/skills/packaged.ts`
Claw3D installs packaged skills from client-safe embedded strings, not by reading `assets/skills/...` directly at runtime.
That means every new packaged skill must also be added to `src/lib/skills/packaged.ts`.
For each file in `assets/skills/<package-id>/`, add a matching entry in the packaged file map:
```ts
const PACKAGED_SKILL_FILES: Record<string, PackagedSkillFile[]> = {
"my-package-id": [
{
relativePath: "SKILL.md",
content: MY_SKILL_MD,
},
{
relativePath: "example.json",
content: MY_EXAMPLE_JSON,
},
],
};
```
Important:
- Keep the embedded strings exactly synchronized with the asset files.
- Do not change spacing, frontmatter, or filenames between the asset copy and packaged copy.
- `tests/unit/packagedSkills.test.ts` exists to catch drift for the current example. Extend it when you add more packaged skills.
## 3. Register the skill in `src/lib/skills/catalog.ts`
Add a `PackagedSkillDefinition` entry:
```ts
{
packageId: "my-package-id",
skillKey: "my-skill-key",
name: "my-skill",
description: "Short description.",
installSource: "openclaw-workspace",
creatorName: "your-handle",
creatorUrl: "https://x.com/your-handle/",
}
```
Field meanings:
- `packageId`: internal packaged asset ID, usually the folder name under `assets/skills/`.
- `skillKey`: the OpenClaw skill key and installed folder name.
- `name`: the human-facing skill name shown in the UI.
- `installSource`: where the skill is installed. For current packaged skills this should be `"openclaw-workspace"`.
- `creatorName` and `creatorUrl`: shown as `Powered by ...` in the marketplace.
## 4. Add marketplace presentation metadata
If you want custom category, tagline, badges, or capability labels, add an override in `src/lib/skills/marketplace.ts`.
Example fields:
- `category`
- `tagline`
- `capabilities`
- `editorBadge`
- `hideStats`
For packaged skills, creator attribution normally comes from `src/lib/skills/catalog.ts`.
For developer-created packaged skills, prefer real attribution over fake popularity numbers.
## 5. Understand where the files get installed
The current packaged install flow writes files into the selected workspace here:
```text
<workspace>/skills/<skillKey>/
```
For the TODO example, that becomes:
```text
<workspace>/skills/todo-board/
SKILL.md
todo-list.example.json
```
The skill itself can then manage additional workspace files such as:
```text
<workspace>/todo-skill/todo-list.json
```
That state file is runtime data created by the skill instructions. It is separate from the installed skill package.
## 6. Keep the example production-ready
Use the `todo-board` example as the quality bar:
- The skill must define a clear trigger and physical office behavior.
- The instructions should be explicit and deterministic.
- State storage should be file-backed and documented.
- Ambiguous requests should force clarification instead of guessing.
- The installed package should contain only the files needed by the skill.
- Marketplace metadata should be honest and attributed.
## 7. Verify your changes
After creating or editing a packaged skill, run:
```bash
npm test -- tests/unit/packagedSkills.test.ts tests/unit/skillsInstallGateway.test.ts
npm run lint
npm run typecheck
```
If you add new packaged skills, update or extend the packaged skill tests so asset files, embedded copies, and marketplace metadata stay aligned.
+11 -1
View File
@@ -159,7 +159,7 @@ Common environment variables:
- `STUDIO_ACCESS_TOKEN` protects Studio when binding to a public host.
- `NEXT_PUBLIC_GATEWAY_URL` provides the default upstream gateway URL when Studio settings are empty.
- `OPENCLAW_STATE_DIR` and `OPENCLAW_CONFIG_PATH` override the default OpenClaw paths.
- `OPENCLAW_GATEWAY_SSH_TARGET` and `OPENCLAW_GATEWAY_SSH_USER` support gateway-host operations over SSH.
- `OPENCLAW_GATEWAY_SSH_TARGET`, `OPENCLAW_GATEWAY_SSH_USER`, `OPENCLAW_GATEWAY_SSH_PORT`, and `OPENCLAW_GATEWAY_SSH_STRICT_HOST_KEY_CHECKING` support advanced gateway-host operations over SSH when needed.
- `ELEVENLABS_API_KEY`, `ELEVENLABS_VOICE_ID`, and `ELEVENLABS_MODEL_ID` enable voice reply integration.
See [`.env.example`](.env.example) for the full local development template.
@@ -202,6 +202,16 @@ If the UI loads but Connect fails, the problem is usually on the Studio -> Gatew
- `401 Studio access token required` usually means `STUDIO_ACCESS_TOKEN` is enabled and the request is missing the expected `studio_access` cookie.
- Helpful proxy error codes include `studio.gateway_url_missing`, `studio.gateway_token_missing`, `studio.upstream_error`, and `studio.upstream_closed`.
Marketplace skill installs now use a gateway-native workspace flow and do not require enabling SSH on the user machine.
If you use other advanced gateway-host operations over SSH:
- macOS: enable `System Settings` -> `General` -> `Sharing` -> `Remote Login`, and make sure the target user is allowed.
- Windows: enable the `OpenSSH Server` optional feature, start the `sshd` service, and allow it through the firewall.
- Linux: make sure `sshd` is installed, running, and reachable from the Studio machine.
For first-time SSH connections, Claw3D uses `StrictHostKeyChecking=accept-new` by default so a new host key can be trusted automatically. If you need stricter behavior, set `OPENCLAW_GATEWAY_SSH_STRICT_HOST_KEY_CHECKING=yes`, or set it to `no` only if you explicitly want to skip host key checks.
## Contributing
Keep pull requests focused, run `npm run lint`, `npm run typecheck`, and `npm run test` before opening a PR, and update docs when behavior or architecture changes.
+109
View File
@@ -0,0 +1,109 @@
---
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.
@@ -0,0 +1,22 @@
{
"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"
}
]
}
@@ -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,
+7 -22
View File
@@ -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,8 +51,6 @@ export function HQSidebar({
? historyPanel
: activeTab === "playbooks"
? playbooksPanel
: activeTab === "marketplace"
? marketplacePanel
: analyticsPanel;
return (
@@ -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,13 +107,11 @@ 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."}
</div>
{railOnly ? (
@@ -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">
<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>
{!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,7 +587,26 @@ 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">
{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>
@@ -532,6 +615,8 @@ export function SkillsMarketplacePanel({
<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&apos;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&apos;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,
};
+59 -20
View File
@@ -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({
const officeAnimationState = useMemo(() => {
const base = buildOfficeAnimationState({
state: officeTriggerState,
agents: state.agents,
marketplaceGymHoldByAgentId,
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"}
+2 -8
View File
@@ -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";
+26
View File
@@ -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;
+116
View File
@@ -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;
};
+74
View File
@@ -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];
};
+129
View File
@@ -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.
}
}
}
};
+34 -2
View File
@@ -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 });
+165
View File
@@ -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 }));
};
+140
View 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
View File
@@ -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;
};
+183
View File
@@ -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;
};
+14
View File
@@ -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) {
+60 -3
View File
@@ -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}`);
}
+44
View File
@@ -0,0 +1,44 @@
import { readFileSync, readdirSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { buildPackagedSkillStatusEntry, listPackagedSkills } from "@/lib/skills/catalog";
import { resolveSkillMarketplaceMetadata } from "@/lib/skills/marketplace";
import { readPackagedSkillFiles } from "@/lib/skills/packaged";
const resolveAssetDir = (packageId: string) => path.join(process.cwd(), "assets/skills", packageId);
describe("packaged skills", () => {
it("keeps packaged skill files synchronized with the asset source files", () => {
for (const packagedSkill of listPackagedSkills()) {
const assetDir = resolveAssetDir(packagedSkill.packageId);
const expectedFiles = readdirSync(assetDir, { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.sort();
const packagedFiles = readPackagedSkillFiles(packagedSkill.packageId);
const packagedRelativePaths = packagedFiles.map((file) => file.relativePath).sort();
expect(packagedRelativePaths).toEqual(expectedFiles);
for (const file of packagedFiles) {
const assetContent = readFileSync(path.join(assetDir, file.relativePath), "utf8");
expect(file.content).toBe(assetContent);
}
}
});
it("exposes creator attribution and hides fake popularity stats for packaged skills", () => {
for (const packagedSkill of listPackagedSkills()) {
expect(packagedSkill.creatorName).toBeTruthy();
expect(packagedSkill.creatorUrl).toBeTruthy();
const metadata = resolveSkillMarketplaceMetadata(buildPackagedSkillStatusEntry(packagedSkill));
expect(metadata.poweredByName).toBe(packagedSkill.creatorName);
expect(metadata.poweredByUrl).toBe(packagedSkill.creatorUrl);
expect(metadata.hideStats).toBe(true);
}
});
});
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import {
buildOfficeSkillTriggerHoldMaps,
DEFAULT_SKILL_TRIGGER_FALLBACKS_BY_SKILL_KEY,
OFFICE_SKILL_TRIGGER_PLACE_REGISTRY,
} from "@/lib/office/places";
import {
listPackagedSkillTriggerDefinitions,
resolveTriggeredSkillDefinition,
} from "@/lib/skills/triggers";
describe("skill triggers", () => {
it("parses packaged skill trigger definitions from SKILL.md", () => {
const todoTrigger = listPackagedSkillTriggerDefinitions().find(
(entry) => entry.skillKey === "todo-board",
);
expect(todoTrigger).not.toBeUndefined();
expect(todoTrigger?.movementTarget).toBe("desk");
expect(todoTrigger?.activationPhrases).toContain("todo");
expect(todoTrigger?.activationPhrases).toContain("blocked tasks");
});
it("matches the running agent's latest request against enabled skill triggers", () => {
const todoTrigger = listPackagedSkillTriggerDefinitions().find(
(entry) => entry.skillKey === "todo-board",
);
const matched = resolveTriggeredSkillDefinition({
isAgentRunning: true,
lastUserMessage: "On telegram, add this to my todo list.",
transcriptEntries: [],
triggers: todoTrigger ? [todoTrigger] : [],
});
expect(matched?.skillKey).toBe("todo-board");
expect(matched?.movementTarget).toBe("desk");
});
it("does not match triggers when the agent is not running", () => {
const todoTrigger = listPackagedSkillTriggerDefinitions().find(
(entry) => entry.skillKey === "todo-board",
);
const matched = resolveTriggeredSkillDefinition({
isAgentRunning: false,
lastUserMessage: "Add this to my todo list.",
transcriptEntries: [],
triggers: todoTrigger ? [todoTrigger] : [],
});
expect(matched).toBeNull();
});
it("keeps trigger places and fallback definitions in one central registry", () => {
expect(OFFICE_SKILL_TRIGGER_PLACE_REGISTRY.desk.interactionTarget).toBe("desk");
expect(OFFICE_SKILL_TRIGGER_PLACE_REGISTRY.github.interactionTarget).toBe("server_room");
expect(DEFAULT_SKILL_TRIGGER_FALLBACKS_BY_SKILL_KEY["todo-board"]?.movementTarget).toBe("desk");
});
it("builds animation hold maps from the central place registry", () => {
const holdMaps = buildOfficeSkillTriggerHoldMaps({
"agent-a": "desk",
"agent-b": "github",
"agent-c": "gym",
"agent-d": "qa_lab",
});
expect(holdMaps.deskHoldByAgentId).toEqual({ "agent-a": true });
expect(holdMaps.githubHoldByAgentId).toEqual({ "agent-b": true });
expect(holdMaps.gymHoldByAgentId).toEqual({ "agent-c": true });
expect(holdMaps.qaHoldByAgentId).toEqual({ "agent-d": true });
expect(holdMaps.skillGymHoldByAgentId).toEqual({ "agent-c": true });
});
});
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
import { installPackagedSkillViaGatewayAgent } from "@/lib/skills/install-gateway";
describe("skills install gateway", () => {
it("creates a temporary installer agent and installs a workspace skill", async () => {
const call = vi.fn(async (method: string) => {
if (method === "agents.create") {
return { agentId: "installer-1" };
}
if (method === "config.get") {
return {
exists: true,
hash: "hash-1",
config: {
agents: {
list: [{ id: "installer-1", tools: {} }],
},
},
};
}
if (method === "config.set") {
return { ok: true };
}
if (method === "config.patch") {
return { ok: true };
}
if (method === "agents.list") {
return { mainKey: "main" };
}
if (method === "chat.send") {
return { runId: "run-1", status: "started" };
}
if (method === "agent.wait") {
return { ok: true };
}
throw new Error(`Unexpected method: ${method}`);
});
const result = await installPackagedSkillViaGatewayAgent({
client: { call } as unknown as GatewayClient,
request: {
packageId: "todo-board",
source: "openclaw-workspace",
workspaceDir: "/home/openclaw/workspace-demo",
managedSkillsDir: "/home/openclaw/.openclaw/skills",
},
});
expect(result).toEqual({
installed: true,
installedPath: "/home/openclaw/workspace-demo/skills/todo-board",
source: "openclaw-workspace",
skillKey: "todo-board",
});
expect(call).toHaveBeenCalledWith("agents.create", {
name: expect.stringContaining("Skill Installer"),
workspace: "/home/openclaw/workspace-demo",
});
expect(call).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
sessionKey: "agent:installer-1:main",
deliver: false,
})
);
expect(call).toHaveBeenCalledWith("agent.wait", { runId: "run-1", timeoutMs: 60_000 });
expect(call).toHaveBeenCalledWith(
"config.patch",
expect.objectContaining({
baseHash: "hash-1",
})
);
});
it("cleans up the temporary installer agent when install fails", async () => {
const call = vi.fn(async (method: string) => {
if (method === "agents.create") {
return { agentId: "installer-2" };
}
if (method === "config.get") {
return {
exists: true,
hash: "hash-2",
config: {
agents: {
list: [{ id: "installer-2", tools: {} }],
},
},
};
}
if (method === "config.set") {
return { ok: true };
}
if (method === "agents.list") {
return { mainKey: "main" };
}
if (method === "chat.send") {
throw new Error("chat failed");
}
if (method === "config.patch") {
return { ok: true };
}
throw new Error(`Unexpected method: ${method}`);
});
await expect(
installPackagedSkillViaGatewayAgent({
client: { call } as unknown as GatewayClient,
request: {
packageId: "todo-board",
source: "openclaw-workspace",
workspaceDir: "/home/openclaw/workspace-demo",
managedSkillsDir: "/home/openclaw/.openclaw/skills",
},
})
).rejects.toThrow("chat failed");
expect(call).toHaveBeenCalledWith(
"config.patch",
expect.objectContaining({
baseHash: "hash-2",
})
);
});
});
+15 -16
View File
@@ -1,28 +1,26 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { removeSkillFromGateway } from "@/lib/skills/remove";
import { removeSkillViaGatewayAgent } from "@/lib/skills/remove-gateway";
vi.mock("@/lib/skills/remove-gateway", () => ({
removeSkillViaGatewayAgent: vi.fn(),
}));
describe("skills remove client", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("posts skill removal payload to the Studio API route", async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
text: async () =>
JSON.stringify({
result: {
it("delegates sanitized skill removal payloads to the gateway-native remover", async () => {
vi.mocked(removeSkillViaGatewayAgent).mockResolvedValueOnce({
removed: true,
removedPath: "/tmp/workspace/skills/github",
source: "openclaw-workspace",
},
}),
}));
vi.stubGlobal("fetch", fetchMock);
});
const result = await removeSkillFromGateway({
client: { call: vi.fn() } as never,
skillKey: " github ",
source: "openclaw-workspace",
baseDir: " /tmp/workspace/skills/github ",
@@ -30,16 +28,15 @@ describe("skills remove client", () => {
managedSkillsDir: " /tmp/managed ",
});
expect(fetchMock).toHaveBeenCalledWith("/api/gateway/skills/remove", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
expect(removeSkillViaGatewayAgent).toHaveBeenCalledWith({
client: expect.any(Object),
request: {
skillKey: "github",
source: "openclaw-workspace",
baseDir: "/tmp/workspace/skills/github",
workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/managed",
}),
},
});
expect(result).toEqual({
removed: true,
@@ -51,6 +48,7 @@ describe("skills remove client", () => {
it("fails fast when required payload fields are missing", async () => {
await expect(
removeSkillFromGateway({
client: { call: vi.fn() } as never,
skillKey: " ",
source: "openclaw-workspace",
baseDir: "/tmp/workspace/skills/github",
@@ -61,6 +59,7 @@ describe("skills remove client", () => {
await expect(
removeSkillFromGateway({
client: { call: vi.fn() } as never,
skillKey: "github",
source: "openclaw-workspace",
baseDir: " ",
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
import { removeSkillViaGatewayAgent } from "@/lib/skills/remove-gateway";
describe("skills remove gateway", () => {
it("creates a temporary remover agent and removes a workspace skill", async () => {
const call = vi.fn(async (method: string) => {
if (method === "agents.create") {
return { agentId: "remover-1" };
}
if (method === "config.get") {
return {
exists: true,
hash: "hash-1",
config: {
agents: {
list: [{ id: "remover-1", tools: {} }],
},
},
};
}
if (method === "config.set") {
return { ok: true };
}
if (method === "config.patch") {
return { ok: true };
}
if (method === "agents.list") {
return { mainKey: "main" };
}
if (method === "chat.send") {
return { runId: "run-1", status: "started" };
}
if (method === "agent.wait") {
return { ok: true };
}
throw new Error(`Unexpected method: ${method}`);
});
const result = await removeSkillViaGatewayAgent({
client: { call } as unknown as GatewayClient,
request: {
skillKey: "todo-board",
source: "openclaw-workspace",
baseDir: "/home/openclaw/workspace-demo/skills/todo-board",
workspaceDir: "/home/openclaw/workspace-demo",
managedSkillsDir: "/home/openclaw/.openclaw/skills",
},
});
expect(result).toEqual({
removed: true,
removedPath: "/home/openclaw/workspace-demo/skills/todo-board",
source: "openclaw-workspace",
});
expect(call).toHaveBeenCalledWith("agents.create", {
name: expect.stringContaining("Skill Remover"),
workspace: "/home/openclaw/workspace-demo",
});
expect(call).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
sessionKey: "agent:remover-1:main",
deliver: false,
}),
);
expect(call).toHaveBeenCalledWith("agent.wait", { runId: "run-1", timeoutMs: 60_000 });
expect(call).toHaveBeenCalledWith(
"config.patch",
expect.objectContaining({
baseHash: "hash-1",
}),
);
});
it("uses the managed skills directory as workspace for managed skill removal", async () => {
const call = vi.fn(async (method: string) => {
if (method === "agents.create") {
return { agentId: "remover-2" };
}
if (method === "config.get") {
return {
exists: true,
hash: "hash-2",
config: {
agents: {
list: [{ id: "remover-2", tools: {} }],
},
},
};
}
if (method === "config.set") {
return { ok: true };
}
if (method === "config.patch") {
return { ok: true };
}
if (method === "agents.list") {
return { mainKey: "main" };
}
if (method === "chat.send") {
return { runId: "run-2", status: "started" };
}
if (method === "agent.wait") {
return { ok: true };
}
throw new Error(`Unexpected method: ${method}`);
});
await removeSkillViaGatewayAgent({
client: { call } as unknown as GatewayClient,
request: {
skillKey: "github",
source: "openclaw-managed",
baseDir: "/home/openclaw/.openclaw/skills/github",
workspaceDir: "/home/openclaw/workspace-demo",
managedSkillsDir: "/home/openclaw/.openclaw/skills",
},
});
expect(call).toHaveBeenCalledWith("agents.create", {
name: expect.stringContaining("Skill Remover"),
workspace: "/home/openclaw/.openclaw/skills",
});
});
});