Skills (#50)
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'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({
|
||||
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"}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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: " ",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user