Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions docs/design/web-shell-skill-manager-page.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Web Shell Skill Management

## Goal

Add an in-place Skill management page that preserves invocation behavior and
lets trusted users install, enable, disable, and delete Skills without an
active chat session.

## Behavior

- `/skills`, `/skills detail`, and `/skills details` open the page.
- The sidebar Plugins page exposes Skills as its third tab.
- The first level lists skills with search and scope filters.
- Skill cards omit scope badges; scope remains available through the filters
and on the details page.
- Layout, responsive card grid, badges, breadcrumbs, and empty states match the
MCP management page.
- Selecting a skill opens its details in the same page.
- Returning from details preserves the active scope filter and search query.
- The details page exposes the daemon's per-skill enable/disable action. Skills
that are not user-invocable cannot be toggled; extension skills can be
toggled unless their parent extension is inactive.
- “Reference skill” returns to chat and places `/<skill-name>` in the composer
without submitting it.
- The list header exposes an Upload action for GitHub, daemon-local folder, and
ZIP sources.
- The detail actions menu exposes Delete for project and global Skills with a
destructive confirmation step. Bundled and extension Skills remain
read-only.
- Successful mutations refresh the list; errors remain visible in context.

## Protocol

The daemon advertises `workspace_skill_manage` and exposes workspace-bound and
workspace-qualified variants of:

- `POST /workspace/skills/install`
- `DELETE /workspace/skills/:name`

Install accepts `scope: "workspace" | "global"` and one source:

- `github`: an HTTPS GitHub URL pointing to `SKILL.md`.
- `folder`: an absolute folder path on the daemon host.
- `zip`: one bounded base64 ZIP archive.

Delete accepts the same scope. The requested scope must match the discovered
Skill level before deletion.

## Filesystem and validation

- Workspace Skills are confined to `<workspace>/.qwen/skills/<slug>`.
- Global Skills are confined to `<QWEN_HOME>/skills/<slug>`.
- Deletion also accepts discovered project/user Skills in compatible
`.agents/skills` provider directories.
- Slugs allow only letters, digits, `.`, `_`, and `-`, excluding `.` and `..`.
- Every package must contain a root `SKILL.md`; a single enclosing folder is
stripped from folder and ZIP uploads.
- File count, individual size, aggregate size, path depth, and path length are
bounded below the daemon JSON parser limit.
- Absolute paths, traversal, duplicate normalized paths, symbolic links, and
special ZIP entries are rejected.
- `SKILL.md` frontmatter `name` must match the requested slug.
- Installation stages into a sibling directory and safely replaces the
destination with rollback only after validation succeeds.
- Deletion validates the discovered canonical `SKILL.md` and dedicated parent
directory before recursively removing it.

After a mutation, cached workspace Skill status is invalidated and active ACP
sessions refresh their SkillManager and slash-command snapshots.

## Scope

This change adds the standalone Skill page and reuses it in Plugins. It does not
migrate the Tools and Agents management pages.

## Testing

- Unit-test filtering and selection retention.
- Verify the slash-command route opens the Skill panel and that starting a new
task rebuilds Skill commands from the latest workspace status.
- Route and service tests cover both scopes, each install source, replacement,
traversal and ZIP-bomb limits, source mismatch, protected sources, and
refresh.
- SDK and WebUI tests cover request serialization and action exposure.
- Run Web Shell typecheck, build, and focused tests to verify the management UI
integration.
1 change: 1 addition & 0 deletions integration-tests/cli/qwen-serve-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ describe('qwen serve — capabilities envelope', () => {
'session_approval_mode_control',
'workspace_tool_toggle',
'workspace_skill_toggle',
'workspace_skill_manage',
'workspace_settings',
'workspace_permissions',
'workspace_voice',
Expand Down
2 changes: 2 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
"update-notifier": "^7.3.1",
"wrap-ansi": "^10.0.0",
"ws": "^8.18.0",
"yauzl": "^2.10.0",
"yargs": "^17.7.2",
"zod": "^3.23.8"
},
Expand All @@ -107,6 +108,7 @@
"@types/semver": "^7.7.0",
"@types/shell-quote": "^1.7.5",
"@types/supertest": "^6.0.3",
"@types/yauzl": "^2.9.1",
"@types/yargs": "^17.0.32",
"archiver": "^7.0.1",
"ink-testing-library": "^4.0.0",
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/serve/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export const SERVE_CAPABILITY_REGISTRY = {
// (`tools.disabled` is consulted at `Config` construction time).
workspace_tool_toggle: { since: 'v1' },
workspace_skill_toggle: { since: 'v1' },
workspace_skill_manage: { since: 'v1' },
workspace_settings: { since: 'v1' },
// `GET /workspace/permissions` is always available when this tag is
// advertised. `POST /workspace/permissions` updates the active ACP
Expand Down
137 changes: 137 additions & 0 deletions packages/cli/src/serve/routes/workspace-skills.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import express, {
type NextFunction,
type Request,
type Response,
} from 'express';
import request from 'supertest';
import { describe, expect, it, vi } from 'vitest';
import type { WorkspaceRuntime } from '../workspace-registry.js';
import { WorkspaceSkillManagementError } from '../workspace-skill-management.js';
import { registerWorkspaceSkillsRoutes } from './workspace-skills.js';

function createHarness() {
const installWorkspaceSkill = vi.fn().mockResolvedValue({
skillName: 'demo-skill',
scope: 'workspace',
installedPath: '/workspace/.qwen/skills/demo-skill/SKILL.md',
});
const deleteWorkspaceSkill = vi.fn().mockResolvedValue({
skillName: 'demo-skill',
scope: 'global',
deleted: true,
});
const app = express();
app.use(express.json({ limit: '10mb' }));
registerWorkspaceSkillsRoutes(app, {
workspaceRuntime: {
workspaceCwd: '/workspace',
trusted: true,
workspaceService: {
installWorkspaceSkill,
deleteWorkspaceSkill,
},
} as unknown as WorkspaceRuntime,
mutate: () => (_req: Request, _res: Response, next: NextFunction) => next(),
safeBody: (req) => req.body as Record<string, unknown>,
sendBridgeError: vi.fn(),
parseAndValidateClientId: () => 'client-1',
});
return { app, installWorkspaceSkill, deleteWorkspaceSkill };
}

describe('workspace Skill management routes', () => {
it('forwards an install request to the workspace service', async () => {
const harness = createHarness();
const body = {
name: 'demo-skill',
scope: 'workspace',
source: {
type: 'github',
url: 'https://github.com/owner/repo/blob/main/demo/SKILL.md',
},
};

const response = await request(harness.app)
.post('/workspace/skills/install')
.send(body);

expect(response.status).toBe(200);
expect(harness.installWorkspaceSkill).toHaveBeenCalledWith(
expect.objectContaining({
workspaceCwd: '/workspace',
originatorClientId: 'client-1',
}),
body,
);
});

it('forwards delete scope and rejects invalid scopes', async () => {
const harness = createHarness();

const response = await request(harness.app).delete(
'/workspace/skills/demo-skill?scope=global',
);
const invalid = await request(harness.app).delete(
'/workspace/skills/demo-skill?scope=extension',
);

expect(response.status).toBe(200);
expect(harness.deleteWorkspaceSkill).toHaveBeenCalledWith(
expect.objectContaining({ originatorClientId: 'client-1' }),
'demo-skill',
'global',
);
expect(invalid.status).toBe(400);
expect(invalid.body.code).toBe('invalid_skill_scope');
});

it('returns structured management errors', async () => {
const harness = createHarness();
harness.installWorkspaceSkill.mockRejectedValueOnce(
new WorkspaceSkillManagementError(
'skill_manifest_missing',
'Skill package must contain a root SKILL.md',
),
);

const response = await request(harness.app)
.post('/workspace/skills/install')
.send({
name: 'demo-skill',
scope: 'workspace',
source: { type: 'zip', contentBase64: 'eA==' },
});

expect(response.status).toBe(400);
expect(response.body).toEqual({
error: 'Skill package must contain a root SKILL.md',
code: 'skill_manifest_missing',
});
});

it('rejects an oversized install name before calling the service', async () => {
const harness = createHarness();
const response = await request(harness.app)
.post('/workspace/skills/install')
.send({
name: 'x'.repeat(257),
scope: 'workspace',
source: { type: 'folder', path: '/tmp/skill' },
});

expect(response.status).toBe(400);
expect(response.body.code).toBe('invalid_skill_name');
expect(harness.installWorkspaceSkill).not.toHaveBeenCalled();
});

it('rejects an invalid delete name before calling the service', async () => {
const harness = createHarness();
const response = await request(harness.app).delete(
'/workspace/skills/invalid%20name?scope=workspace',
);

expect(response.status).toBe(400);
expect(response.body.code).toBe('invalid_skill_name');
expect(harness.deleteWorkspaceSkill).not.toHaveBeenCalled();
});
});
Loading
Loading