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
|
# Optional: required only for public/remote deployments
|
||||||
# STUDIO_ACCESS_TOKEN=
|
# 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
|
# Optional: voice features
|
||||||
# ELEVENLABS_API_KEY=
|
# 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.
|
- `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.
|
- `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_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.
|
- `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.
|
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.
|
- `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`.
|
- 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
|
## 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.
|
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}
|
disabled={anySkillBusy}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const approved = window.confirm(
|
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) {
|
if (!approved) {
|
||||||
return;
|
return;
|
||||||
@@ -224,7 +224,7 @@ export const AgentSkillsSetupModal = ({
|
|||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Remove skill from gateway
|
Remove for all agents
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -932,6 +932,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
|||||||
label: `Remove ${normalizedSkillKey}`,
|
label: `Remove ${normalizedSkillKey}`,
|
||||||
run: async () => {
|
run: async () => {
|
||||||
const result = await removeSkillFromGateway({
|
const result = await removeSkillFromGateway({
|
||||||
|
client: params.client,
|
||||||
skillKey: normalizedSkillKey,
|
skillKey: normalizedSkillKey,
|
||||||
source: normalizedSource,
|
source: normalizedSource,
|
||||||
baseDir: skill.baseDir,
|
baseDir: skill.baseDir,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export type HQSidebarTab =
|
|||||||
| "inbox"
|
| "inbox"
|
||||||
| "history"
|
| "history"
|
||||||
| "playbooks"
|
| "playbooks"
|
||||||
| "marketplace"
|
|
||||||
| "analytics";
|
| "analytics";
|
||||||
|
|
||||||
type HQSidebarProps = {
|
type HQSidebarProps = {
|
||||||
@@ -15,10 +14,10 @@ type HQSidebarProps = {
|
|||||||
inboxCount: number;
|
inboxCount: number;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onTabChange: (tab: HQSidebarTab) => void;
|
onTabChange: (tab: HQSidebarTab) => void;
|
||||||
|
onOpenMarketplace: () => void;
|
||||||
inboxPanel: ReactNode;
|
inboxPanel: ReactNode;
|
||||||
historyPanel: ReactNode;
|
historyPanel: ReactNode;
|
||||||
playbooksPanel: ReactNode;
|
playbooksPanel: ReactNode;
|
||||||
marketplacePanel: ReactNode;
|
|
||||||
analyticsPanel: ReactNode;
|
analyticsPanel: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -26,7 +25,6 @@ const TAB_LABELS: Record<HQSidebarTab, string> = {
|
|||||||
inbox: "Inbox",
|
inbox: "Inbox",
|
||||||
history: "History",
|
history: "History",
|
||||||
playbooks: "Playbooks",
|
playbooks: "Playbooks",
|
||||||
marketplace: "Marketplace",
|
|
||||||
analytics: "Analytics",
|
analytics: "Analytics",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,15 +36,14 @@ export function HQSidebar({
|
|||||||
inboxCount,
|
inboxCount,
|
||||||
onToggle,
|
onToggle,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
|
onOpenMarketplace,
|
||||||
inboxPanel,
|
inboxPanel,
|
||||||
historyPanel,
|
historyPanel,
|
||||||
playbooksPanel,
|
playbooksPanel,
|
||||||
marketplacePanel,
|
|
||||||
analyticsPanel,
|
analyticsPanel,
|
||||||
}: HQSidebarProps) {
|
}: HQSidebarProps) {
|
||||||
const analyticsOnly = activeTab === "analytics";
|
const analyticsOnly = activeTab === "analytics";
|
||||||
const marketplaceOnly = activeTab === "marketplace";
|
const railOnly = analyticsOnly;
|
||||||
const railOnly = analyticsOnly || marketplaceOnly;
|
|
||||||
const activePanel =
|
const activePanel =
|
||||||
activeTab === "inbox"
|
activeTab === "inbox"
|
||||||
? inboxPanel
|
? inboxPanel
|
||||||
@@ -54,8 +51,6 @@ export function HQSidebar({
|
|||||||
? historyPanel
|
? historyPanel
|
||||||
: activeTab === "playbooks"
|
: activeTab === "playbooks"
|
||||||
? playbooksPanel
|
? playbooksPanel
|
||||||
: activeTab === "marketplace"
|
|
||||||
? marketplacePanel
|
|
||||||
: analyticsPanel;
|
: analyticsPanel;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -76,18 +71,10 @@ export function HQSidebar({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onTabChange("marketplace");
|
onOpenMarketplace();
|
||||||
if (!open) {
|
|
||||||
onToggle();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
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 ${
|
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"
|
||||||
marketplaceOnly
|
aria-label="Open marketplace"
|
||||||
? "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"
|
|
||||||
>
|
>
|
||||||
<span className="block leading-none [writing-mode:vertical-rl]">
|
<span className="block leading-none [writing-mode:vertical-rl]">
|
||||||
MARKETPLACE
|
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="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="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">
|
<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>
|
||||||
<div className="mt-1 font-mono text-[11px] text-white/45">
|
<div className="mt-1 font-mono text-[11px] text-white/45">
|
||||||
{analyticsOnly
|
{analyticsOnly
|
||||||
? "Cost, budgets, and performance intelligence."
|
? "Cost, budgets, and performance intelligence."
|
||||||
: marketplaceOnly
|
|
||||||
? "Discover, install, and enable new skills."
|
|
||||||
: "Monitor outputs, runs, and schedules."}
|
: "Monitor outputs, runs, and schedules."}
|
||||||
</div>
|
</div>
|
||||||
{railOnly ? (
|
{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;
|
type MarketplaceFilter = "all" | SkillMarketplaceCollectionId;
|
||||||
|
|
||||||
const FILTER_LABELS: Record<MarketplaceFilter, string> = {
|
const FILTER_LABELS: Record<MarketplaceFilter, string> = {
|
||||||
|
claw3d: "Claw3D",
|
||||||
all: "All",
|
all: "All",
|
||||||
featured: "Featured",
|
featured: "Featured",
|
||||||
installed: "Installed",
|
installed: "Installed",
|
||||||
@@ -99,10 +100,13 @@ export function SkillsMarketplacePanel({
|
|||||||
onOpenAgentSettings: (agentId: string) => void;
|
onOpenAgentSettings: (agentId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [activeFilter, setActiveFilter] = useState<MarketplaceFilter>("all");
|
const [activeFilter, setActiveFilter] = useState<MarketplaceFilter>("claw3d");
|
||||||
const [detailSkillKey, setDetailSkillKey] = useState<string | null>(null);
|
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 collections = useMemo(() => buildSkillMarketplaceCollections(entries), [entries]);
|
||||||
const accessMode = useMemo(
|
const accessMode = useMemo(
|
||||||
() => deriveAgentSkillsAccessMode(marketplace.skillsAllowlist),
|
() => deriveAgentSkillsAccessMode(marketplace.skillsAllowlist),
|
||||||
@@ -117,7 +121,7 @@ export function SkillsMarketplacePanel({
|
|||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
const visibleCollectionIds: SkillMarketplaceCollectionId[] =
|
const visibleCollectionIds: SkillMarketplaceCollectionId[] =
|
||||||
activeFilter === "all"
|
activeFilter === "all"
|
||||||
? ["built-in", "installed", "workspace", "extra", "other"]
|
? ["claw3d", "built-in", "installed", "workspace", "extra", "other"]
|
||||||
: [activeFilter];
|
: [activeFilter];
|
||||||
return collections
|
return collections
|
||||||
.filter((collection) => visibleCollectionIds.includes(collection.id))
|
.filter((collection) => visibleCollectionIds.includes(collection.id))
|
||||||
@@ -151,6 +155,7 @@ export function SkillsMarketplacePanel({
|
|||||||
|
|
||||||
const filterCounts = useMemo(() => {
|
const filterCounts = useMemo(() => {
|
||||||
const counts: Record<MarketplaceFilter, number> = {
|
const counts: Record<MarketplaceFilter, number> = {
|
||||||
|
claw3d: 0,
|
||||||
all: entries.length,
|
all: entries.length,
|
||||||
featured: 0,
|
featured: 0,
|
||||||
installed: 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="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">
|
<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
|
Packaged skill installs target the selected agent workspace. Global setup actions still affect
|
||||||
to the selected agent.
|
the whole gateway. Agent access controls below apply only to the selected agent.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 rounded border border-cyan-500/15 bg-white/[0.03] px-3 py-3">
|
<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.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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -327,14 +337,32 @@ export function SkillsMarketplacePanel({
|
|||||||
{entry.metadata.editorBadge ?? "Featured"}
|
{entry.metadata.editorBadge ?? "Featured"}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<span className="inline-flex items-center gap-1">
|
||||||
<Star className="h-3 w-3 text-amber-300" />
|
<Star className="h-3 w-3 text-amber-300" />
|
||||||
{formatRating(entry.metadata.rating)}
|
{formatRating(entry.metadata.rating)}
|
||||||
</span>
|
</span>
|
||||||
<span>{formatInstalls(entry.metadata.installs)} installs</span>
|
<span>{formatInstalls(entry.metadata.installs)} installs</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<span>{entry.metadata.category}</span>
|
<span>{entry.metadata.category}</span>
|
||||||
</div>
|
</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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -355,9 +383,17 @@ export function SkillsMarketplacePanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{collection.entries.map((entry) => {
|
{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 isEnabledForAgent = getAgentSkillEnabled(entry.skill.name, accessMode, allowlistSet);
|
||||||
const primaryAction =
|
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",
|
label: "Install deps",
|
||||||
run: () => void marketplace.handleInstallSkill(entry.skill),
|
run: () => void marketplace.handleInstallSkill(entry.skill),
|
||||||
@@ -411,13 +447,30 @@ export function SkillsMarketplacePanel({
|
|||||||
<Shield className="h-3 w-3 text-cyan-300" />
|
<Shield className="h-3 w-3 text-cyan-300" />
|
||||||
{entry.metadata.trustLabel}
|
{entry.metadata.trustLabel}
|
||||||
</span>
|
</span>
|
||||||
|
{!entry.metadata.hideStats ? (
|
||||||
|
<>
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<Star className="h-3 w-3 text-amber-300" />
|
<Star className="h-3 w-3 text-amber-300" />
|
||||||
{formatRating(entry.metadata.rating)}
|
{formatRating(entry.metadata.rating)}
|
||||||
</span>
|
</span>
|
||||||
<span>{formatInstalls(entry.metadata.installs)} installs</span>
|
<span>{formatInstalls(entry.metadata.installs)} installs</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<span>{entry.skill.source}</span>
|
<span>{entry.skill.source}</span>
|
||||||
</div>
|
</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 ? (
|
{entry.missingDetails.length > 0 ? (
|
||||||
<div className="mt-2 font-mono text-[10px] text-amber-100/85">
|
<div className="mt-2 font-mono text-[10px] text-amber-100/85">
|
||||||
{entry.missingDetails[0]}
|
{entry.missingDetails[0]}
|
||||||
@@ -430,6 +483,7 @@ export function SkillsMarketplacePanel({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => void marketplace.handleSetSkillEnabled(entry.skill.name, !isEnabledForAgent)}
|
onClick={() => void marketplace.handleSetSkillEnabled(entry.skill.name, !isEnabledForAgent)}
|
||||||
disabled={
|
disabled={
|
||||||
|
packageOnly ||
|
||||||
entry.readiness === "unavailable" ||
|
entry.readiness === "unavailable" ||
|
||||||
!marketplace.selectedAgentId ||
|
!marketplace.selectedAgentId ||
|
||||||
marketplace.busySkillKey === entry.skill.skillKey
|
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"
|
: "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>
|
</button>
|
||||||
|
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
<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"
|
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" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
Remove
|
Remove for all agents
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -478,6 +532,16 @@ export function SkillsMarketplacePanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -523,7 +587,26 @@ export function SkillsMarketplacePanel({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 font-mono text-[11px] text-white/75">{detailEntry.metadata.tagline}</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="rounded border border-white/8 bg-black/30 px-2 py-2">
|
||||||
<div className="text-white/35">Rating</div>
|
<div className="text-white/35">Rating</div>
|
||||||
<div className="mt-1 text-white/90">{formatRating(detailEntry.metadata.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="text-white/35">Installs</div>
|
||||||
<div className="mt-1 text-white/90">{formatInstalls(detailEntry.metadata.installs)}</div>
|
<div className="mt-1 text-white/90">{formatInstalls(detailEntry.metadata.installs)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<div className="rounded border border-white/8 bg-black/30 px-2 py-2">
|
<div className="rounded border border-white/8 bg-black/30 px-2 py-2">
|
||||||
<div className="text-white/35">Source</div>
|
<div className="text-white/35">Source</div>
|
||||||
<div className="mt-1 text-white/90">{detailEntry.skill.source}</div>
|
<div className="mt-1 text-white/90">{detailEntry.skill.source}</div>
|
||||||
@@ -574,11 +659,23 @@ export function SkillsMarketplacePanel({
|
|||||||
) : null}
|
) : 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">
|
<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
|
Packaged installs land in the selected workspace. Gateway setup changes still apply to every
|
||||||
agent's allowlist.
|
agent, and agent enablement depends on the selected agent's allowlist.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
<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 ? (
|
{detailEntry.readiness === "needs-setup" && detailEntry.installable ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -628,6 +725,10 @@ export function SkillsMarketplacePanel({
|
|||||||
</a>
|
</a>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 { readGatewayAgentSkillsAllowlist } from "@/lib/gateway/agentConfig";
|
||||||
import { isGatewayDisconnectLikeError } from "@/lib/gateway/GatewayClient";
|
import { isGatewayDisconnectLikeError } from "@/lib/gateway/GatewayClient";
|
||||||
import { setAgentSkillEnabled } from "@/lib/skills/agentAccess";
|
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 { resolvePreferredInstallOption } from "@/lib/skills/presentation";
|
||||||
import { removeSkillFromGateway } from "@/lib/skills/remove";
|
import { removeSkillFromGateway } from "@/lib/skills/remove";
|
||||||
import {
|
import {
|
||||||
@@ -50,11 +56,19 @@ export const useOfficeSkillsMarketplace = ({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busySkillKey, setBusySkillKey] = useState<string | null>(null);
|
const [busySkillKey, setBusySkillKey] = useState<string | null>(null);
|
||||||
const [message, setMessage] = useState<MarketplaceMessage | null>(null);
|
const [message, setMessage] = useState<MarketplaceMessage | null>(null);
|
||||||
|
const packagedSkillsByKey = useMemo(
|
||||||
|
() => new Map(listPackagedSkills().map((skill) => [skill.skillKey, skill])),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const selectedAgent = useMemo(
|
const selectedAgent = useMemo(
|
||||||
() => agents.find((agent) => agent.agentId === selectedAgentId) ?? null,
|
() => agents.find((agent) => agent.agentId === selectedAgentId) ?? null,
|
||||||
[agents, selectedAgentId],
|
[agents, selectedAgentId],
|
||||||
);
|
);
|
||||||
|
const marketplaceSkills = useMemo(
|
||||||
|
() => appendPackagedSkillsToMarketplace(skillsReport?.skills ?? []),
|
||||||
|
[skillsReport]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const preferred = (preferredAgentId ?? "").trim();
|
const preferred = (preferredAgentId ?? "").trim();
|
||||||
@@ -240,6 +254,36 @@ export const useOfficeSkillsMarketplace = ({
|
|||||||
[client, runSkillMutation],
|
[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(
|
const handleSetSkillGlobalEnabled = useCallback(
|
||||||
async (skillKey: string, enabled: boolean) => {
|
async (skillKey: string, enabled: boolean) => {
|
||||||
await runSkillMutation({
|
await runSkillMutation({
|
||||||
@@ -262,6 +306,7 @@ export const useOfficeSkillsMarketplace = ({
|
|||||||
successMessage: `${skill.name.trim()} removed from gateway files.`,
|
successMessage: `${skill.name.trim()} removed from gateway files.`,
|
||||||
run: async (_agentId, report) => {
|
run: async (_agentId, report) => {
|
||||||
await removeSkillFromGateway({
|
await removeSkillFromGateway({
|
||||||
|
client,
|
||||||
skillKey: skill.skillKey,
|
skillKey: skill.skillKey,
|
||||||
source: skill.source as
|
source: skill.source as
|
||||||
| "openclaw-managed"
|
| "openclaw-managed"
|
||||||
@@ -282,6 +327,8 @@ export const useOfficeSkillsMarketplace = ({
|
|||||||
selectedAgentId,
|
selectedAgentId,
|
||||||
setSelectedAgentId,
|
setSelectedAgentId,
|
||||||
skillsReport,
|
skillsReport,
|
||||||
|
marketplaceSkills,
|
||||||
|
packagedSkillsByKey,
|
||||||
skillsAllowlist,
|
skillsAllowlist,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
@@ -290,6 +337,7 @@ export const useOfficeSkillsMarketplace = ({
|
|||||||
refresh,
|
refresh,
|
||||||
handleSetSkillEnabled,
|
handleSetSkillEnabled,
|
||||||
handleInstallSkill,
|
handleInstallSkill,
|
||||||
|
handleInstallPackagedSkill,
|
||||||
handleSetSkillGlobalEnabled,
|
handleSetSkillGlobalEnabled,
|
||||||
handleRemoveSkill,
|
handleRemoveSkill,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ import { AnalyticsPanel } from "@/features/office/components/panels/AnalyticsPan
|
|||||||
import { HistoryPanel } from "@/features/office/components/panels/HistoryPanel";
|
import { HistoryPanel } from "@/features/office/components/panels/HistoryPanel";
|
||||||
import { InboxPanel } from "@/features/office/components/panels/InboxPanel";
|
import { InboxPanel } from "@/features/office/components/panels/InboxPanel";
|
||||||
import { PlaybooksPanel } from "@/features/office/components/panels/PlaybooksPanel";
|
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 { useOfficeSkillsMarketplace } from "@/features/office/hooks/useOfficeSkillsMarketplace";
|
||||||
import { useOfficeStandupController } from "@/features/office/hooks/useOfficeStandupController";
|
import { useOfficeStandupController } from "@/features/office/hooks/useOfficeStandupController";
|
||||||
import { useRunLog } from "@/features/office/hooks/useRunLog";
|
import { useRunLog } from "@/features/office/hooks/useRunLog";
|
||||||
@@ -106,6 +107,7 @@ import {
|
|||||||
type OfficePhoneCallRequest,
|
type OfficePhoneCallRequest,
|
||||||
type OfficeTextMessageRequest,
|
type OfficeTextMessageRequest,
|
||||||
} from "@/lib/office/eventTriggers";
|
} from "@/lib/office/eventTriggers";
|
||||||
|
import { buildOfficeSkillTriggerHoldMaps } from "@/lib/office/places";
|
||||||
import type { MockPhoneCallScenario } from "@/lib/office/call/types";
|
import type { MockPhoneCallScenario } from "@/lib/office/call/types";
|
||||||
import type { MockTextMessageScenario } from "@/lib/office/text/types";
|
import type { MockTextMessageScenario } from "@/lib/office/text/types";
|
||||||
import {
|
import {
|
||||||
@@ -759,6 +761,7 @@ export function OfficeScreen({
|
|||||||
>({});
|
>({});
|
||||||
const [gatewayModels, setGatewayModels] = useState<GatewayModelChoice[]>([]);
|
const [gatewayModels, setGatewayModels] = useState<GatewayModelChoice[]>([]);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const [marketplaceOpen, setMarketplaceOpen] = useState(false);
|
||||||
const [activeSidebarTab, setActiveSidebarTab] =
|
const [activeSidebarTab, setActiveSidebarTab] =
|
||||||
useState<HQSidebarTab>("inbox");
|
useState<HQSidebarTab>("inbox");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -1699,22 +1702,53 @@ export function OfficeScreen({
|
|||||||
onSkillActivityStart: handleMarketplaceGymStart,
|
onSkillActivityStart: handleMarketplaceGymStart,
|
||||||
onSkillActivityEnd: handleMarketplaceGymEnd,
|
onSkillActivityEnd: handleMarketplaceGymEnd,
|
||||||
});
|
});
|
||||||
|
const skillTriggers = useOfficeSkillTriggers({
|
||||||
|
client,
|
||||||
|
status,
|
||||||
|
agents: state.agents,
|
||||||
|
});
|
||||||
const animationNowMs = Date.now();
|
const animationNowMs = Date.now();
|
||||||
const officeAnimationState = useMemo(
|
const officeAnimationState = useMemo(() => {
|
||||||
() =>
|
const base = buildOfficeAnimationState({
|
||||||
buildOfficeAnimationState({
|
|
||||||
state: officeTriggerState,
|
state: officeTriggerState,
|
||||||
agents: state.agents,
|
agents: state.agents,
|
||||||
marketplaceGymHoldByAgentId,
|
marketplaceGymHoldByAgentId,
|
||||||
nowMs: animationNowMs,
|
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,
|
animationNowMs,
|
||||||
marketplaceGymHoldByAgentId,
|
marketplaceGymHoldByAgentId,
|
||||||
officeTriggerState,
|
officeTriggerState,
|
||||||
|
skillTriggers.movementTargetByAgentId,
|
||||||
state.agents,
|
state.agents,
|
||||||
],
|
]);
|
||||||
);
|
|
||||||
const {
|
const {
|
||||||
deskHoldByAgentId,
|
deskHoldByAgentId,
|
||||||
githubHoldByAgentId,
|
githubHoldByAgentId,
|
||||||
@@ -2851,8 +2885,7 @@ export function OfficeScreen({
|
|||||||
onPhoneCallComplete={handlePhoneCallComplete}
|
onPhoneCallComplete={handlePhoneCallComplete}
|
||||||
onTextMessageComplete={handleTextMessageComplete}
|
onTextMessageComplete={handleTextMessageComplete}
|
||||||
onOpenGithubSkillSetup={() => {
|
onOpenGithubSkillSetup={() => {
|
||||||
setSidebarOpen(true);
|
setMarketplaceOpen(true);
|
||||||
setActiveSidebarTab("marketplace");
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -2887,6 +2920,7 @@ export function OfficeScreen({
|
|||||||
inboxCount={unseenInboxCount}
|
inboxCount={unseenInboxCount}
|
||||||
onToggle={() => setSidebarOpen((prev) => !prev)}
|
onToggle={() => setSidebarOpen((prev) => !prev)}
|
||||||
onTabChange={setActiveSidebarTab}
|
onTabChange={setActiveSidebarTab}
|
||||||
|
onOpenMarketplace={() => setMarketplaceOpen(true)}
|
||||||
inboxPanel={
|
inboxPanel={
|
||||||
<InboxPanel
|
<InboxPanel
|
||||||
agents={state.agents}
|
agents={state.agents}
|
||||||
@@ -2914,16 +2948,6 @@ export function OfficeScreen({
|
|||||||
standup={standupController}
|
standup={standupController}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
marketplacePanel={
|
|
||||||
<SkillsMarketplacePanel
|
|
||||||
marketplace={marketplace}
|
|
||||||
onSelectAgent={handleOpenAgentChat}
|
|
||||||
onOpenAgentSettings={(agentId) => {
|
|
||||||
handleOpenAgentChat(agentId);
|
|
||||||
router.push("/office");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
analyticsPanel={
|
analyticsPanel={
|
||||||
<AnalyticsPanel
|
<AnalyticsPanel
|
||||||
client={client}
|
client={client}
|
||||||
@@ -2941,6 +2965,21 @@ export function OfficeScreen({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : 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 ? (
|
{showOnboardingWizard ? (
|
||||||
<OnboardingWizard
|
<OnboardingWizard
|
||||||
gatewayConnected={status === "connected"}
|
gatewayConnected={status === "connected"}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
|
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
|
||||||
|
import type { OfficeInteractionTargetId } from "@/lib/office/places";
|
||||||
|
|
||||||
export type OfficeAgent = {
|
export type OfficeAgent = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -50,14 +51,7 @@ export type RenderAgent = SceneActor & {
|
|||||||
pingPongTableUid?: string;
|
pingPongTableUid?: string;
|
||||||
pingPongSide?: 0 | 1;
|
pingPongSide?: 0 | 1;
|
||||||
pingPongPreviousWalkSpeed?: number;
|
pingPongPreviousWalkSpeed?: number;
|
||||||
interactionTarget?:
|
interactionTarget?: OfficeInteractionTargetId;
|
||||||
| "desk"
|
|
||||||
| "server_room"
|
|
||||||
| "meeting_room"
|
|
||||||
| "gym"
|
|
||||||
| "qa_lab"
|
|
||||||
| "sms_booth"
|
|
||||||
| "phone_booth";
|
|
||||||
smsBoothStage?: "door_outer" | "door_inner" | "typing";
|
smsBoothStage?: "door_outer" | "door_inner" | "typing";
|
||||||
phoneBoothStage?: "door_outer" | "door_inner" | "receiver";
|
phoneBoothStage?: "door_outer" | "door_inner" | "receiver";
|
||||||
serverRoomStage?: "door_outer" | "door_inner" | "terminal";
|
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: {
|
export const updateGatewayHeartbeat = async (params: {
|
||||||
client: GatewayClient;
|
client: GatewayClient;
|
||||||
agentId: string;
|
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,
|
hasInstallableMissingBinary,
|
||||||
type SkillReadinessState,
|
type SkillReadinessState,
|
||||||
} from "@/lib/skills/presentation";
|
} from "@/lib/skills/presentation";
|
||||||
|
import { getPackagedSkillBySkillKey } from "@/lib/skills/catalog";
|
||||||
import type { SkillStatusEntry } from "@/lib/skills/types";
|
import type { SkillStatusEntry } from "@/lib/skills/types";
|
||||||
|
|
||||||
export type SkillMarketplaceCollectionId =
|
export type SkillMarketplaceCollectionId =
|
||||||
|
| "claw3d"
|
||||||
| "featured"
|
| "featured"
|
||||||
| "installed"
|
| "installed"
|
||||||
| "setup-required"
|
| "setup-required"
|
||||||
@@ -26,6 +28,9 @@ export type SkillMarketplaceMetadata = {
|
|||||||
editorBadge?: string;
|
editorBadge?: string;
|
||||||
rating?: number;
|
rating?: number;
|
||||||
installs?: number;
|
installs?: number;
|
||||||
|
poweredByName?: string;
|
||||||
|
poweredByUrl?: string;
|
||||||
|
hideStats?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SkillMarketplaceEntry = {
|
export type SkillMarketplaceEntry = {
|
||||||
@@ -72,6 +77,14 @@ const SKILL_MARKETPLACE_OVERRIDES: Record<string, Partial<SkillMarketplaceMetada
|
|||||||
rating: 4.7,
|
rating: 4.7,
|
||||||
installs: 11980,
|
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 => {
|
const hashString = (value: string): number => {
|
||||||
@@ -146,24 +159,38 @@ export const resolveSkillMarketplaceMetadata = (skill: SkillStatusEntry): SkillM
|
|||||||
const normalizedKey = skill.skillKey.trim().toLowerCase();
|
const normalizedKey = skill.skillKey.trim().toLowerCase();
|
||||||
const fallback = buildFallbackMetadata(skill);
|
const fallback = buildFallbackMetadata(skill);
|
||||||
const override = SKILL_MARKETPLACE_OVERRIDES[normalizedKey];
|
const override = SKILL_MARKETPLACE_OVERRIDES[normalizedKey];
|
||||||
|
const packagedSkill = getPackagedSkillBySkillKey(skill.skillKey);
|
||||||
if (!override) {
|
if (!override) {
|
||||||
return fallback;
|
return {
|
||||||
|
...fallback,
|
||||||
|
poweredByName: packagedSkill?.creatorName,
|
||||||
|
poweredByUrl: packagedSkill?.creatorUrl,
|
||||||
|
hideStats: Boolean(packagedSkill),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...fallback,
|
...fallback,
|
||||||
...override,
|
...override,
|
||||||
capabilities: override.capabilities ?? fallback.capabilities,
|
capabilities: override.capabilities ?? fallback.capabilities,
|
||||||
|
poweredByName: packagedSkill?.creatorName,
|
||||||
|
poweredByUrl: packagedSkill?.creatorUrl,
|
||||||
|
hideStats: override.hideStats ?? Boolean(packagedSkill),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildSkillMarketplaceEntry = (skill: SkillStatusEntry): SkillMarketplaceEntry => {
|
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 {
|
return {
|
||||||
skill,
|
skill,
|
||||||
readiness: deriveSkillReadinessState(skill),
|
readiness: deriveSkillReadinessState(skill),
|
||||||
metadata: resolveSkillMarketplaceMetadata(skill),
|
metadata: resolveSkillMarketplaceMetadata(skill),
|
||||||
installable: hasInstallableMissingBinary(skill),
|
installable: hasInstallableMissingBinary(skill),
|
||||||
removable: canRemoveSkill(skill),
|
removable: canRemoveSkill(skill),
|
||||||
missingDetails: buildSkillMissingDetails(skill),
|
missingDetails,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -187,6 +214,11 @@ export const buildSkillMarketplaceCollections = (
|
|||||||
collections.push({ id: "featured", label: "Featured", entries: featured });
|
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);
|
const installed = entries.filter((entry) => entry.readiness === "ready" || entry.skill.disabled);
|
||||||
if (installed.length > 0) {
|
if (installed.length > 0) {
|
||||||
collections.push({ id: "installed", label: "Installed", entries: installed });
|
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";
|
import type { SkillRemoveRequest, SkillRemoveResult } from "@/lib/skills/types";
|
||||||
|
|
||||||
const normalizeRequired = (value: string, field: string): string => {
|
const normalizeRequired = (value: string, field: string): string => {
|
||||||
@@ -10,20 +11,17 @@ const normalizeRequired = (value: string, field: string): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const removeSkillFromGateway = async (
|
export const removeSkillFromGateway = async (
|
||||||
request: SkillRemoveRequest
|
params: { client: GatewayClient } & SkillRemoveRequest
|
||||||
): Promise<SkillRemoveResult> => {
|
): Promise<SkillRemoveResult> => {
|
||||||
const payload: SkillRemoveRequest = {
|
const payload: SkillRemoveRequest = {
|
||||||
skillKey: normalizeRequired(request.skillKey, "skillKey"),
|
skillKey: normalizeRequired(params.skillKey, "skillKey"),
|
||||||
source: request.source,
|
source: params.source,
|
||||||
baseDir: normalizeRequired(request.baseDir, "baseDir"),
|
baseDir: normalizeRequired(params.baseDir, "baseDir"),
|
||||||
workspaceDir: normalizeRequired(request.workspaceDir, "workspaceDir"),
|
workspaceDir: normalizeRequired(params.workspaceDir, "workspaceDir"),
|
||||||
managedSkillsDir: normalizeRequired(request.managedSkillsDir, "managedSkillsDir"),
|
managedSkillsDir: normalizeRequired(params.managedSkillsDir, "managedSkillsDir"),
|
||||||
};
|
};
|
||||||
|
return removeSkillViaGatewayAgent({
|
||||||
const response = await fetchJson<{ result: SkillRemoveResult }>("/api/gateway/skills/remove", {
|
client: params.client,
|
||||||
method: "POST",
|
request: payload,
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify(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;
|
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 resolveAgentId = (agentId: string): string => {
|
||||||
const trimmed = agentId.trim();
|
const trimmed = agentId.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import * as childProcess from "node:child_process";
|
|||||||
|
|
||||||
const SSH_TARGET_ENV = "OPENCLAW_GATEWAY_SSH_TARGET";
|
const SSH_TARGET_ENV = "OPENCLAW_GATEWAY_SSH_TARGET";
|
||||||
const SSH_USER_ENV = "OPENCLAW_GATEWAY_SSH_USER";
|
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 => {
|
export const resolveConfiguredSshTarget = (env: NodeJS.ProcessEnv = process.env): string | null => {
|
||||||
const configuredTarget = env[SSH_TARGET_ENV]?.trim() ?? "";
|
const configuredTarget = env[SSH_TARGET_ENV]?.trim() ?? "";
|
||||||
@@ -17,6 +19,50 @@ export const resolveConfiguredSshTarget = (env: NodeJS.ProcessEnv = process.env)
|
|||||||
return null;
|
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 = (
|
export const resolveGatewaySshTargetFromGatewayUrl = (
|
||||||
gatewayUrl: string,
|
gatewayUrl: string,
|
||||||
env: NodeJS.ProcessEnv = process.env
|
env: NodeJS.ProcessEnv = process.env
|
||||||
@@ -86,6 +132,8 @@ export const parseJsonOutput = (raw: string, label: string): unknown => {
|
|||||||
|
|
||||||
export const runSshJson = (params: {
|
export const runSshJson = (params: {
|
||||||
sshTarget: string;
|
sshTarget: string;
|
||||||
|
sshPort?: number | null;
|
||||||
|
strictHostKeyChecking?: "accept-new" | "yes" | "no";
|
||||||
argv: string[];
|
argv: string[];
|
||||||
label: string;
|
label: string;
|
||||||
input?: string;
|
input?: string;
|
||||||
@@ -100,9 +148,18 @@ export const runSshJson = (params: {
|
|||||||
options.maxBuffer = params.maxBuffer;
|
options.maxBuffer = params.maxBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = childProcess.spawnSync("ssh", ["-o", "BatchMode=yes", params.sshTarget, ...params.argv], {
|
const sshArgs = [
|
||||||
...options,
|
"-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) {
|
if (result.error) {
|
||||||
throw new Error(`Failed to execute ssh: ${result.error.message}`);
|
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 { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { removeSkillFromGateway } from "@/lib/skills/remove";
|
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", () => {
|
describe("skills remove client", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("posts skill removal payload to the Studio API route", async () => {
|
it("delegates sanitized skill removal payloads to the gateway-native remover", async () => {
|
||||||
const fetchMock = vi.fn(async () => ({
|
vi.mocked(removeSkillViaGatewayAgent).mockResolvedValueOnce({
|
||||||
ok: true,
|
|
||||||
text: async () =>
|
|
||||||
JSON.stringify({
|
|
||||||
result: {
|
|
||||||
removed: true,
|
removed: true,
|
||||||
removedPath: "/tmp/workspace/skills/github",
|
removedPath: "/tmp/workspace/skills/github",
|
||||||
source: "openclaw-workspace",
|
source: "openclaw-workspace",
|
||||||
},
|
});
|
||||||
}),
|
|
||||||
}));
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
|
||||||
|
|
||||||
const result = await removeSkillFromGateway({
|
const result = await removeSkillFromGateway({
|
||||||
|
client: { call: vi.fn() } as never,
|
||||||
skillKey: " github ",
|
skillKey: " github ",
|
||||||
source: "openclaw-workspace",
|
source: "openclaw-workspace",
|
||||||
baseDir: " /tmp/workspace/skills/github ",
|
baseDir: " /tmp/workspace/skills/github ",
|
||||||
@@ -30,16 +28,15 @@ describe("skills remove client", () => {
|
|||||||
managedSkillsDir: " /tmp/managed ",
|
managedSkillsDir: " /tmp/managed ",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith("/api/gateway/skills/remove", {
|
expect(removeSkillViaGatewayAgent).toHaveBeenCalledWith({
|
||||||
method: "POST",
|
client: expect.any(Object),
|
||||||
headers: { "content-type": "application/json" },
|
request: {
|
||||||
body: JSON.stringify({
|
|
||||||
skillKey: "github",
|
skillKey: "github",
|
||||||
source: "openclaw-workspace",
|
source: "openclaw-workspace",
|
||||||
baseDir: "/tmp/workspace/skills/github",
|
baseDir: "/tmp/workspace/skills/github",
|
||||||
workspaceDir: "/tmp/workspace",
|
workspaceDir: "/tmp/workspace",
|
||||||
managedSkillsDir: "/tmp/managed",
|
managedSkillsDir: "/tmp/managed",
|
||||||
}),
|
},
|
||||||
});
|
});
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
removed: true,
|
removed: true,
|
||||||
@@ -51,6 +48,7 @@ describe("skills remove client", () => {
|
|||||||
it("fails fast when required payload fields are missing", async () => {
|
it("fails fast when required payload fields are missing", async () => {
|
||||||
await expect(
|
await expect(
|
||||||
removeSkillFromGateway({
|
removeSkillFromGateway({
|
||||||
|
client: { call: vi.fn() } as never,
|
||||||
skillKey: " ",
|
skillKey: " ",
|
||||||
source: "openclaw-workspace",
|
source: "openclaw-workspace",
|
||||||
baseDir: "/tmp/workspace/skills/github",
|
baseDir: "/tmp/workspace/skills/github",
|
||||||
@@ -61,6 +59,7 @@ describe("skills remove client", () => {
|
|||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
removeSkillFromGateway({
|
removeSkillFromGateway({
|
||||||
|
client: { call: vi.fn() } as never,
|
||||||
skillKey: "github",
|
skillKey: "github",
|
||||||
source: "openclaw-workspace",
|
source: "openclaw-workspace",
|
||||||
baseDir: " ",
|
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