diff --git a/docs/design/web-shell-skill-manager-page.md b/docs/design/web-shell-skill-manager-page.md new file mode 100644 index 00000000000..6ab765a8431 --- /dev/null +++ b/docs/design/web-shell-skill-manager-page.md @@ -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 `/` 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 `/.qwen/skills/`. +- Global Skills are confined to `/skills/`. +- 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. diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index 3c5056dbd31..f37e47670c9 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -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', diff --git a/package-lock.json b/package-lock.json index 2b93e107f1d..3cde07e2f78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28120,6 +28120,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" }, @@ -28142,6 +28143,7 @@ "@types/shell-quote": "^1.7.5", "@types/supertest": "^6.0.3", "@types/ws": "^8.5.0", + "@types/yauzl": "^2.9.1", "@types/yargs": "^17.0.32", "archiver": "^7.0.1", "ink-testing-library": "^4.0.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index 0bec751564a..e8a5e1241bd 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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" }, @@ -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", diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 0faddfdf919..82d82a1f870 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -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 diff --git a/packages/cli/src/serve/routes/workspace-skills.test.ts b/packages/cli/src/serve/routes/workspace-skills.test.ts new file mode 100644 index 00000000000..f14f543a18d --- /dev/null +++ b/packages/cli/src/serve/routes/workspace-skills.test.ts @@ -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, + 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(); + }); +}); diff --git a/packages/cli/src/serve/routes/workspace-skills.ts b/packages/cli/src/serve/routes/workspace-skills.ts index 9c615f8d8c9..54882312dc8 100644 --- a/packages/cli/src/serve/routes/workspace-skills.ts +++ b/packages/cli/src/serve/routes/workspace-skills.ts @@ -8,7 +8,6 @@ import type { Application, Request, RequestHandler, Response } from 'express'; import type { SendBridgeError } from '../server/error-response.js'; import { createBuildWorkspaceCtx, - MAX_SKILL_NAME_LENGTH, parseAndValidateWorkspaceClientId, } from '../server/request-helpers.js'; import { @@ -19,6 +18,13 @@ import type { WorkspaceRegistry, WorkspaceRuntime, } from '../workspace-registry.js'; +import { + MAX_WORKSPACE_SKILL_NAME_LENGTH, + WorkspaceSkillManagementError, + validateWorkspaceSkillName, + type WorkspaceSkillInstallRequest, + type WorkspaceSkillScope, +} from '../workspace-skill-management.js'; interface RegisterWorkspaceSkillsRoutesDeps { workspaceRuntime: WorkspaceRuntime; @@ -52,9 +58,9 @@ function parseSkillToggleRequest( }); return undefined; } - if (skillName.length > MAX_SKILL_NAME_LENGTH) { + if (skillName.length > MAX_WORKSPACE_SKILL_NAME_LENGTH) { res.status(400).json({ - error: `Skill name exceeds ${MAX_SKILL_NAME_LENGTH}-character limit`, + error: `Skill name exceeds ${MAX_WORKSPACE_SKILL_NAME_LENGTH}-character limit`, code: 'invalid_skill_name', }); return undefined; @@ -70,6 +76,87 @@ function parseSkillToggleRequest( return { skillName, enabled }; } +function parseSkillScope( + value: unknown, + res: Response, +): WorkspaceSkillScope | undefined { + if (value === 'workspace' || value === 'global') return value; + res.status(400).json({ + error: '`scope` must be "workspace" or "global"', + code: 'invalid_skill_scope', + }); + return undefined; +} + +function parseSkillInstallRequest( + req: Request, + res: Response, + safeBody: (req: Request) => Record, +): WorkspaceSkillInstallRequest | undefined { + const body = safeBody(req); + const name = body['name']; + if (typeof name !== 'string' || !name.trim()) { + res.status(400).json({ + error: '`name` is required and must be a string', + code: 'invalid_skill_name', + }); + return undefined; + } + if (name.trim().length > MAX_WORKSPACE_SKILL_NAME_LENGTH) { + res.status(400).json({ + error: `Skill name exceeds ${MAX_WORKSPACE_SKILL_NAME_LENGTH}-character limit`, + code: 'invalid_skill_name', + }); + return undefined; + } + const scope = parseSkillScope(body['scope'], res); + if (!scope) return undefined; + const rawSource = body['source']; + if (!rawSource || typeof rawSource !== 'object' || Array.isArray(rawSource)) { + res.status(400).json({ + error: '`source` is required', + code: 'invalid_skill_source', + }); + return undefined; + } + const source = rawSource as Record; + if (source['type'] === 'github' && typeof source['url'] === 'string') { + return { name, scope, source: { type: 'github', url: source['url'] } }; + } + if (source['type'] === 'zip' && typeof source['contentBase64'] === 'string') { + return { + name, + scope, + source: { type: 'zip', contentBase64: source['contentBase64'] }, + }; + } + if (source['type'] === 'folder' && typeof source['path'] === 'string') { + return { + name, + scope, + source: { type: 'folder', path: source['path'] }, + }; + } + res.status(400).json({ + error: 'Invalid Skill install source', + code: 'invalid_skill_source', + }); + return undefined; +} + +function parseDeleteScope(req: Request, res: Response) { + return parseSkillScope(req.query['scope'], res); +} + +function sendSkillManagementError(res: Response, error: unknown): boolean { + if (!(error instanceof WorkspaceSkillManagementError)) return false; + res.status(error.statusCode).json({ + error: error.message, + code: error.code, + }); + return true; +} + export function registerWorkspaceSkillsRoutes( app: Application, deps: RegisterWorkspaceSkillsRoutesDeps, @@ -78,6 +165,61 @@ export function registerWorkspaceSkillsRoutes( deps.workspaceRuntime.workspaceCwd, ); const route = 'POST /workspace/skills/:name/enable'; + app.post( + '/workspace/skills/install', + deps.mutate({ strict: true }), + async (req, res) => { + if (!requireTrustedWorkspaceRuntime(deps.workspaceRuntime, res)) return; + const input = parseSkillInstallRequest(req, res, deps.safeBody); + if (!input) return; + const clientId = deps.parseAndValidateClientId(req, res); + if (clientId === null) return; + const installRoute = 'POST /workspace/skills/install'; + try { + const result = + await deps.workspaceRuntime.workspaceService.installWorkspaceSkill( + buildWorkspaceCtx(installRoute, clientId), + input, + ); + res.status(200).json(result); + } catch (err) { + if (!sendSkillManagementError(res, err)) + deps.sendBridgeError(res, err, { route: installRoute }); + } + }, + ); + app.delete( + '/workspace/skills/:name', + deps.mutate({ strict: true }), + async (req, res) => { + if (!requireTrustedWorkspaceRuntime(deps.workspaceRuntime, res)) return; + const rawSkillName = req.params['name']; + const scope = parseDeleteScope(req, res); + if (!rawSkillName || !scope) return; + let skillName: string; + try { + skillName = validateWorkspaceSkillName(rawSkillName); + } catch (error) { + sendSkillManagementError(res, error); + return; + } + const clientId = deps.parseAndValidateClientId(req, res); + if (clientId === null) return; + const deleteRoute = 'DELETE /workspace/skills/:name'; + try { + const result = + await deps.workspaceRuntime.workspaceService.deleteWorkspaceSkill( + buildWorkspaceCtx(deleteRoute, clientId), + skillName, + scope, + ); + res.status(200).json(result); + } catch (err) { + if (!sendSkillManagementError(res, err)) + deps.sendBridgeError(res, err, { route: deleteRoute }); + } + }, + ); app.post( '/workspace/skills/:name/enable', deps.mutate({ strict: true }), @@ -110,6 +252,77 @@ export function registerWorkspaceQualifiedSkillsRoutes( > & { workspaceRegistry: WorkspaceRegistry }, ): void { const route = 'POST /workspaces/:workspace/skills/:name/enable'; + app.post( + '/workspaces/:workspace/skills/install', + deps.mutate({ strict: true }), + async (req, res) => { + const runtime = resolveWorkspaceRuntimeFromParam( + deps.workspaceRegistry, + req, + res, + ); + if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return; + const input = parseSkillInstallRequest(req, res, deps.safeBody); + if (!input) return; + const clientId = parseAndValidateWorkspaceClientId( + req, + res, + runtime.bridge, + ); + if (clientId === null) return; + const installRoute = 'POST /workspaces/:workspace/skills/install'; + try { + const result = await runtime.workspaceService.installWorkspaceSkill( + createBuildWorkspaceCtx(runtime.workspaceCwd)(installRoute, clientId), + input, + ); + res.status(200).json(result); + } catch (err) { + if (!sendSkillManagementError(res, err)) + deps.sendBridgeError(res, err, { route: installRoute }); + } + }, + ); + app.delete( + '/workspaces/:workspace/skills/:name', + deps.mutate({ strict: true }), + async (req, res) => { + const runtime = resolveWorkspaceRuntimeFromParam( + deps.workspaceRegistry, + req, + res, + ); + if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return; + const rawSkillName = req.params['name']; + const scope = parseDeleteScope(req, res); + if (!rawSkillName || !scope) return; + let skillName: string; + try { + skillName = validateWorkspaceSkillName(rawSkillName); + } catch (error) { + sendSkillManagementError(res, error); + return; + } + const clientId = parseAndValidateWorkspaceClientId( + req, + res, + runtime.bridge, + ); + if (clientId === null) return; + const deleteRoute = 'DELETE /workspaces/:workspace/skills/:name'; + try { + const result = await runtime.workspaceService.deleteWorkspaceSkill( + createBuildWorkspaceCtx(runtime.workspaceCwd)(deleteRoute, clientId), + skillName, + scope, + ); + res.status(200).json(result); + } catch (err) { + if (!sendSkillManagementError(res, err)) + deps.sendBridgeError(res, err, { route: deleteRoute }); + } + }, + ); app.post( '/workspaces/:workspace/skills/:name/enable', deps.mutate({ strict: true }), diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index e42313cf4b7..7ff10c843eb 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -3270,6 +3270,7 @@ export async function runQwenServe( statusProvider, workspaceProvidersStatusProvider, workspaceSkillsStatusProvider, + skillInstallEnv: runtimeEffectiveEnv, voiceEnv: runtimeEffectiveEnv, isChannelLive: () => bridge.isChannelLive(), persistDisabledTools: persistDisabledToolsFn, @@ -3586,6 +3587,7 @@ export async function runQwenServe( }), workspaceSkillsStatusProvider: runtime.createWorkspaceSkillsStatusProvider(), + skillInstallEnv: secondaryEnv.effectiveEnv, voiceEnv: secondaryEnv.effectiveEnv, voiceSettingsScope: WORKSPACE_SETTING_SCOPE, isChannelLive: () => secondaryBridge.isChannelLive(), @@ -3958,6 +3960,7 @@ export async function runQwenServe( }), workspaceSkillsStatusProvider: runtime.createWorkspaceSkillsStatusProvider(), + skillInstallEnv: wsEnv.effectiveEnv, voiceEnv: wsEnv.effectiveEnv, voiceSettingsScope: WORKSPACE_SETTING_SCOPE, isChannelLive: () => wsBridge.isChannelLive(), diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index f8cd7a76cb9..8219e6a322c 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -294,6 +294,7 @@ const EXPECTED_STAGE1_FEATURES = [ 'session_approval_mode_control', 'workspace_tool_toggle', 'workspace_skill_toggle', + 'workspace_skill_manage', 'workspace_permissions', 'workspace_trust', 'workspace_init', @@ -14050,6 +14051,34 @@ describe('createServeApp', () => { expect(persistDisabledSkills).not.toHaveBeenCalled(); }); + it('returns a dedicated code for an inactive extension skill', async () => { + const persistDisabledSkills = vi.fn(); + const app = createServeApp(tokenOpts, undefined, { + bridge: fakeBridge({ + workspaceSkillsImpl: async () => ({ + v: 1, + workspaceCwd: WS_BOUND, + initialized: true, + skills: [ + { ...reviewSkill, level: 'extension', status: 'disabled' }, + ], + }), + }), + persistDisabledSkills, + primaryWorkspaceTrusted: true, + }); + const res = await auth( + request(app).post('/workspace/skills/review/enable'), + ).send({ enabled: true }); + + expect(res.status).toBe(409); + expect(res.body).toMatchObject({ + code: 'skill_inactive_extension', + reason: 'inactive_extension', + }); + expect(persistDisabledSkills).not.toHaveBeenCalled(); + }); + it('returns the locked scope from persistence validation', async () => { const app = createServeApp(tokenOpts, undefined, { bridge: fakeBridge({ diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 22546b12619..34b5abdb6c7 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -829,6 +829,7 @@ export function createServeApp( primaryEffectiveEnv ? { env: primaryEffectiveEnv } : {}, ), workspaceSkillsStatusProvider: createWorkspaceSkillsStatusProvider(), + ...(primaryEffectiveEnv ? { skillInstallEnv: primaryEffectiveEnv } : {}), ...(primaryEffectiveEnv ? { voiceEnv: primaryEffectiveEnv } : {}), isChannelLive: () => bridge.isChannelLive(), persistDisabledTools: diff --git a/packages/cli/src/serve/server/error-response.ts b/packages/cli/src/serve/server/error-response.ts index e1c003bb138..c23a01fe3a9 100644 --- a/packages/cli/src/serve/server/error-response.ts +++ b/packages/cli/src/serve/server/error-response.ts @@ -169,7 +169,10 @@ export function sendBridgeError( if (err instanceof WorkspaceSkillNotToggleableError) { res.status(409).json({ error: err.message, - code: 'skill_not_toggleable', + code: + err.reason === 'inactive_extension' + ? 'skill_inactive_extension' + : 'skill_not_toggleable', skillName: err.skillName, reason: err.reason, ...(err.lockedScope ? { lockedScope: err.lockedScope } : {}), diff --git a/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts b/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts index 300bc8b7b6e..b26e49596ce 100644 --- a/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts +++ b/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts @@ -1321,6 +1321,10 @@ describe('createDaemonWorkspaceService', () => { }); it('uses the canonical skill name and refreshes every active session', async () => { + const invalidate = vi.fn(); + const workspaceSkillsStatusProvider = Object.assign(vi.fn(), { + invalidate, + }); const persistDisabledSkills = vi.fn().mockResolvedValue({ changed: true, disabled: ['review'], @@ -1333,6 +1337,7 @@ describe('createDaemonWorkspaceService', () => { const svc = createDaemonWorkspaceService( makeDeps({ queryWorkspaceStatus: statusQuery(), + workspaceSkillsStatusProvider, persistDisabledSkills, invokeWorkspaceCommand, publishWorkspaceEvent, @@ -1351,6 +1356,7 @@ describe('createDaemonWorkspaceService', () => { 'review', false, ); + expect(invalidate).toHaveBeenCalledWith('/workspace'); expect(invokeWorkspaceCommand).toHaveBeenCalledWith( 'qwen/control/workspace/skills/refresh', { cwd: '/workspace' }, diff --git a/packages/cli/src/serve/workspace-service/index.ts b/packages/cli/src/serve/workspace-service/index.ts index 21782c5b7c9..2f5c506855d 100644 --- a/packages/cli/src/serve/workspace-service/index.ts +++ b/packages/cli/src/serve/workspace-service/index.ts @@ -58,6 +58,11 @@ import { type WorkspaceVoiceSettingsWrite, } from '../../services/voice-service.js'; import { writeStderrLine } from '../../utils/stdioHelpers.js'; +import { + deleteWorkspaceSkill, + installWorkspaceSkill, + WorkspaceSkillManagementError, +} from '../workspace-skill-management.js'; import { WorkspacePermissionRulesSessionRequiredError, @@ -76,6 +81,9 @@ import type { WorkspaceAcpPreheatResult, WorkspaceAcpStatusResult, WorkspaceSkillToggleResult, + WorkspaceSkillInstallRequest, + WorkspaceSkillMutationResult, + WorkspaceSkillScope, } from './types.js'; // Re-export types for consumers. @@ -207,6 +215,7 @@ export function createDaemonWorkspaceService( persistDisabledSkills, persistSetting, persistSettings, + skillInstallEnv, voiceEnv, voiceSettingsScope, preheatAcpChild: preheatAcpChildOnBridge, @@ -266,6 +275,27 @@ export function createDaemonWorkspaceService( return status; }; + const refreshWorkspaceSkillsAfterMutation = async (): Promise => { + lastWorkspaceSkillsStatus = undefined; + workspaceSkillsStatusProvider?.invalidate?.(boundWorkspace); + if (!(isChannelLive?.() ?? false)) return; + try { + await invokeWorkspaceCommand( + SERVE_CONTROL_EXT_METHODS.workspaceSkillsRefresh, + { cwd: boundWorkspace }, + ); + } catch (err) { + if ( + !(err instanceof SessionNotFoundError) && + !(err instanceof BridgeChannelClosedError) + ) { + writeStderrLine( + `qwen serve: workspace skill refresh after mutation failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + }; + // -- Facade -- return { // -- Status queries (delegate to ACP child via queryWorkspaceStatus) -- @@ -700,6 +730,7 @@ export function createDaemonWorkspaceService( if (persisted.changed) { lastWorkspaceSkillsStatus = undefined; + workspaceSkillsStatusProvider?.invalidate?.(boundWorkspace); if (channelLive) { try { const refreshed = @@ -748,6 +779,48 @@ export function createDaemonWorkspaceService( }; }, + async installWorkspaceSkill( + _ctx: WorkspaceRequestContext, + request: WorkspaceSkillInstallRequest, + ): Promise { + const result = await installWorkspaceSkill( + boundWorkspace, + request, + skillInstallEnv?.['GH_TOKEN'] ?? skillInstallEnv?.['GITHUB_TOKEN'], + ); + await refreshWorkspaceSkillsAfterMutation(); + return result; + }, + + async deleteWorkspaceSkill( + _ctx: WorkspaceRequestContext, + requestedSkillName: string, + scope: WorkspaceSkillScope, + ): Promise { + const normalizedName = requestedSkillName.trim().toLowerCase(); + const status = await getWorkspaceSkillsStatus(); + const skill = status.skills.find( + (candidate) => candidate.name.trim().toLowerCase() === normalizedName, + ); + if (!skill) throw new WorkspaceSkillNotFoundError(requestedSkillName); + const expectedLevel = scope === 'workspace' ? 'project' : 'user'; + if (skill.level !== expectedLevel || !skill.installedPath) { + throw new WorkspaceSkillManagementError( + 'skill_not_managed', + 'Skill is not managed in the requested scope', + 409, + ); + } + const result = await deleteWorkspaceSkill( + boundWorkspace, + scope, + skill.name, + skill.installedPath, + ); + await refreshWorkspaceSkillsAfterMutation(); + return result; + }, + async initWorkspace( ctx: WorkspaceRequestContext, opts: { force?: boolean }, diff --git a/packages/cli/src/serve/workspace-service/types.ts b/packages/cli/src/serve/workspace-service/types.ts index d6f892d4f90..c5136fa6b40 100644 --- a/packages/cli/src/serve/workspace-service/types.ts +++ b/packages/cli/src/serve/workspace-service/types.ts @@ -39,6 +39,17 @@ import type { WorkspaceVoiceStatus } from '../../services/voice-service.js'; import type { VoiceMode } from '../../services/voice-settings.js'; import type { WorkspaceProvidersStatusProvider } from '../workspace-providers-status.js'; import type { WorkspaceSkillsStatusProvider } from '../workspace-skills-status.js'; +import type { + WorkspaceSkillInstallRequest, + WorkspaceSkillMutationResult, + WorkspaceSkillScope, +} from '../workspace-skill-management.js'; + +export type { + WorkspaceSkillInstallRequest, + WorkspaceSkillMutationResult, + WorkspaceSkillScope, +} from '../workspace-skill-management.js'; // --------------------------------------------------------------------------- // WorkspaceRequestContext @@ -199,6 +210,19 @@ export interface DaemonWorkspaceService { enabled: boolean, ): Promise; + /** Install a project- or user-level Skill from a bounded package. */ + installWorkspaceSkill( + ctx: WorkspaceRequestContext, + request: WorkspaceSkillInstallRequest, + ): Promise; + + /** Delete a managed project- or user-level Skill. */ + deleteWorkspaceSkill( + ctx: WorkspaceRequestContext, + skillName: string, + scope: WorkspaceSkillScope, + ): Promise; + /** Scaffold (init) a QWEN.md file in the workspace. */ initWorkspace( ctx: WorkspaceRequestContext, @@ -450,6 +474,9 @@ export interface DaemonWorkspaceServiceDeps { /** Runtime-local environment used by workspace Voice operations. */ voiceEnv?: Readonly>; + /** Runtime-local environment used to authenticate GitHub Skill installs. */ + skillInstallEnv?: Readonly>; + /** Force Voice settings writes into this scope for workspace-qualified ACP. */ voiceSettingsScope?: SettingScope; diff --git a/packages/cli/src/serve/workspace-skill-management.test.ts b/packages/cli/src/serve/workspace-skill-management.test.ts new file mode 100644 index 00000000000..ae16ae8574f --- /dev/null +++ b/packages/cli/src/serve/workspace-skill-management.test.ts @@ -0,0 +1,626 @@ +import { promises as fs } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { PassThrough } from 'node:stream'; + +import archiver from 'archiver'; +import { Storage } from '@qwen-code/qwen-code-core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + deleteWorkspaceSkill, + installWorkspaceSkill, +} from './workspace-skill-management.js'; + +const temporaryDirectories: string[] = []; + +function skillMarkdown(name: string): string { + return `---\nname: ${name}\ndescription: Test skill\n---\n\nInstructions.\n`; +} + +async function zip(files: Record): Promise { + const output = new PassThrough(); + const chunks: Buffer[] = []; + output.on('data', (chunk: Buffer) => chunks.push(chunk)); + const archive = archiver('zip'); + archive.pipe(output); + for (const [name, content] of Object.entries(files)) { + archive.append(content, { name }); + } + const complete = new Promise((resolve, reject) => { + output.on('end', resolve); + output.on('error', reject); + archive.on('error', reject); + }); + await archive.finalize(); + await complete; + return Buffer.concat(chunks).toString('base64'); +} + +async function temporaryDirectory(label: string): Promise { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), label)); + temporaryDirectories.push(directory); + return directory; +} + +afterEach(async () => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => fs.rm(directory, { recursive: true, force: true })), + ); +}); + +describe('workspace Skill management', () => { + it('installs folder files into the workspace and deletes them', async () => { + const workspace = await temporaryDirectory('qwen-skill-workspace-'); + const source = await temporaryDirectory('qwen-skill-source-'); + await fs.mkdir(path.join(source, 'references')); + await fs.writeFile( + path.join(source, 'SKILL.md'), + skillMarkdown('demo-skill'), + ); + await fs.writeFile( + path.join(source, 'references', 'example.md'), + 'example', + ); + + const result = await installWorkspaceSkill(workspace, { + name: 'demo-skill', + scope: 'workspace', + source: { type: 'folder', path: source }, + }); + + expect(result.installedPath).toBe( + path.join(workspace, '.qwen', 'skills', 'demo-skill', 'SKILL.md'), + ); + expect( + await fs.readFile( + path.join( + workspace, + '.qwen', + 'skills', + 'demo-skill', + 'references', + 'example.md', + ), + 'utf8', + ), + ).toBe('example'); + + await deleteWorkspaceSkill( + workspace, + 'workspace', + 'demo-skill', + result.installedPath!, + ); + await expect( + fs.access(path.dirname(result.installedPath!)), + ).rejects.toThrow(); + }); + + it('installs a ZIP into the global Skill directory', async () => { + const workspace = await temporaryDirectory('qwen-skill-workspace-'); + const globalDirectory = await temporaryDirectory('qwen-skill-global-'); + vi.spyOn(Storage, 'getGlobalQwenDir').mockReturnValue(globalDirectory); + + const result = await installWorkspaceSkill(workspace, { + name: 'zip-skill', + scope: 'global', + source: { + type: 'zip', + contentBase64: await zip({ + 'zip-skill/SKILL.md': skillMarkdown('zip-skill'), + 'zip-skill/assets/data.txt': 'data', + '__MACOSX/zip-skill/._SKILL.md': 'finder metadata', + 'zip-skill/.DS_Store': 'finder metadata', + }), + }, + }); + + expect(result.installedPath).toBe( + path.join(globalDirectory, 'skills', 'zip-skill', 'SKILL.md'), + ); + await expect(fs.readFile(result.installedPath!, 'utf8')).resolves.toContain( + 'name: zip-skill', + ); + }); + + it('installs a Skill from a GitHub SKILL.md URL', async () => { + const workspace = await temporaryDirectory('qwen-skill-workspace-'); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify([ + { + name: 'SKILL.md', + path: 'SKILL.md', + type: 'file', + download_url: + 'https://raw.githubusercontent.com/owner/repo/main/SKILL.md', + }, + ]), + { status: 200 }, + ), + ) + .mockResolvedValueOnce( + new Response(skillMarkdown('github-skill'), { status: 200 }), + ), + ); + + const result = await installWorkspaceSkill( + workspace, + { + name: 'github-skill', + scope: 'workspace', + source: { + type: 'github', + url: 'https://github.com/owner/repo/blob/main/SKILL.md', + }, + }, + 'github-token', + ); + + await expect(fs.readFile(result.installedPath!, 'utf8')).resolves.toContain( + 'name: github-skill', + ); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenNthCalledWith( + 1, + 'https://api.github.com/repos/owner/repo/contents?ref=main', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer github-token', + }), + }), + ); + }); + + it('explains when a GitHub Skill path does not exist', async () => { + const workspace = await temporaryDirectory('qwen-skill-workspace-'); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response(null, { status: 404 })), + ); + + await expect( + installWorkspaceSkill(workspace, { + name: 'missing-skill', + scope: 'workspace', + source: { + type: 'github', + url: 'https://github.com/owner/repo/blob/main/missing/SKILL.md', + }, + }), + ).rejects.toMatchObject({ + code: 'github_api_failed', + message: expect.stringContaining('repository URL, branch, and path'), + statusCode: 404, + }); + }); + + it('explains when GitHub denies access to a Skill file', async () => { + const workspace = await temporaryDirectory('qwen-skill-workspace-'); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify([ + { + name: 'SKILL.md', + path: 'SKILL.md', + type: 'file', + download_url: + 'https://raw.githubusercontent.com/owner/repo/main/SKILL.md', + }, + ]), + { status: 200 }, + ), + ) + .mockResolvedValueOnce(new Response(null, { status: 403 })), + ); + + await expect( + installWorkspaceSkill(workspace, { + name: 'private-skill', + scope: 'workspace', + source: { + type: 'github', + url: 'https://github.com/owner/repo/blob/main/SKILL.md', + }, + }), + ).rejects.toMatchObject({ + code: 'github_skill_download_failed', + message: expect.stringContaining('private or API rate-limited'), + statusCode: 502, + }); + }); + + it.each([ + [401, 'authentication failed'], + [429, 'rate limit exceeded'], + ])( + 'explains GitHub Skill file HTTP %i failures', + async (status, expectedMessage) => { + const workspace = await temporaryDirectory('qwen-skill-workspace-'); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify([ + { + name: 'SKILL.md', + path: 'SKILL.md', + type: 'file', + download_url: + 'https://raw.githubusercontent.com/owner/repo/main/SKILL.md', + }, + ]), + { status: 200 }, + ), + ) + .mockResolvedValueOnce(new Response(null, { status })), + ); + + await expect( + installWorkspaceSkill(workspace, { + name: 'unavailable-skill', + scope: 'workspace', + source: { + type: 'github', + url: 'https://github.com/owner/repo/blob/main/SKILL.md', + }, + }), + ).rejects.toMatchObject({ + code: 'github_skill_download_failed', + message: expect.stringContaining(expectedMessage), + statusCode: 502, + }); + }, + ); + + it('rejects an oversized Skill name before reading its source', async () => { + const workspace = await temporaryDirectory('qwen-skill-workspace-'); + const source = await temporaryDirectory('qwen-skill-source-'); + await fs.writeFile(path.join(source, 'SKILL.md'), skillMarkdown('demo')); + + await expect( + installWorkspaceSkill(workspace, { + name: 'x'.repeat(257), + scope: 'workspace', + source: { type: 'folder', path: source }, + }), + ).rejects.toMatchObject({ code: 'invalid_skill_name' }); + }); + + it('rejects relative folder paths before reading files', async () => { + const workspace = await temporaryDirectory('qwen-skill-workspace-'); + + await expect( + installWorkspaceSkill(workspace, { + name: 'unsafe-skill', + scope: 'workspace', + source: { type: 'folder', path: '../unsafe-skill' }, + }), + ).rejects.toThrow('must be absolute'); + }); + + it('reports malformed GitHub URLs as invalid sources', async () => { + const workspace = await temporaryDirectory('qwen-skill-workspace-'); + + await expect( + installWorkspaceSkill(workspace, { + name: 'invalid-source', + scope: 'workspace', + source: { type: 'github', url: 'not a URL' }, + }), + ).rejects.toMatchObject({ code: 'invalid_skill_source' }); + }); + + it.each(['--upload-pack', 'main%20--upload-pack'])( + 'rejects unsafe GitHub ref %s before making a request', + async (ref) => { + const workspace = await temporaryDirectory('qwen-skill-workspace-'); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect( + installWorkspaceSkill(workspace, { + name: 'invalid-ref', + scope: 'workspace', + source: { + type: 'github', + url: `https://github.com/owner/repo/blob/${ref}/SKILL.md`, + }, + }), + ).rejects.toMatchObject({ code: 'invalid_skill_source' }); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + it('rejects traversal in a GitHub Skill path before making a request', async () => { + const workspace = await temporaryDirectory('qwen-skill-workspace-'); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect( + installWorkspaceSkill(workspace, { + name: 'invalid-path', + scope: 'workspace', + source: { + type: 'github', + url: 'https://github.com/owner/repo/blob/main/..%2F../users/SKILL.md', + }, + }), + ).rejects.toMatchObject({ code: 'invalid_skill_source' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('stops reading an oversized GitHub Skill file', async () => { + const workspace = await temporaryDirectory('qwen-skill-workspace-'); + const oversizedBody = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(2 * 1024 * 1024)); + controller.enqueue(new Uint8Array(1)); + controller.close(); + }, + }); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify([ + { + name: 'SKILL.md', + path: 'SKILL.md', + type: 'file', + download_url: + 'https://raw.githubusercontent.com/owner/repo/main/SKILL.md', + }, + ]), + { status: 200 }, + ), + ) + .mockResolvedValueOnce(new Response(oversizedBody, { status: 200 })), + ); + + await expect( + installWorkspaceSkill(workspace, { + name: 'large-github-skill', + scope: 'workspace', + source: { + type: 'github', + url: 'https://github.com/owner/repo/blob/main/SKILL.md', + }, + }), + ).rejects.toMatchObject({ + code: 'skill_package_too_large', + statusCode: 413, + }); + }); + + it('rejects a symbolic link as the source folder', async () => { + const workspace = await temporaryDirectory('qwen-skill-workspace-'); + const source = await temporaryDirectory('qwen-skill-source-'); + const sourceLink = path.join(workspace, 'source-link'); + await fs.writeFile(path.join(source, 'SKILL.md'), skillMarkdown('linked')); + await fs.symlink(source, sourceLink); + + await expect( + installWorkspaceSkill(workspace, { + name: 'linked', + scope: 'workspace', + source: { type: 'folder', path: sourceLink }, + }), + ).rejects.toMatchObject({ code: 'invalid_skill_folder' }); + }); + + it('reports an expanded ZIP entry over the limit as too large', async () => { + const workspace = await temporaryDirectory('qwen-skill-workspace-'); + + await expect( + installWorkspaceSkill(workspace, { + name: 'large-skill', + scope: 'workspace', + source: { + type: 'zip', + contentBase64: await zip({ + 'large-skill/SKILL.md': skillMarkdown('large-skill'), + 'large-skill/asset.txt': 'x'.repeat(2 * 1024 * 1024 + 1), + }), + }, + }), + ).rejects.toMatchObject({ + code: 'skill_package_too_large', + statusCode: 413, + }); + }); + + it('reports a ZIP whose total expanded content is over the limit', async () => { + const workspace = await temporaryDirectory('qwen-skill-workspace-'); + const largeFile = 'x'.repeat(1_600_000); + + await expect( + installWorkspaceSkill(workspace, { + name: 'large-total-skill', + scope: 'workspace', + source: { + type: 'zip', + contentBase64: await zip({ + 'large-total-skill/SKILL.md': skillMarkdown('large-total-skill'), + 'large-total-skill/one.txt': largeFile, + 'large-total-skill/two.txt': largeFile, + 'large-total-skill/three.txt': largeFile, + 'large-total-skill/four.txt': largeFile, + }), + }, + }), + ).rejects.toMatchObject({ + code: 'skill_package_too_large', + statusCode: 413, + }); + }); + + it('keeps the existing Skill when replacement validation fails', async () => { + const workspace = await temporaryDirectory('qwen-skill-workspace-'); + const source = await temporaryDirectory('qwen-skill-source-'); + const invalidSource = await temporaryDirectory('qwen-skill-source-'); + await fs.writeFile( + path.join(source, 'SKILL.md'), + skillMarkdown('stable-skill'), + ); + await fs.writeFile( + path.join(invalidSource, 'SKILL.md'), + skillMarkdown('different-name'), + ); + const validRequest = { + name: 'stable-skill', + scope: 'workspace' as const, + source: { type: 'folder' as const, path: source }, + }; + const installed = await installWorkspaceSkill(workspace, validRequest); + + await expect( + installWorkspaceSkill(workspace, { + ...validRequest, + source: { type: 'folder', path: invalidSource }, + }), + ).rejects.toThrow('does not match requested name'); + await expect( + fs.readFile(installed.installedPath!, 'utf8'), + ).resolves.toContain('name: stable-skill'); + }); + + it('keeps a committed replacement when backup cleanup fails', async () => { + const workspace = await temporaryDirectory('qwen-skill-workspace-'); + const source = await temporaryDirectory('qwen-skill-source-'); + const replacement = await temporaryDirectory('qwen-skill-source-'); + await fs.writeFile( + path.join(source, 'SKILL.md'), + skillMarkdown('stable-skill'), + ); + await fs.writeFile( + path.join(replacement, 'SKILL.md'), + `${skillMarkdown('stable-skill')}Replacement instructions.`, + ); + const request = { + name: 'stable-skill', + scope: 'workspace' as const, + source: { type: 'folder' as const, path: source }, + }; + await installWorkspaceSkill(workspace, request); + vi.spyOn(fs, 'rm').mockRejectedValueOnce(new Error('cleanup failed')); + + const result = await installWorkspaceSkill(workspace, { + ...request, + source: { type: 'folder', path: replacement }, + }); + + await expect(fs.readFile(result.installedPath!, 'utf8')).resolves.toContain( + 'Replacement instructions.', + ); + await expect( + fs.readdir(path.join(workspace, '.qwen', 'skills')), + ).resolves.toEqual(['stable-skill']); + }); + + it('preserves the install error when staging cleanup fails', async () => { + const workspace = await temporaryDirectory('qwen-skill-workspace-'); + const source = await temporaryDirectory('qwen-skill-source-'); + await fs.writeFile( + path.join(source, 'SKILL.md'), + skillMarkdown('different-name'), + ); + vi.spyOn(fs, 'rm').mockRejectedValueOnce(new Error('cleanup failed')); + + await expect( + installWorkspaceSkill(workspace, { + name: 'expected-name', + scope: 'workspace', + source: { type: 'folder', path: source }, + }), + ).rejects.toThrow('does not match requested name'); + await expect( + fs.readdir(path.join(workspace, '.qwen', 'skills')), + ).resolves.toEqual([]); + }); + + it('removes legacy install artifacts before installing', async () => { + const workspace = await temporaryDirectory('qwen-skill-workspace-'); + const source = await temporaryDirectory('qwen-skill-source-'); + const baseDir = path.join(workspace, '.qwen', 'skills'); + const legacyBackup = path.join(baseDir, '.stable-skill.backup-legacy'); + const staleBackup = path.join( + path.dirname(baseDir), + '.skills-stable-skill.backup-stale', + ); + await fs.mkdir(legacyBackup, { recursive: true }); + await fs.mkdir(staleBackup, { recursive: true }); + await fs.writeFile( + path.join(legacyBackup, 'SKILL.md'), + skillMarkdown('stable-skill'), + ); + await fs.writeFile( + path.join(source, 'SKILL.md'), + skillMarkdown('stable-skill'), + ); + + await installWorkspaceSkill(workspace, { + name: 'stable-skill', + scope: 'workspace', + source: { type: 'folder', path: source }, + }); + + await expect(fs.access(legacyBackup)).rejects.toThrow(); + await expect(fs.access(staleBackup)).rejects.toThrow(); + }); + + it('restores the existing Skill when committing a replacement fails', async () => { + const workspace = await temporaryDirectory('qwen-skill-workspace-'); + const source = await temporaryDirectory('qwen-skill-source-'); + const replacement = await temporaryDirectory('qwen-skill-source-'); + await fs.writeFile( + path.join(source, 'SKILL.md'), + skillMarkdown('stable-skill'), + ); + await fs.writeFile( + path.join(replacement, 'SKILL.md'), + `${skillMarkdown('stable-skill')}Replacement instructions.`, + ); + const request = { + name: 'stable-skill', + scope: 'workspace' as const, + source: { type: 'folder' as const, path: source }, + }; + const installed = await installWorkspaceSkill(workspace, request); + const rename = fs.rename.bind(fs); + vi.spyOn(fs, 'rename').mockImplementation( + async (sourcePath, targetPath) => { + if (String(sourcePath).includes('.installing-')) { + throw new Error('commit failed'); + } + await rename(sourcePath, targetPath); + }, + ); + + await expect( + installWorkspaceSkill(workspace, { + ...request, + source: { type: 'folder', path: replacement }, + }), + ).rejects.toThrow('commit failed'); + await expect( + fs.readFile(installed.installedPath!, 'utf8'), + ).resolves.not.toContain('Replacement instructions.'); + }); +}); diff --git a/packages/cli/src/serve/workspace-skill-management.ts b/packages/cli/src/serve/workspace-skill-management.ts new file mode 100644 index 00000000000..51ca227829b --- /dev/null +++ b/packages/cli/src/serve/workspace-skill-management.ts @@ -0,0 +1,880 @@ +import { promises as fs } from 'node:fs'; +import { execFile } from 'node:child_process'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { promisify } from 'node:util'; + +import { SkillManager, Storage, type Config } from '@qwen-code/qwen-code-core'; +import { fromBuffer, type Entry, type ZipFile } from 'yauzl'; + +export type WorkspaceSkillScope = 'workspace' | 'global'; + +export type WorkspaceSkillInstallSource = + | { type: 'github'; url: string } + | { type: 'folder'; path: string } + | { type: 'zip'; contentBase64: string }; + +export interface WorkspaceSkillInstallRequest { + name: string; + scope: WorkspaceSkillScope; + source: WorkspaceSkillInstallSource; +} + +export interface WorkspaceSkillMutationResult { + skillName: string; + scope: WorkspaceSkillScope; + installedPath?: string; + deleted?: boolean; +} + +interface SkillPackageFile { + relativePath: string; + content: Buffer; +} + +const MAX_FILES = 128; +const MAX_FILE_BYTES = 2 * 1024 * 1024; +const MAX_TOTAL_BYTES = 6 * 1024 * 1024; +const MAX_PATH_LENGTH = 512; +const MAX_PATH_DEPTH = 16; +export const MAX_WORKSPACE_SKILL_NAME_LENGTH = 256; +const execFileAsync = promisify(execFile); + +export class WorkspaceSkillManagementError extends Error { + constructor( + readonly code: string, + message: string, + readonly statusCode = 400, + ) { + super(message); + this.name = 'WorkspaceSkillManagementError'; + } +} + +function skillError(code: string, message: string, statusCode = 400): never { + throw new WorkspaceSkillManagementError(code, message, statusCode); +} + +export function validateWorkspaceSkillName(name: string): string { + const normalized = name.trim(); + if ( + !normalized || + normalized === '.' || + normalized === '..' || + normalized.length > MAX_WORKSPACE_SKILL_NAME_LENGTH || + !/^[A-Za-z0-9._-]+$/.test(normalized) + ) { + skillError('invalid_skill_name', 'Invalid skill name'); + } + return normalized; +} + +function decodeBase64(value: string): Buffer { + if (!value || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) { + skillError('invalid_skill_source', 'Invalid base64 skill content'); + } + const content = Buffer.from(value, 'base64'); + if (content.length > MAX_TOTAL_BYTES) { + skillError( + 'skill_package_too_large', + 'Skill package exceeds the allowed size', + 413, + ); + } + return content; +} + +function normalizeRelativePath(value: string): string { + const normalized = value.replaceAll('\\', '/').replace(/^\.\//, ''); + const segments = normalized.split('/'); + if ( + !normalized || + normalized.length > MAX_PATH_LENGTH || + normalized.startsWith('/') || + segments.length > MAX_PATH_DEPTH || + segments.some((segment) => !segment || segment === '.' || segment === '..') + ) { + skillError('invalid_skill_package', `Invalid skill file path: ${value}`); + } + return segments.join('/'); +} + +function isPlatformMetadataPath(value: string): boolean { + return value + .replaceAll('\\', '/') + .split('/') + .some( + (segment) => + segment === '__MACOSX' || + segment === '.DS_Store' || + segment.startsWith('._'), + ); +} + +function normalizePackageFiles(files: SkillPackageFile[]): SkillPackageFile[] { + files = files.filter((file) => !isPlatformMetadataPath(file.relativePath)); + if (!files.length || files.length > MAX_FILES) { + skillError( + 'invalid_skill_package', + 'Skill package has an invalid file count', + ); + } + let normalized = files.map((file) => ({ + relativePath: normalizeRelativePath(file.relativePath), + content: file.content, + })); + if (!normalized.some((file) => file.relativePath === 'SKILL.md')) { + const roots = new Set( + normalized.map((file) => file.relativePath.split('/')[0]), + ); + if (roots.size !== 1) { + skillError( + 'skill_manifest_missing', + 'Skill package must contain a root SKILL.md', + ); + } + const [root] = roots; + normalized = normalized.map((file) => ({ + ...file, + relativePath: file.relativePath.slice(root.length + 1), + })); + } + const seen = new Set(); + let totalBytes = 0; + for (const file of normalized) { + file.relativePath = normalizeRelativePath(file.relativePath); + if (seen.has(file.relativePath)) { + skillError( + 'invalid_skill_package', + `Duplicate skill file path: ${file.relativePath}`, + ); + } + seen.add(file.relativePath); + if (file.content.length > MAX_FILE_BYTES) { + skillError( + 'skill_package_too_large', + `Skill file is too large: ${file.relativePath}`, + 413, + ); + } + totalBytes += file.content.length; + if (totalBytes > MAX_TOTAL_BYTES) { + skillError( + 'skill_package_too_large', + 'Skill package exceeds the allowed size', + 413, + ); + } + } + if (!seen.has('SKILL.md')) { + skillError( + 'skill_manifest_missing', + 'Skill package must contain a root SKILL.md', + ); + } + return normalized; +} + +function githubRequestError( + code: 'github_api_failed' | 'github_skill_download_failed', + status: number, + resource: 'Skill path' | 'Skill file', +): WorkspaceSkillManagementError { + const message = + status === 404 + ? `GitHub ${resource} was not found; check the repository URL, branch, and path` + : status === 401 + ? 'GitHub authentication failed; check GH_TOKEN or GITHUB_TOKEN' + : status === 403 + ? 'GitHub access was denied; the repository may be private or API rate-limited' + : status === 429 + ? 'GitHub API rate limit exceeded; try again later or configure GH_TOKEN' + : `GitHub request failed (HTTP ${status})`; + return new WorkspaceSkillManagementError( + code, + message, + status === 404 ? 404 : 502, + ); +} + +async function fetchBytes(url: string, githubToken?: string): Promise { + const response = await fetch(url, { + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': 'qwen-code', + ...(githubToken ? { Authorization: `Bearer ${githubToken}` } : {}), + }, + }); + if (!response.ok) { + throw githubRequestError( + 'github_skill_download_failed', + response.status, + 'Skill file', + ); + } + const declared = Number(response.headers.get('content-length')); + if (Number.isFinite(declared) && declared > MAX_FILE_BYTES) { + skillError('skill_package_too_large', 'Skill file is too large', 413); + } + if (!response.body) return Buffer.alloc(0); + const reader = response.body.getReader(); + const chunks: Buffer[] = []; + let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > MAX_FILE_BYTES) { + await reader.cancel().catch(() => undefined); + skillError('skill_package_too_large', 'Skill file is too large', 413); + } + chunks.push(Buffer.from(value)); + } + } finally { + reader.releaseLock(); + } + return Buffer.concat(chunks, size); +} + +async function downloadGitHubDirectory( + owner: string, + repo: string, + ref: string, + directory: string, + githubToken?: string, + relativeRoot = '', + depth = 0, + state: { files: SkillPackageFile[]; totalBytes: number } = { + files: [], + totalBytes: 0, + }, +): Promise { + if (depth > MAX_PATH_DEPTH) + skillError('invalid_skill_package', 'GitHub Skill is nested too deeply'); + const encodedDirectory = directory + .split('/') + .filter(Boolean) + .map(encodeURIComponent) + .join('/'); + const apiUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents${encodedDirectory ? `/${encodedDirectory}` : ''}?ref=${encodeURIComponent(ref)}`; + const response = await fetch(apiUrl, { + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': 'qwen-code', + ...(githubToken ? { Authorization: `Bearer ${githubToken}` } : {}), + }, + }); + if (!response.ok) { + throw githubRequestError( + 'github_api_failed', + response.status, + 'Skill path', + ); + } + const items = (await response.json()) as unknown; + if (!Array.isArray(items)) + skillError('invalid_skill_source', 'GitHub URL must point to SKILL.md'); + for (const item of items) { + if (!item || typeof item !== 'object') continue; + const record = item as Record; + const itemName = typeof record['name'] === 'string' ? record['name'] : ''; + const itemPath = typeof record['path'] === 'string' ? record['path'] : ''; + const itemType = typeof record['type'] === 'string' ? record['type'] : ''; + const relativePath = relativeRoot + ? `${relativeRoot}/${itemName}` + : itemName; + if (itemType === 'dir') { + await downloadGitHubDirectory( + owner, + repo, + ref, + itemPath, + githubToken, + relativePath, + depth + 1, + state, + ); + } else if (itemType === 'file') { + const downloadUrl = record['download_url']; + if (typeof downloadUrl !== 'string') continue; + const parsedDownloadUrl = new URL(downloadUrl); + if ( + parsedDownloadUrl.protocol !== 'https:' || + parsedDownloadUrl.hostname !== 'raw.githubusercontent.com' + ) { + skillError( + 'github_skill_download_failed', + 'GitHub returned an invalid Skill file URL', + 502, + ); + } + const content = await fetchBytes(downloadUrl, githubToken); + state.files.push({ relativePath, content }); + state.totalBytes += content.length; + if (state.files.length > MAX_FILES) + skillError('invalid_skill_package', 'Skill package has too many files'); + if (state.totalBytes > MAX_TOTAL_BYTES) { + skillError( + 'skill_package_too_large', + 'Skill package exceeds the allowed size', + 413, + ); + } + } + } + return state.files; +} + +async function downloadGitHubDirectoryWithGit( + owner: string, + repo: string, + ref: string, + directory: string, +): Promise { + const checkout = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-skill-git-')); + try { + await execFileAsync( + 'git', + [ + 'clone', + '--depth', + '1', + '--filter=blob:none', + '--sparse', + '--branch', + ref, + `https://github.com/${owner}/${repo}.git`, + checkout, + ], + { timeout: 60_000, maxBuffer: 1024 * 1024 }, + ); + if (directory) { + await execFileAsync( + 'git', + ['-C', checkout, 'sparse-checkout', 'set', '--cone', directory], + { timeout: 30_000, maxBuffer: 1024 * 1024 }, + ); + } else { + await execFileAsync( + 'git', + ['-C', checkout, 'sparse-checkout', 'disable'], + { timeout: 30_000, maxBuffer: 1024 * 1024 }, + ); + } + return await filesFromFolder(path.join(checkout, directory)); + } catch (error) { + if (error instanceof WorkspaceSkillManagementError) throw error; + throw new WorkspaceSkillManagementError( + 'github_skill_download_failed', + `Failed to download GitHub Skill: ${error instanceof Error ? error.message : String(error)}`, + 502, + ); + } finally { + await fs.rm(checkout, { recursive: true, force: true }); + } +} + +async function downloadGitHubSkill( + sourceUrl: string, + githubToken?: string, +): Promise { + let url: URL; + try { + url = new URL(sourceUrl); + } catch { + skillError('invalid_skill_source', 'Invalid GitHub Skill URL'); + } + if (url.protocol !== 'https:') + skillError('invalid_skill_source', 'GitHub URL must use HTTPS'); + if (url.hostname === 'raw.githubusercontent.com') { + if (!url.pathname.endsWith('/SKILL.md')) { + skillError('invalid_skill_source', 'GitHub URL must point to SKILL.md'); + } + return [ + { + relativePath: 'SKILL.md', + content: await fetchBytes(url.toString(), githubToken), + }, + ]; + } + if (url.hostname !== 'github.com') { + skillError('invalid_skill_source', 'Only GitHub Skill URLs are supported'); + } + let segments: string[]; + try { + segments = url.pathname.split('/').filter(Boolean).map(decodeURIComponent); + } catch { + skillError('invalid_skill_source', 'Invalid GitHub Skill URL'); + } + if ( + segments.length < 5 || + segments[2] !== 'blob' || + segments.at(-1) !== 'SKILL.md' + ) { + skillError( + 'invalid_skill_source', + 'GitHub URL must point to a repository SKILL.md file', + ); + } + const [owner, repo, , ref, ...filePath] = segments; + if ( + !owner || + !repo || + !ref || + ref.startsWith('-') || + !/^[A-Za-z0-9._/+-]+$/.test(ref) || + !/^[A-Za-z0-9._-]+$/.test(owner) || + !/^[A-Za-z0-9._-]+$/.test(repo) + ) { + skillError('invalid_skill_source', 'Invalid GitHub Skill URL'); + } + const directory = filePath.slice(0, -1).join('/'); + const directorySegments = directory.split('/'); + if ( + directory && + (directory.length > MAX_PATH_LENGTH || + directory.includes('\\') || + directory.startsWith('/') || + directorySegments.length > MAX_PATH_DEPTH || + directorySegments.some( + (segment) => !segment || segment === '.' || segment === '..', + )) + ) { + skillError('invalid_skill_source', 'Invalid GitHub Skill URL path'); + } + try { + return await downloadGitHubDirectory( + owner, + repo, + ref, + directory, + githubToken, + ); + } catch (error) { + if ( + !(error instanceof WorkspaceSkillManagementError) || + error.code !== 'github_api_failed' || + error.statusCode === 404 + ) { + throw error; + } + return downloadGitHubDirectoryWithGit(owner, repo, ref, directory); + } +} + +function openZip(content: Buffer): Promise { + return new Promise((resolve, reject) => { + fromBuffer(content, { lazyEntries: true }, (error, zipFile) => { + if (error) reject(error); + else resolve(zipFile); + }); + }); +} + +function readZipEntry(zipFile: ZipFile, entry: Entry): Promise { + return new Promise((resolve, reject) => { + zipFile.openReadStream(entry, (error, stream) => { + if (error) { + reject(error); + return; + } + const chunks: Buffer[] = []; + let size = 0; + stream.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_FILE_BYTES) { + stream.destroy( + new WorkspaceSkillManagementError( + 'skill_package_too_large', + `Skill ZIP entry is too large: ${entry.fileName}`, + 413, + ), + ); + } else { + chunks.push(chunk); + } + }); + stream.on('error', reject); + stream.on('end', () => resolve(Buffer.concat(chunks))); + }); + }); +} + +async function filesFromZip(content: Buffer): Promise { + const zipFile = await openZip(content); + const files: SkillPackageFile[] = []; + let totalBytes = 0; + return new Promise((resolve, reject) => { + let settled = false; + const finish = (error?: unknown) => { + if (settled) return; + settled = true; + zipFile.close(); + if (error) reject(error); + else resolve(files); + }; + zipFile.on('error', finish); + zipFile.on('entry', (entry: Entry) => { + const mode = (entry.externalFileAttributes >>> 16) & 0xffff; + const fileType = mode & 0xf000; + if (fileType === 0xa000) { + finish(new Error('Skill ZIP contains a symbolic link')); + return; + } + if (entry.generalPurposeBitFlag & 1) { + finish(new Error('Encrypted Skill ZIP entries are not supported')); + return; + } + if (entry.fileName.endsWith('/') || fileType === 0x4000) { + zipFile.readEntry(); + return; + } + if (files.length >= MAX_FILES) { + finish( + new WorkspaceSkillManagementError( + 'invalid_skill_package', + 'Skill ZIP contains too many files', + ), + ); + return; + } + if (entry.uncompressedSize > MAX_FILE_BYTES) { + finish( + new WorkspaceSkillManagementError( + 'skill_package_too_large', + 'Skill ZIP entry exceeds the allowed size', + 413, + ), + ); + return; + } + if (totalBytes + entry.uncompressedSize > MAX_TOTAL_BYTES) { + finish( + new WorkspaceSkillManagementError( + 'skill_package_too_large', + 'Skill ZIP expands beyond the allowed size', + 413, + ), + ); + return; + } + void readZipEntry(zipFile, entry).then((entryContent) => { + totalBytes += entryContent.length; + if (totalBytes > MAX_TOTAL_BYTES) { + finish( + new WorkspaceSkillManagementError( + 'skill_package_too_large', + 'Skill ZIP expands beyond the allowed size', + 413, + ), + ); + return; + } + files.push({ relativePath: entry.fileName, content: entryContent }); + if (!settled) zipFile.readEntry(); + }, finish); + }); + zipFile.on('end', () => finish()); + zipFile.readEntry(); + }); +} + +async function filesFromSource( + source: WorkspaceSkillInstallSource, + githubToken?: string, +): Promise { + try { + if (source.type === 'github') + return normalizePackageFiles( + await downloadGitHubSkill(source.url, githubToken), + ); + if (source.type === 'folder') + return normalizePackageFiles(await filesFromFolder(source.path)); + const archive = decodeBase64(source.contentBase64); + return normalizePackageFiles(await filesFromZip(archive)); + } catch (error) { + if (error instanceof WorkspaceSkillManagementError) throw error; + const code = + source.type === 'github' + ? 'github_skill_download_failed' + : source.type === 'folder' + ? 'invalid_skill_folder' + : 'invalid_skill_package'; + throw new WorkspaceSkillManagementError( + code, + error instanceof Error ? error.message : String(error), + source.type === 'github' ? 502 : 400, + ); + } +} + +async function filesFromFolder( + folderPath: string, +): Promise { + if (!path.isAbsolute(folderPath)) { + skillError('invalid_skill_folder', 'Skill folder path must be absolute'); + } + if ((await fs.lstat(folderPath)).isSymbolicLink()) { + skillError( + 'invalid_skill_folder', + 'Skill folder path must not be a symbolic link', + ); + } + const root = await fs.realpath(folderPath); + if (!(await fs.stat(root)).isDirectory()) { + skillError( + 'invalid_skill_folder', + 'Skill folder path must point to a directory', + ); + } + const files: SkillPackageFile[] = []; + let totalBytes = 0; + const visit = async (directory: string, relativeRoot = '', depth = 0) => { + if (depth > MAX_PATH_DEPTH) + skillError('invalid_skill_package', 'Skill folder is nested too deeply'); + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + if (depth === 0 && entry.name === '.git') continue; + const absolutePath = path.join(directory, entry.name); + const relativePath = relativeRoot + ? `${relativeRoot}/${entry.name}` + : entry.name; + if (entry.isSymbolicLink()) { + skillError( + 'unsafe_skill_path', + 'Skill folder contains a symbolic link', + ); + } + if (entry.isDirectory()) { + await visit(absolutePath, relativePath, depth + 1); + continue; + } + if (!entry.isFile()) + skillError('unsafe_skill_path', 'Skill folder contains a special file'); + if (files.length >= MAX_FILES) { + skillError('invalid_skill_package', 'Skill package has too many files'); + } + const stats = await fs.lstat(absolutePath); + if (stats.isSymbolicLink() || !stats.isFile()) { + skillError('unsafe_skill_path', 'Skill folder contains an unsafe file'); + } + if (stats.size > MAX_FILE_BYTES) { + skillError( + 'skill_package_too_large', + `Skill file is too large: ${relativePath}`, + 413, + ); + } + totalBytes += stats.size; + if (totalBytes > MAX_TOTAL_BYTES) { + skillError( + 'skill_package_too_large', + 'Skill package exceeds the allowed size', + 413, + ); + } + const content = await fs.readFile(absolutePath); + if (content.length !== stats.size) { + skillError( + 'invalid_skill_package', + `Skill file changed while reading: ${relativePath}`, + ); + } + files.push({ relativePath, content }); + } + }; + await visit(root); + return files; +} + +function skillBaseDir(workspace: string, scope: WorkspaceSkillScope): string { + return scope === 'workspace' + ? path.join(workspace, '.qwen', 'skills') + : path.join(Storage.getGlobalQwenDir(), 'skills'); +} + +async function removeInstallArtifacts( + baseDir: string, + skillName: string, +): Promise { + const workDir = path.dirname(baseDir); + const workPrefix = `.${path.basename(baseDir)}-${skillName}`; + const locations = [ + { + directory: baseDir, + prefixes: [`.${skillName}.installing-`, `.${skillName}.backup-`], + }, + { + directory: workDir, + prefixes: [`${workPrefix}.installing-`, `${workPrefix}.backup-`], + }, + ]; + for (const { directory, prefixes } of locations) { + const entries = await fs.readdir(directory, { withFileTypes: true }); + await Promise.all( + entries + .filter((entry) => + prefixes.some((prefix) => entry.name.startsWith(prefix)), + ) + .map((entry) => + fs.rm(path.join(directory, entry.name), { + recursive: true, + force: true, + }), + ), + ); + } +} + +async function ensureDirectoryWithoutSymlinks( + directory: string, +): Promise { + const missing: string[] = []; + let current = directory; + while (true) { + try { + const stats = await fs.lstat(current); + if (stats.isSymbolicLink() || !stats.isDirectory()) { + skillError('unsafe_skill_path', `Unsafe Skill directory: ${current}`); + } + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + missing.push(current); + const parent = path.dirname(current); + if (parent === current) throw error; + current = parent; + } + } + for (const entry of missing.reverse()) await fs.mkdir(entry); +} + +export async function installWorkspaceSkill( + workspace: string, + request: WorkspaceSkillInstallRequest, + githubToken?: string, +): Promise { + const skillName = validateWorkspaceSkillName(request.name); + const files = await filesFromSource(request.source, githubToken); + const baseDir = skillBaseDir(workspace, request.scope); + await ensureDirectoryWithoutSymlinks(baseDir); + await removeInstallArtifacts(baseDir, skillName); + const destination = path.join(baseDir, skillName); + const existing = await fs.lstat(destination).catch(() => undefined); + if (existing?.isSymbolicLink() || (existing && !existing.isDirectory())) { + skillError('unsafe_skill_path', 'Refusing to replace an unsafe Skill path'); + } + const workDir = path.dirname(baseDir); + const workPrefix = `.${path.basename(baseDir)}-${skillName}`; + const staging = await fs.mkdtemp( + path.join(workDir, `${workPrefix}.installing-`), + ); + const backup = path.join(workDir, `${workPrefix}.backup-${Date.now()}`); + let movedExisting = false; + try { + for (const file of files) { + const target = path.join(staging, ...file.relativePath.split('/')); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, file.content); + } + const skillFile = path.join(staging, 'SKILL.md'); + let parsed: ReturnType; + try { + parsed = new SkillManager({} as Config).parseSkillContent( + await fs.readFile(skillFile, 'utf8'), + skillFile, + request.scope === 'workspace' ? 'project' : 'user', + ); + } catch (error) { + throw new WorkspaceSkillManagementError( + 'invalid_skill_manifest', + error instanceof Error ? error.message : String(error), + ); + } + if (parsed.name !== skillName) { + skillError( + 'skill_name_mismatch', + `Skill name "${parsed.name}" does not match requested name "${skillName}"`, + ); + } + if (existing) { + await fs.rename(destination, backup); + movedExisting = true; + } + await fs.rename(staging, destination); + } catch (error) { + await fs + .rm(staging, { recursive: true, force: true }) + .catch(() => undefined); + if (movedExisting) { + await fs.rename(backup, destination).catch(() => undefined); + } + throw error; + } + if (movedExisting) { + await fs + .rm(backup, { recursive: true, force: true }) + .catch(() => undefined); + } + return { + skillName, + scope: request.scope, + installedPath: path.join(destination, 'SKILL.md'), + }; +} + +export async function deleteWorkspaceSkill( + workspace: string, + scope: WorkspaceSkillScope, + skillNameInput: string, + installedPath: string, +): Promise { + const skillName = validateWorkspaceSkillName(skillNameInput); + const skillDir = path.resolve(path.dirname(installedPath)); + const skillFile = path.resolve(installedPath); + const allowedBaseDirs = new SkillManager({ + getProjectRoot: () => workspace, + } as Config) + .getSkillsBaseDirs(scope === 'workspace' ? 'project' : 'user') + .map((directory) => path.resolve(directory)); + const baseDir = path.dirname(skillDir); + if ( + skillFile !== path.join(skillDir, 'SKILL.md') || + !allowedBaseDirs.includes(baseDir) || + path.basename(skillDir) !== skillName + ) { + skillError( + 'skill_not_managed', + 'Skill is not managed in the requested scope', + 409, + ); + } + const baseRealPath = await fs.realpath(baseDir); + const skillDirStats = await fs.lstat(skillDir); + const skillFileStats = await fs.lstat(skillFile); + if ( + skillDirStats.isSymbolicLink() || + !skillDirStats.isDirectory() || + skillFileStats.isSymbolicLink() || + !skillFileStats.isFile() + ) { + skillError('unsafe_skill_path', 'Refusing to delete an unsafe Skill path'); + } + const skillDirRealPath = await fs.realpath(skillDir); + if (path.dirname(skillDirRealPath) !== baseRealPath) { + skillError('unsafe_skill_path', 'Refusing to delete an unsafe Skill path'); + } + const parsed = new SkillManager({} as Config).parseSkillContent( + await fs.readFile(skillFile, 'utf8'), + skillFile, + scope === 'workspace' ? 'project' : 'user', + ); + if (parsed.name !== skillName) { + skillError( + 'skill_name_mismatch', + 'Skill name does not match its installed directory', + ); + } + await fs.rm(skillDir, { recursive: true, force: true }); + return { skillName, scope, deleted: true }; +} diff --git a/packages/cli/src/serve/workspace-skills-status.ts b/packages/cli/src/serve/workspace-skills-status.ts index 7a417b01d7a..ac5a9f3ff95 100644 --- a/packages/cli/src/serve/workspace-skills-status.ts +++ b/packages/cli/src/serve/workspace-skills-status.ts @@ -38,9 +38,10 @@ import { loadSettings } from '../config/settings.js'; import { writeStderrLine } from '../utils/stdioHelpers.js'; import { mapSkillConfigToStatus } from './workspace-skills-mapping.js'; -export type WorkspaceSkillsStatusProvider = ( - workspaceCwd: string, -) => Promise; +export interface WorkspaceSkillsStatusProvider { + (workspaceCwd: string): Promise; + invalidate?(workspaceCwd: string): void; +} /** * The `Config` surface `SkillManager.listSkills()` actually reads. Declaring it @@ -64,7 +65,13 @@ export function createWorkspaceSkillsStatusProvider(): WorkspaceSkillsStatusProv // picked up until the daemon restarts — is acceptable: the live child // re-lists authoritatively once a session exists. const managers = new Map(); - return (workspaceCwd) => buildWorkspaceSkillsStatus(workspaceCwd, managers); + const provider = ((workspaceCwd: string) => + buildWorkspaceSkillsStatus( + workspaceCwd, + managers, + )) as WorkspaceSkillsStatusProvider; + provider.invalidate = (workspaceCwd) => managers.delete(workspaceCwd); + return provider; } async function buildWorkspaceSkillsStatus( diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index e79d45b92ad..d4a3767cd0d 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -55,7 +55,7 @@ const rootDir = join(__dirname, '..'); // Bumped from 154KB to 155KB after merging workspace skill-toggle APIs. // Bumped from 155KB to 160KB to accommodate recent growth and reduce churn // from repeated 1KB bumps as new daemon APIs are added. -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 160 * 1024; +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 161 * 1024; // The opt-in `daemon/transports` browser bundle legitimately ships the concrete // ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so // it's larger than the default barrel — but still budgeted so a future PR can't diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 5451bcd6655..308e7d62ce2 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -122,6 +122,9 @@ import type { DaemonRuntimeMcpRemoveResult, DaemonToolToggleResult, DaemonSkillToggleResult, + DaemonSkillInstallRequest, + DaemonSkillMutationResult, + DaemonSkillScope, DaemonSessionArtifactInput, DaemonSessionArtifactMutationResult, DaemonSessionArtifactsEnvelope, @@ -2746,6 +2749,26 @@ export class DaemonClient { ); } + installWorkspaceSkill( + request: DaemonSkillInstallRequest, + ): Promise { + return this.jsonRequest('/workspace/skills/install', 'Skill', { + method: 'POST', + body: request, + }); + } + + deleteWorkspaceSkill( + skillName: string, + scope: DaemonSkillScope, + ): Promise { + return this.jsonRequest( + `/workspace/skills/${urlEncode(skillName)}?scope=${scope}`, + 'Skill', + { method: 'DELETE' }, + ); + } + async workspaceSettings(opts?: { clientId?: string; }): Promise { diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 15072ee89ea..ce663204539 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -364,6 +364,10 @@ export type { DaemonToolToggleResult, DaemonSkillToggleActivation, DaemonSkillToggleResult, + DaemonSkillScope, + DaemonSkillInstallSource, + DaemonSkillInstallRequest, + DaemonSkillMutationResult, DaemonSettingDescriptor, DaemonPermissionRuleSet, DaemonPermissionRuleType, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 82df61dd2b6..62968275abf 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -2038,6 +2038,26 @@ export interface DaemonSkillToggleResult { sessionsFailed: number; } +export type DaemonSkillScope = 'workspace' | 'global'; + +export type DaemonSkillInstallSource = + | { type: 'github'; url: string } + | { type: 'folder'; path: string } + | { type: 'zip'; contentBase64: string }; + +export interface DaemonSkillInstallRequest { + name: string; + scope: DaemonSkillScope; + source: DaemonSkillInstallSource; +} + +export interface DaemonSkillMutationResult { + skillName: string; + scope: DaemonSkillScope; + installedPath?: string; + deleted?: boolean; +} + export interface DaemonSettingDescriptor { key: string; type: string; diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 10a6f10f0b4..8052e8cc66b 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -69,6 +69,10 @@ export { type DaemonToolToggleResult, type DaemonSkillToggleActivation, type DaemonSkillToggleResult, + type DaemonSkillScope, + type DaemonSkillInstallSource, + type DaemonSkillInstallRequest, + type DaemonSkillMutationResult, type DaemonToolToggledData, type DaemonToolToggledEvent, type DaemonTrustChangeRequestedData, diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index 2a626c5dea5..b13b0bd4abb 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -3445,6 +3445,57 @@ describe('DaemonClient', () => { }); }); + describe('workspace Skill management', () => { + it('uploads a Skill package', async () => { + const response = { + skillName: 'demo-skill', + scope: 'workspace', + installedPath: '/workspace/.qwen/skills/demo-skill/SKILL.md', + }; + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, response), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const request = { + name: 'demo-skill', + scope: 'workspace' as const, + source: { + type: 'folder' as const, + path: '/Users/example/skills/demo-skill', + }, + }; + + await expect(client.installWorkspaceSkill(request)).resolves.toEqual( + response, + ); + expect(calls[0]).toMatchObject({ + url: 'http://daemon/workspace/skills/install', + method: 'POST', + body: JSON.stringify(request), + }); + }); + + it('deletes a global Skill', async () => { + const response = { + skillName: 'demo-skill', + scope: 'global', + deleted: true, + }; + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, response), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.deleteWorkspaceSkill('demo-skill', 'global'), + ).resolves.toEqual(response); + expect(calls[0]).toMatchObject({ + url: 'http://daemon/workspace/skills/demo-skill?scope=global', + method: 'DELETE', + }); + }); + }); + describe('initWorkspace (#4175 Wave 4 PR 17)', () => { it('POSTs an empty body when force is omitted', async () => { const { fetch, calls } = recordingFetch(() => diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index f35ff18c98f..1b3bad25138 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -17,6 +17,7 @@ type MockConnection = { currentMode: string; models: Array<{ id: string; label?: string }>; commands: unknown[]; + skills: string[]; capabilities: { qwenCodeVersion: string; features: string[] }; loadingTranscript: boolean; catchingUp: boolean; @@ -33,6 +34,7 @@ type ChatEditorTestProps = { metadata?: { inputAnnotations?: DaemonInputAnnotation[] }, ) => boolean | void; skills?: Array<{ name: string; description: string }>; + commands?: Array<{ name: string }>; isPreparing?: boolean; dialogOpen?: boolean; placeholderText?: string; @@ -67,13 +69,16 @@ const { currentMode: 'default', models: [{ id: 'qwen', label: 'Qwen' }], commands: [], + skills: [], capabilities: { qwenCodeVersion: '1.2.3', features: [] }, loadingTranscript: false, catchingUp: false, }; + const loadSkillsStatus = vi.fn().mockResolvedValue({ skills: [] }); const workspaceClient = { workspaceByCwd: vi.fn(() => ({ workspaceGit: vi.fn().mockResolvedValue({ branch: 'main' }), + workspaceSkills: loadSkillsStatus, })), }; return { @@ -104,7 +109,7 @@ const { client: workspaceClient, }, mockWorkspaceActions: { - loadSkillsStatus: vi.fn().mockResolvedValue({ skills: [] }), + loadSkillsStatus, loadProviders: vi.fn().mockResolvedValue({ current: null }), loadPreflight: vi.fn().mockResolvedValue(null), loadEnv: vi.fn().mockResolvedValue(null), @@ -800,11 +805,14 @@ beforeEach(() => { mockConnection.error = undefined; mockConnection.errorStatus = undefined; mockConnection.missingSession = false; + mockConnection.commands = []; + mockConnection.skills = []; mockConnection.loadingTranscript = false; mockConnection.catchingUp = false; mockWorkspace.capabilities = { workspaces: [{ id: 'primary', cwd: '/workspace', primary: true }], }; + mockWorkspace.client.workspaceByCwd.mockClear(); testState.prompt = 'hello'; testState.inputAnnotations = undefined; testState.streamingState = 'idle'; @@ -925,6 +933,25 @@ describe('App session callbacks', () => { ); }); + it('reloads skills from the target workspace when starting a new session', async () => { + const { container } = renderApp({ + lockedWorkspaceCwd: '/work/secondary', + }); + await flush(); + mockWorkspace.client.workspaceByCwd.mockClear(); + + await act(async () => { + container + .querySelector('[data-testid="new-session"]') + ?.click(); + await Promise.resolve(); + }); + + expect(mockWorkspace.client.workspaceByCwd).toHaveBeenCalledWith( + '/work/secondary', + ); + }); + it('uses a registered capability fallback while the workspace list is stale', async () => { mockConnection.sessionId = undefined; mockWorkspace.capabilities = { @@ -1020,6 +1047,87 @@ describe('App session callbacks', () => { ]); }); + it('reloads skills when starting a new session', async () => { + mockConnection.commands = [ + { + name: 'review', + description: 'Review', + raw: { + name: 'review', + description: 'Review', + input: null, + _meta: { source: 'skill' }, + }, + }, + ]; + mockConnection.skills = ['review']; + mockWorkspaceActions.loadSkillsStatus.mockResolvedValue({ + skills: [{ name: 'review', description: 'Review', status: 'ok' }], + }); + const { container } = renderApp(); + await flush(); + expect(testState.latestChatEditorProps?.skills).toEqual([ + { name: 'review', description: 'Review' }, + ]); + expect(testState.latestChatEditorProps?.commands).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'review' })]), + ); + + mockWorkspaceActions.loadSkillsStatus.mockResolvedValue({ + skills: [{ name: 'review', description: 'Review', status: 'disabled' }], + }); + await act(async () => { + container + .querySelector('[data-testid="new-session"]') + ?.click(); + await Promise.resolve(); + }); + + expect(testState.latestChatEditorProps?.skills).toEqual([]); + expect(testState.latestChatEditorProps?.commands).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'review' })]), + ); + expect(mockWorkspaceActions.loadSkillsStatus).toHaveBeenCalledTimes(2); + }); + + it('adds an enabled skill command when starting a new session', async () => { + mockWorkspaceActions.loadSkillsStatus.mockResolvedValue({ + skills: [{ name: 'review', description: 'Review', status: 'disabled' }], + }); + const { container } = renderApp(); + await flush(); + expect(testState.latestChatEditorProps?.commands).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'review' })]), + ); + + mockWorkspaceActions.loadSkillsStatus.mockResolvedValue({ + skills: [ + { + name: 'review', + description: 'Review', + argumentHint: '', + status: 'ok', + }, + ], + }); + await act(async () => { + container + .querySelector('[data-testid="new-session"]') + ?.click(); + await Promise.resolve(); + }); + + expect(testState.latestChatEditorProps?.commands).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'review', + argumentHint: '', + source: 'skill', + }), + ]), + ); + }); + it.each([404, 410])( 'shows a missing-session empty state with a new-session action for %d', async (status) => { @@ -1765,6 +1873,25 @@ describe('App session callbacks', () => { expect(editorFocus).toHaveBeenCalled(); }); + it.each(['/skills', '/skills detail', '/skills details'])( + 'opens the Skill manager page with %s', + async (command) => { + const { container } = renderApp(); + await flush(); + + testState.prompt = command; + await clickSubmit(container); + await flush(); + + expect( + container + .querySelector('[data-testid="inline-panel"]') + ?.getAttribute('aria-label'), + ).toBe('Skills'); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + }, + ); + it('opens plugin management tabs from the sidebar', async () => { mockWorkspaceActions.loadMcpStatus.mockResolvedValue({ initialized: true, @@ -1791,9 +1918,21 @@ describe('App session callbacks', () => { expect(Array.from(tabs ?? []).map((tab) => tab.textContent)).toEqual([ 'Extensions', 'MCP', + 'Skills', ]); expect(extensionsTab?.getAttribute('aria-selected')).toBe('true'); expect(document.activeElement).toBe(extensionsTab); + + await act(async () => { + tabs?.[2]?.focus(); + tabs?.[2]?.click(); + await Promise.resolve(); + }); + expect( + panel + ?.querySelectorAll('button[role="tab"]')[2] + ?.getAttribute('aria-selected'), + ).toBe('true'); }); it('only shows server startup progress during MCP discovery', async () => { diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index cad39004a5f..a8faf5583cb 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -72,6 +72,7 @@ import { import { MemoryMessage } from './components/messages/MemoryMessage'; import { AuthMessage } from './components/messages/AuthMessage'; import { ToolsDialog } from './components/dialogs/ToolsDialog'; +import { SkillsManagerPage } from './components/skills/SkillsManagerPage'; import { DaemonStatusDialog } from './components/dialogs/DaemonStatusDialog'; import { SessionOverviewPanel } from './components/SessionOverviewPanel'; import { SplitView } from './components/SplitView'; @@ -302,13 +303,19 @@ interface PaneArtifactSnapshot { const BOUND_RUN_SWITCH_TIMEOUT_MS = 30_000; function availableSkillInfos(status: { - skills?: Array<{ status?: string; name: string; description?: string }>; + skills?: Array<{ + status?: string; + name: string; + description?: string; + argumentHint?: string; + }>; }): SkillInfo[] { return (status.skills ?? []) .filter((skill) => skill.status === 'ok') .map((skill) => ({ name: skill.name, description: skill.description ?? '', + ...(skill.argumentHint ? { argumentHint: skill.argumentHint } : {}), })) .sort((a, b) => a.name.localeCompare(b.name)); } @@ -2053,16 +2060,33 @@ export function App({ const showRetryHintRef = useRef(showRetryHint); showRetryHintRef.current = showRetryHint; const connected = connection.status === 'connected'; + const workspaceEventSignals = useWorkspaceEventSignals(); const [loadedSkills, setLoadedSkills] = useState([]); + const [loadedSkillsReady, setLoadedSkillsReady] = useState(false); + const loadedSkillsRequestRef = useRef(0); + const reloadLoadedSkills = useCallback( + async (workspaceCwd?: string) => { + const request = ++loadedSkillsRequestRef.current; + try { + const status = + workspaceCwd && workspace.client + ? await workspace.client + .workspaceByCwd(workspaceCwd) + .workspaceSkills() + : await workspaceActions.loadSkillsStatus(); + if (request !== loadedSkillsRequestRef.current) return; + setLoadedSkills(availableSkillInfos(status)); + setLoadedSkillsReady(true); + } catch { + return; + } + }, + [workspace.client, workspaceActions], + ); useEffect(() => { if (!connected) return; - workspaceActions - .loadSkillsStatus() - .then((status) => { - setLoadedSkills(availableSkillInfos(status)); - }) - .catch(() => {}); - }, [connected, workspaceActions]); + void reloadLoadedSkills(connection.workspaceCwd); + }, [connected, connection.workspaceCwd, reloadLoadedSkills]); const [modelDialogMode, setModelDialogMode] = useState(null); @@ -2108,9 +2132,26 @@ export function App({ // chat view (message list + composer), not as a modal overlay. Only one may be // active at a time; null means the normal chat view is shown. const [activePanel, setActivePanel] = useState< - 'settings' | 'status' | 'sessions' | 'extensions' | 'mcp' | 'plugins' | null + | 'settings' + | 'status' + | 'sessions' + | 'extensions' + | 'mcp' + | 'skills' + | 'plugins' + | null >(null); const closePanel = useCallback(() => setActivePanel(null), []); + const handleUseSkill = useCallback( + (name: string) => { + closePanel(); + window.setTimeout(() => { + editorRef.current?.setText(`/${name} `); + editorRef.current?.focus(); + }, 0); + }, + [closePanel], + ); // The Settings/Status panel (activePanel) and the Scheduled Tasks page // (mainView) are mutually-exclusive full-pane views — the latter is a // position:absolute overlay that would otherwise cover the former — so opening @@ -2125,6 +2166,7 @@ export function App({ | 'sessions' | 'extensions' | 'mcp' + | 'skills' | 'plugins', ) => { setMainView('chat'); @@ -2440,7 +2482,6 @@ export function App({ }, []); // Refresh commands when extensions change (install/uninstall/update). - const workspaceEventSignals = useWorkspaceEventSignals(); const extensionsVersionRef = useRef( workspaceEventSignals?.extensionsVersion ?? 0, ); @@ -3668,7 +3709,10 @@ export function App({ sessionActions as typeof sessionActions & SessionActionsWithCreate ).clearSession(); focusRequest = scheduleComposerFocus(); - await clearPromise; + await Promise.all([ + clearPromise, + reloadLoadedSkills(targetWorkspaceCwd), + ]); return true; } catch (error) { if (composerFocusRequestRef.current === focusRequest) { @@ -3683,6 +3727,7 @@ export function App({ closePanel, lockedWorkspaceCwd, reportError, + reloadLoadedSkills, scheduleComposerFocus, sessionActions, ], @@ -4466,7 +4511,9 @@ export function App({ } if (cmd === 'skills') { const skillArg = text.slice(match[0].length).trim(); - if (skillArg) { + if (!skillArg || skillArg === 'detail' || skillArg === 'details') { + openPanel('skills'); + } else { if (promptBlocked) { return enqueuePrompt( text, @@ -4482,31 +4529,6 @@ export function App({ 'Failed to send /skills command', { inputAnnotations: metadata?.inputAnnotations }, ); - } else { - if (echoOrDeferLocalCommand(text, images)) return true; - workspaceActions - .loadSkillsStatus() - .then((status) => { - const skills = availableSkillInfos(status); - setLoadedSkills(skills); - if (skills.length === 0) { - store.dispatch([ - { type: 'status', text: t('skills.none') }, - ]); - } else { - const list = skills.map((s) => `- ${s.name}`).join('\n'); - store.dispatch([ - { - type: 'status', - text: `${t('skills.available')}\n\n${list}`, - }, - ]); - } - resumeChatBottomFollow('smooth'); - }) - .catch((error: unknown) => { - reportError(error, 'Failed to load skills'); - }); } return true; } @@ -5481,8 +5503,31 @@ export function App({ }, [modelDialogMode, showFallbacksDialog, showAuthDialog]); const commands = useMemo(() => { + const previousSkillNames = new Set( + (connection.skills ?? []).map((skill) => skill.toLowerCase()), + ); + const retainedCommands = loadedSkillsReady + ? (connection.commands ?? []).filter( + (command) => + command.source !== 'skill' && + !previousSkillNames.has(command.name.toLowerCase()), + ) + : (connection.commands ?? []); + const refreshedSkillCommands = loadedSkillsReady + ? loadedSkills.map((skill) => ({ + name: skill.name, + description: skill.description, + ...(skill.argumentHint ? { argumentHint: skill.argumentHint } : {}), + source: 'skill', + displayCategory: 'skill' as const, + })) + : []; return localizeBuiltinDescriptions( - mergeCommands(connection.commands ?? [], getLocalCommands(t)), + mergeCommands( + retainedCommands, + refreshedSkillCommands, + getLocalCommands(t), + ), t, ) .filter( @@ -5497,7 +5542,14 @@ export function App({ description: t(skillKey), }; }); - }, [connection.commands, hiddenCommands, t]); + }, [ + connection.commands, + connection.skills, + hiddenCommands, + loadedSkills, + loadedSkillsReady, + t, + ]); const welcomeHeaderProps = useMemo( () => ({ @@ -6079,15 +6131,18 @@ export function App({ ? t('daemon.title') : activePanel === 'extensions' ? t('extensions.manage.title') - : activePanel === 'mcp' - ? t('mcp.title') - : activePanel === 'plugins' + : activePanel === 'mcp' + ? t('mcp.title') + : activePanel === 'skills' + ? t('skills.title') + : activePanel === 'plugins' ? t('plugins.title') : t('sessionsOverview.title') } > {activePanel !== 'extensions' && activePanel !== 'mcp' && + activePanel !== 'skills' && activePanel !== 'plugins' && (
) : null} @@ -88,6 +92,13 @@ export function PluginManagerPage({ onClose={onClose} embedded={embedded} /> + ) : activeTab === 'skills' ? ( + ) : mcpLoadError ? ( {t('plugins.mcpLoadFailed')} diff --git a/packages/web-shell/client/components/skills/SkillInstallDialog.tsx b/packages/web-shell/client/components/skills/SkillInstallDialog.tsx new file mode 100644 index 00000000000..555076cf5f0 --- /dev/null +++ b/packages/web-shell/client/components/skills/SkillInstallDialog.tsx @@ -0,0 +1,292 @@ +import { useState } from 'react'; +import { AlertCircleIcon, UploadIcon } from 'lucide-react'; +import type { + DaemonSkillInstallRequest, + DaemonSkillScope, +} from '@qwen-code/sdk/daemon'; +import { useI18n } from '../../i18n'; +import { extractErrorDetail } from '../../utils/errorDetail'; +import { Alert, AlertDescription } from '../ui/alert'; +import { Button } from '../ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '../ui/dialog'; +import { Input } from '../ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../ui/select'; +import { Spinner } from '../ui/spinner'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; + +type InstallSource = 'github' | 'folder' | 'zip'; +const MAX_SKILL_ZIP_BYTES = 6 * 1024 * 1024; + +function installErrorMessage( + error: unknown, + t: ReturnType['t'], +): string { + const body = + error && typeof error === 'object' + ? (error as { body?: unknown }).body + : undefined; + const code = + body && typeof body === 'object' + ? (body as { code?: unknown }).code + : undefined; + if (code === 'invalid_skill_source') + return t('skills.install.error.invalidSource'); + if (code === 'invalid_skill_scope') + return t('skills.install.error.invalidScope'); + if (code === 'invalid_skill_name') + return t('skills.install.error.invalidName'); + if (code === 'skill_manifest_missing') + return t('skills.install.error.manifestMissing'); + if ( + code === 'invalid_skill_package' || + code === 'invalid_skill_manifest' || + code === 'skill_name_mismatch' || + code === 'unsafe_skill_path' + ) { + return t('skills.install.error.invalidPackage'); + } + if (code === 'skill_package_too_large') + return t('skills.install.error.zipTooLarge'); + if (code === 'invalid_skill_folder') + return t('skills.install.error.invalidFolder'); + if (code === 'github_api_failed' || code === 'github_skill_download_failed') + return t('skills.install.error.githubFailed'); + if (code === 'skill_not_found') return t('skills.install.error.notFound'); + if (code === 'token_required') + return t('skills.install.error.authentication'); + if (code === 'untrusted_workspace') + return t('skills.install.error.untrusted'); + return extractErrorDetail(error) || t('skills.install.failed'); +} + +interface SkillInstallDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onInstall: (request: DaemonSkillInstallRequest) => Promise; +} + +async function fileToBase64(file: File): Promise { + const bytes = new Uint8Array(await file.arrayBuffer()); + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); + } + return btoa(binary); +} + +export function SkillInstallDialog({ + open, + onOpenChange, + onInstall, +}: SkillInstallDialogProps) { + const { t } = useI18n(); + const [name, setName] = useState(''); + const [scope, setScope] = useState('workspace'); + const [source, setSource] = useState('github'); + const [githubUrl, setGithubUrl] = useState(''); + const [folderPath, setFolderPath] = useState(''); + const [zip, setZip] = useState(null); + const [installing, setInstalling] = useState(false); + const [error, setError] = useState(null); + + function reset() { + setName(''); + setScope('workspace'); + setSource('github'); + setGithubUrl(''); + setFolderPath(''); + setZip(null); + setError(null); + } + + async function submit() { + setInstalling(true); + setError(null); + try { + if (!name.trim()) throw new Error(t('skills.install.error.nameRequired')); + let installSource: DaemonSkillInstallRequest['source']; + if (source === 'github') { + if (!githubUrl.trim()) + throw new Error(t('skills.install.error.githubRequired')); + installSource = { type: 'github', url: githubUrl.trim() }; + } else if (source === 'folder') { + if (!folderPath.trim()) + throw new Error(t('skills.install.error.folderRequired')); + installSource = { type: 'folder', path: folderPath.trim() }; + } else { + if (!zip) throw new Error(t('skills.install.selectZip')); + if (zip.size > MAX_SKILL_ZIP_BYTES) { + throw new Error(t('skills.install.error.zipTooLarge')); + } + installSource = { + type: 'zip', + contentBase64: await fileToBase64(zip), + }; + } + await onInstall({ name: name.trim(), scope, source: installSource }); + onOpenChange(false); + reset(); + } catch (installError) { + setError(installErrorMessage(installError, t)); + } finally { + setInstalling(false); + } + } + + return ( + { + if (installing) return; + onOpenChange(nextOpen); + if (!nextOpen) reset(); + }} + > + event.preventDefault()} + > + + {t('skills.install.title')} + + {t('skills.install.description')} + + +
+ {error ? ( + + + {error} + + ) : null} + + + setSource(value as InstallSource)} + > + + + GitHub + + + {t('skills.install.folder')} + + + ZIP + + + + + + + + + +
+ { + const file = event.target.files?.[0] ?? null; + setZip(file); + if (file) { + setName( + (currentName) => + currentName || file.name.replace(/\.zip$/i, ''), + ); + } + }} + disabled={installing} + /> + {zip ? ( +
+ {t('skills.install.zipSelected', { name: zip.name })} +
+ ) : null} +
+
+
+
+ + + + +
+
+ ); +} diff --git a/packages/web-shell/client/components/skills/SkillsManagerPage.module.css b/packages/web-shell/client/components/skills/SkillsManagerPage.module.css new file mode 100644 index 00000000000..952b318d2ba --- /dev/null +++ b/packages/web-shell/client/components/skills/SkillsManagerPage.module.css @@ -0,0 +1,26 @@ +.skillGrid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; +} + +@container panel-body (min-width: 600px) { + .skillGrid[data-column-count='2'], + .skillGrid[data-column-count='3'], + .skillGrid[data-column-count='4'] { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@container panel-body (min-width: 900px) { + .skillGrid[data-column-count='3'], + .skillGrid[data-column-count='4'] { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@container panel-body (min-width: 1200px) { + .skillGrid[data-column-count='4'] { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} diff --git a/packages/web-shell/client/components/skills/SkillsManagerPage.tsx b/packages/web-shell/client/components/skills/SkillsManagerPage.tsx new file mode 100644 index 00000000000..c40fcb77d81 --- /dev/null +++ b/packages/web-shell/client/components/skills/SkillsManagerPage.tsx @@ -0,0 +1,765 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + AlertCircleIcon, + ArrowLeftIcon, + EllipsisVerticalIcon, + InfoIcon, + PlayIcon, + PlusIcon, + RefreshCwIcon, + SearchIcon, + SparklesIcon, +} from 'lucide-react'; +import { + useSkills, + useWorkspace, + type DaemonWorkspaceSkillStatus, +} from '@qwen-code/webui/daemon-react-sdk'; +import { useI18n } from '../../i18n'; +import { + filterSkills, + preserveSkillSelection, + type SkillLevelFilter, + type SkillStatusFilter, +} from './skills-manager-logic'; +import { Alert, AlertDescription } from '../ui/alert'; +import { Badge } from '../ui/badge'; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from '../ui/breadcrumb'; +import { Button } from '../ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '../ui/card'; +import { Empty, EmptyHeader, EmptyMedia, EmptyTitle } from '../ui/empty'; +import { Input } from '../ui/input'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuTrigger, +} from '../ui/dropdown-menu'; +import { Spinner } from '../ui/spinner'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../ui/select'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '../ui/alert-dialog'; +import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '../ui/tooltip'; +import type { EmbeddedManagerPage } from '../plugins/manager-page'; +import { SkillInstallDialog } from './SkillInstallDialog'; +import styles from './SkillsManagerPage.module.css'; + +interface SkillsManagerPageProps { + onClose: () => void; + onUseSkill: (name: string) => void; + embedded?: EmbeddedManagerPage; +} + +function skillLevelLabel( + skill: DaemonWorkspaceSkillStatus, + t: ReturnType['t'], +): string { + return t(`skills.level.${skill.level}`); +} + +function skillStatusLabel( + skill: DaemonWorkspaceSkillStatus, + t: ReturnType['t'], +): string { + return t( + skill.status === 'disabled' + ? 'skills.status.disabled' + : 'skills.status.enabled', + ); +} + +function skillStatusBadgeClass(skill: DaemonWorkspaceSkillStatus): string { + return skill.status === 'disabled' + ? '' + : 'bg-[var(--success-bg)] text-[var(--success-color)]'; +} + +function toggleErrorMessage( + error: unknown, + t: ReturnType['t'], +): string { + const body = + error && typeof error === 'object' + ? (error as { body?: unknown }).body + : undefined; + const code = + body && typeof body === 'object' + ? (body as { code?: unknown }).code + : undefined; + if (code === 'skill_inactive_extension') { + return t('skills.error.inactiveExtension'); + } + if (code === 'skill_not_toggleable') return t('skills.notToggleable'); + return error instanceof Error ? error.message : t('skills.toggleFailed'); +} + +function DetailField({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function ManualReferenceBadge({ compact = false }: { compact?: boolean }) { + const { t } = useI18n(); + return ( + + + + + {t('skills.manualReference')} + + + {t('skills.manualReferenceHint')} + + + ); +} + +export function SkillsManagerPage({ + onClose, + onUseSkill, + embedded, +}: SkillsManagerPageProps) { + const { t } = useI18n(); + const workspace = useWorkspace(); + const { + status, + skills, + loading, + error, + reload, + setEnabled, + install, + remove, + } = useSkills({ autoLoad: true }); + const canToggleSkills = + workspace.capabilities?.features.includes('workspace_skill_toggle') === + true; + const canManageSkills = + workspace.capabilities?.features.includes('workspace_skill_manage') === + true; + const [query, setQuery] = useState(''); + const [levelFilter, setLevelFilter] = useState('all'); + const [statusFilter, setStatusFilter] = + useState('enabled'); + const [statusOverrides, setStatusOverrides] = useState< + Record + >({}); + const [selectedName, setSelectedName] = useState(null); + const [busySkill, setBusySkill] = useState(null); + const [installOpen, setInstallOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [listNotice, setListNotice] = useState(null); + const [notice, setNotice] = useState<{ + skillName: string; + text: string; + error: boolean; + } | null>(null); + const displayedSkills = useMemo( + () => + skills.map((skill) => ({ + ...skill, + status: statusOverrides[skill.name] ?? skill.status, + })), + [skills, statusOverrides], + ); + const selectedSkill = useMemo( + () => displayedSkills.find((skill) => skill.name === selectedName), + [displayedSkills, selectedName], + ); + const filteredSkills = useMemo( + () => filterSkills(displayedSkills, query, levelFilter, statusFilter), + [displayedSkills, levelFilter, query, statusFilter], + ); + const disabledCount = displayedSkills.filter( + (skill) => skill.status === 'disabled', + ).length; + const message = error?.message ?? status?.errors?.[0]?.error; + const levelOptions: Array<{ + value: SkillLevelFilter; + label: string; + }> = [ + { value: 'all', label: t('skills.filter.all') }, + { value: 'user', label: t('skills.filter.user') }, + { value: 'project', label: t('skills.filter.project') }, + { value: 'extension', label: t('skills.filter.extension') }, + { value: 'bundled', label: t('skills.filter.bundled') }, + ]; + + useEffect(() => { + setSelectedName((name) => preserveSkillSelection(name, displayedSkills)); + }, [displayedSkills]); + + useEffect(() => { + setStatusOverrides((current) => { + const next = { ...current }; + let changed = false; + for (const skill of skills) { + if (next[skill.name] === skill.status) { + delete next[skill.name]; + changed = true; + } + } + return changed ? next : current; + }); + }, [skills]); + + useEffect(() => { + embedded?.onDetailChange(Boolean(selectedSkill)); + }, [embedded, selectedSkill]); + + async function toggleSkill(skill: DaemonWorkspaceSkillStatus) { + const enabled = skill.status === 'disabled'; + setBusySkill(skill.name); + setNotice(null); + try { + await setEnabled(skill.name, enabled); + setStatusOverrides((current) => ({ + ...current, + [skill.name]: enabled ? 'ok' : 'disabled', + })); + await reload(); + setNotice({ + skillName: skill.name, + text: t(enabled ? 'skills.enabled' : 'skills.disabled'), + error: false, + }); + } catch (toggleError) { + setNotice({ + skillName: skill.name, + text: toggleErrorMessage(toggleError, t), + error: true, + }); + } finally { + setBusySkill(null); + } + } + + async function installSkill( + request: Parameters[0], + ): Promise { + setListNotice(null); + await install(request); + setListNotice(t('skills.install.succeeded', { name: request.name.trim() })); + await reload().catch(() => undefined); + } + + async function deleteSkill(): Promise { + if (!selectedSkill) return; + const scope = selectedSkill.level === 'project' ? 'workspace' : 'global'; + setBusySkill(selectedSkill.name); + try { + await remove(selectedSkill.name, scope); + setDeleteOpen(false); + setSelectedName(null); + setListNotice(t('skills.delete.succeeded', { name: selectedSkill.name })); + await reload().catch(() => undefined); + } catch (deleteError) { + setDeleteOpen(false); + setNotice({ + skillName: selectedSkill.name, + text: + deleteError instanceof Error + ? deleteError.message + : t('skills.delete.failed'), + error: true, + }); + } finally { + setBusySkill(null); + } + } + + function returnToList(): void { + setSelectedName(null); + void reload(); + } + + const standaloneNavigation = ( + + + + + + + {selectedSkill ? ( + + + + ) : ( + {t('skills.title')} + )} + + {selectedSkill ? : null} + {selectedSkill ? ( + + {selectedSkill.name} + + ) : null} + + + ); + const navigation = embedded ? ( + selectedSkill ? ( + + + + + + + + + + {selectedSkill.name} + + + + ) : null + ) : ( + standaloneNavigation + ); + if (selectedSkill) { + const invocation = `/${selectedSkill.name}${ + selectedSkill.argumentHint ? ` ${selectedSkill.argumentHint}` : '' + }`; + return ( +
+ {navigation} +
+
+
+ +
+
+
+

+ {selectedSkill.name} +

+ + {skillLevelLabel(selectedSkill, t)} + + + {skillStatusLabel(selectedSkill, t)} + + {!selectedSkill.modelInvocable ? ( + + ) : null} +
+
+ + + + + + event.preventDefault()} + > + + void toggleSkill(selectedSkill)} + > + {t( + selectedSkill.status === 'disabled' + ? 'skills.enable' + : 'skills.disable', + )} + + {canManageSkills && + (selectedSkill.level === 'project' || + selectedSkill.level === 'user') ? ( + setDeleteOpen(true)} + > + {t('skills.delete.action')} + + ) : null} + + + +
+ + {notice?.skillName === selectedSkill.name ? ( + + {notice.error ? : } + {notice.text} + + ) : null} + + {message || selectedSkill.error ? ( + + + + {selectedSkill.error || message} + + + ) : null} + + + + {t('skills.details')} + + {selectedSkill.description || t('skills.noDescription')} + + + + + + + + + {selectedSkill.hint ? ( +
+ +
+ ) : null} +
+
+ { + if (!open && busySkill !== null) return; + setDeleteOpen(open); + }} + > + + + {t('skills.delete.title')} + + {t('skills.delete.description', { + name: selectedSkill.name, + })} + + + + + {t('common.cancel')} + + { + event.preventDefault(); + void deleteSkill(); + }} + > + {busySkill ? : null} + {t('skills.delete.action')} + + + + +
+
+ ); + } + + return ( +
+ {navigation} +
+
+
+

+ {t('skills.title')} +

+

+ {t('skills.count', { + count: skills.length, + enabled: skills.length - disabledCount, + disabled: disabledCount, + })} +

+
+
+ {canManageSkills ? ( + + ) : null} + +
+
+ + {message ? ( + + + {message} + + ) : null} + + {listNotice ? ( + + + {listNotice} + + ) : null} + +
+ + setQuery(event.target.value)} + placeholder={t('skills.search')} + className="pl-9" + /> +
+ +
+ { + if (value) setLevelFilter(value as SkillLevelFilter); + }} + variant="outline" + size="sm" + aria-label={t('skills.filter.label')} + > + {levelOptions.map((option) => ( + + {option.label} + + ))} + + +
+ + {filteredSkills.length ? ( +
+ {filteredSkills.map((skill) => ( + setSelectedName(skill.name)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setSelectedName(skill.name); + } + }} + > + +
+
+ +
+
+
+ + {skill.name} + +
+ + {skillStatusLabel(skill, t)} + + {!skill.modelInvocable ? ( + + ) : null} +
+
+ + + + + + {skill.description || t('skills.noDescription')} + + + + {skill.description || t('skills.noDescription')} + + + + +
+
+
+
+ ))} +
+ ) : ( + + + + {query || levelFilter !== 'all' || statusFilter !== 'all' ? ( + + ) : ( + + )} + + + {query || levelFilter !== 'all' || statusFilter !== 'all' + ? t('skills.noMatches') + : t('skills.empty')} + + + + )} +
+ +
+ ); +} diff --git a/packages/web-shell/client/components/skills/skills-manager-logic.test.ts b/packages/web-shell/client/components/skills/skills-manager-logic.test.ts new file mode 100644 index 00000000000..8caca361283 --- /dev/null +++ b/packages/web-shell/client/components/skills/skills-manager-logic.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import type { DaemonWorkspaceSkillStatus } from '@qwen-code/webui/daemon-react-sdk'; +import { filterSkills, preserveSkillSelection } from './skills-manager-logic'; + +const skills: DaemonWorkspaceSkillStatus[] = [ + { + kind: 'skill', + status: 'ok', + name: 'frontend-design', + description: 'Design interfaces', + level: 'extension', + modelInvocable: true, + extensionName: 'design-pack', + }, + { + kind: 'skill', + status: 'ok', + name: 'review', + description: 'Review code', + level: 'user', + modelInvocable: false, + argumentHint: '', + }, +]; + +describe('skills manager logic', () => { + it('filters skills by title and scope', () => { + expect(filterSkills(skills, 'FRONTEND')).toEqual([skills[0]]); + expect(filterSkills(skills, 'design-pack')).toEqual([]); + expect(filterSkills(skills, '')).toEqual([]); + expect(filterSkills(skills, '', 'extension')).toEqual([skills[0]]); + expect(filterSkills(skills, 'design', 'user')).toEqual([]); + }); + + it('filters skills by enabled status', () => { + const disabledSkills = [ + skills[0], + { ...skills[1], status: 'disabled' as const }, + ]; + expect(filterSkills(disabledSkills, '', 'all', 'enabled')).toEqual([ + disabledSkills[0], + ]); + expect(filterSkills(disabledSkills, '', 'all', 'disabled')).toEqual([ + disabledSkills[1], + ]); + expect(filterSkills(disabledSkills, '', 'user', 'disabled')).toEqual([ + disabledSkills[1], + ]); + expect(filterSkills(disabledSkills, '', 'extension', 'disabled')).toEqual( + [], + ); + }); + + it('preserves only a selection that still exists', () => { + expect(preserveSkillSelection('review', skills)).toBe('review'); + expect(preserveSkillSelection('removed', skills)).toBeNull(); + }); +}); diff --git a/packages/web-shell/client/components/skills/skills-manager-logic.ts b/packages/web-shell/client/components/skills/skills-manager-logic.ts new file mode 100644 index 00000000000..ee63bcb17e2 --- /dev/null +++ b/packages/web-shell/client/components/skills/skills-manager-logic.ts @@ -0,0 +1,27 @@ +import type { DaemonWorkspaceSkillStatus } from '@qwen-code/webui/daemon-react-sdk'; + +export type SkillLevelFilter = 'all' | DaemonWorkspaceSkillStatus['level']; +export type SkillStatusFilter = 'all' | 'enabled' | 'disabled'; + +export function filterSkills( + skills: readonly DaemonWorkspaceSkillStatus[], + query: string, + level: SkillLevelFilter = 'all', + status: SkillStatusFilter = 'all', +): DaemonWorkspaceSkillStatus[] { + const normalized = query.trim().toLowerCase(); + return skills.filter((skill) => { + if (level !== 'all' && skill.level !== level) return false; + if (status === 'disabled' && skill.status !== 'disabled') return false; + if (status === 'enabled' && skill.status === 'disabled') return false; + if (!normalized) return true; + return skill.name.toLowerCase().includes(normalized); + }); +} + +export function preserveSkillSelection( + name: string | null, + skills: readonly DaemonWorkspaceSkillStatus[], +): string | null { + return name && skills.some((skill) => skill.name === name) ? name : null; +} diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 13c94a86b41..ddeca072fc3 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -1650,14 +1650,63 @@ const EN: Messages = { 'resume.title': 'Resume Session', 'parallelAgents.title': 'Parallel agents', 'parallelAgents.done': (v) => `${v?.done ?? 0}/${v?.total ?? 0} done`, - 'skills.available': 'Available skills:', - 'skills.none': 'No skills are currently available.', + 'skills.actions': 'Skill actions', + 'skills.disable': 'Disable', + 'skills.disabled': 'Skill disabled.', + 'skills.enable': 'Enable', + 'skills.enabled': 'Skill enabled.', + 'skills.install.action': 'Upload skill', + 'skills.install.description': + 'Install a skill from GitHub, a local folder, or a ZIP archive.', + 'skills.install.error.authentication': + 'Authentication is required to upload skills.', + 'skills.install.error.folderRequired': 'Enter a folder path.', + 'skills.install.error.githubRequired': 'Enter a GitHub SKILL.md URL.', + 'skills.install.error.invalidName': + 'The skill name is missing or contains unsupported characters.', + 'skills.install.error.manifestMissing': + 'The package must contain a SKILL.md file at its root.', + 'skills.install.error.invalidPackage': + 'The skill package is invalid. Check its structure and SKILL.md content.', + 'skills.install.error.zipTooLarge': 'The ZIP file must not exceed 6 MB.', + 'skills.install.error.invalidFolder': 'Enter a valid local skill folder.', + 'skills.install.error.githubFailed': + 'Could not download the skill from GitHub. Check the URL or authentication and try again.', + 'skills.install.error.invalidScope': + 'Choose either the workspace or global install location.', + 'skills.install.error.invalidSource': + 'The daemon does not recognize this upload source. Restart the updated daemon and try again.', + 'skills.install.error.notFound': 'The requested skill was not found.', + 'skills.install.error.nameRequired': 'Enter the skill name.', + 'skills.install.error.untrusted': + 'Trust this workspace before uploading skills.', + 'skills.install.failed': 'Failed to upload skill.', + 'skills.install.folder': 'Local folder', + 'skills.install.folderPath': 'Folder path', + 'skills.install.githubUrl': 'SKILL.md URL', + 'skills.install.name': 'Skill name', + 'skills.install.scope': 'Install to', + 'skills.install.scope.global': 'Global', + 'skills.install.scope.workspace': 'Workspace', + 'skills.install.selectZip': 'Select a ZIP archive.', + 'skills.install.title': 'Upload skill', + 'skills.install.succeeded': (v) => + `Skill “${v?.name ?? ''}” uploaded successfully.`, + 'skills.install.zipSelected': (v) => `Selected: ${v?.name ?? ''}`, + 'skills.delete.action': 'Delete skill', + 'skills.delete.description': (v) => + `Delete “${v?.name ?? ''}” from disk? This cannot be undone.`, + 'skills.delete.failed': 'Failed to delete skill.', + 'skills.delete.succeeded': (v) => + `Skill “${v?.name ?? ''}” deleted successfully.`, + 'skills.delete.title': 'Delete skill?', 'skills.empty': 'No skills available.', - 'skills.footer': (v) => - v?.name - ? `Use /skills ${v.name} to invoke · r to refresh · Esc to close` - : 'r to refresh · Esc to close', - 'skills.invocable': (v) => `${v?.enabled ?? 0}/${v?.total ?? 0} invocable`, + 'skills.count': (v) => + [ + `${v?.count ?? 0} skills`, + ...(v?.enabled ? [`${v.enabled} enabled`] : []), + ...(v?.disabled ? [`${v.disabled} disabled`] : []), + ].join(', '), 'skills.details': 'Skill details', 'skills.extension': 'Extension', 'skills.filter.all': 'All', @@ -1665,15 +1714,22 @@ const EN: Messages = { 'skills.filter.extension': 'Extensions', 'skills.filter.label': 'Filter skills by source', 'skills.filter.project': 'Workspace settings', - 'skills.filter.user': 'User settings', + 'skills.filter.status.all': 'All', + 'skills.filter.status.disabled': 'Disabled', + 'skills.filter.status.enabled': 'Enabled', + 'skills.filter.status.label': 'Filter skills by status', + 'skills.filter.user': 'Global settings', 'skills.hint': 'Hint', 'skills.invocation': 'Invocation', 'skills.level': 'Scope', 'skills.level.bundled': 'Bundled', 'skills.level.extension': 'Extension', 'skills.level.project': 'Project', - 'skills.level.user': 'User', + 'skills.level.user': 'Global', 'skills.loading': 'Loading skills...', + 'skills.manualReference': 'Manual reference', + 'skills.manualReferenceHint': + 'The model cannot discover this skill automatically. Reference it manually.', 'skills.model': 'Model', 'skills.modelAccess': 'Model access', 'skills.modelAccess.disabled': 'Not model-invocable', @@ -1681,10 +1737,17 @@ const EN: Messages = { 'skills.modelInvocable': 'Model', 'skills.noDescription': 'No description', 'skills.noMatches': 'No matching skills.', - 'skills.run': 'Run skill', + 'skills.notToggleable': 'This skill cannot be enabled or disabled.', + 'skills.run': 'Reference skill', 'skills.search': 'Search skills…', 'skills.status': 'Status', 'skills.status.disabled': 'disabled', + 'skills.status.enabled': 'enabled', + 'skills.toggleFailed': 'Failed to update skill.', + 'skills.error.inactiveExtension': + 'This skill belongs to an inactive extension. Enable the extension first.', + 'skills.toggleUnsupported': + 'The connected daemon does not support skill enable or disable actions.', 'skills.title': 'Skills', 'plugins.extensions': 'Extensions', 'plugins.agents': 'Agents', @@ -3606,14 +3669,56 @@ const ZH: Messages = { 'resume.title': '恢复会话', 'parallelAgents.title': '并行智能体', 'parallelAgents.done': (v) => `${v?.done ?? 0}/${v?.total ?? 0} 完成`, - 'skills.available': '可用 skills:', - 'skills.none': '当前没有可用 skill。', + 'skills.actions': 'Skill 操作', + 'skills.disable': '禁用', + 'skills.disabled': 'Skill 已禁用。', + 'skills.enable': '启用', + 'skills.enabled': 'Skill 已启用。', + 'skills.install.action': '上传技能', + 'skills.install.description': '从 GitHub、本地文件夹或 ZIP 压缩包安装技能。', + 'skills.install.error.authentication': '上传技能需要身份认证。', + 'skills.install.error.folderRequired': '请输入文件夹路径。', + 'skills.install.error.githubRequired': '请输入 GitHub SKILL.md 地址。', + 'skills.install.error.invalidName': '技能名称缺失或包含不支持的字符。', + 'skills.install.error.manifestMissing': + '技能包根目录中必须包含 SKILL.md 文件。', + 'skills.install.error.invalidPackage': + '技能包无效,请检查目录结构和 SKILL.md 内容。', + 'skills.install.error.zipTooLarge': 'ZIP 文件不能超过 6 MB。', + 'skills.install.error.invalidFolder': '请输入有效的本地技能文件夹路径。', + 'skills.install.error.githubFailed': + '无法从 GitHub 下载技能,请检查地址或认证信息后重试。', + 'skills.install.error.invalidScope': '请选择工作区或全局安装位置。', + 'skills.install.error.invalidSource': + '当前 daemon 无法识别这种上传来源,请重启已更新的 daemon 后重试。', + 'skills.install.error.notFound': '未找到指定的技能。', + 'skills.install.error.nameRequired': '请输入技能名称。', + 'skills.install.error.untrusted': '请先信任当前工作区,再上传技能。', + 'skills.install.failed': '上传技能失败。', + 'skills.install.folder': '本地文件夹', + 'skills.install.folderPath': '文件夹路径', + 'skills.install.githubUrl': 'SKILL.md 地址', + 'skills.install.name': '技能名称', + 'skills.install.scope': '安装位置', + 'skills.install.scope.global': '全局', + 'skills.install.scope.workspace': '工作区', + 'skills.install.selectZip': '请选择 ZIP 压缩包。', + 'skills.install.title': '上传技能', + 'skills.install.succeeded': (v) => `技能“${v?.name ?? ''}”上传成功。`, + 'skills.install.zipSelected': (v) => `已选择:${v?.name ?? ''}`, + 'skills.delete.action': '删除技能', + 'skills.delete.description': (v) => + `确定从磁盘删除“${v?.name ?? ''}”吗?此操作无法撤销。`, + 'skills.delete.failed': '删除技能失败。', + 'skills.delete.succeeded': (v) => `技能“${v?.name ?? ''}”删除成功。`, + 'skills.delete.title': '删除技能?', 'skills.empty': '没有可用 skill。', - 'skills.footer': (v) => - v?.name - ? `使用 /skills ${v.name} 调用 · r 刷新 · Esc 关闭` - : 'r 刷新 · Esc 关闭', - 'skills.invocable': (v) => `${v?.enabled ?? 0}/${v?.total ?? 0} 可调用`, + 'skills.count': (v) => + [ + `${v?.count ?? 0} 个技能`, + ...(v?.enabled ? [`已启用 ${v.enabled} 个`] : []), + ...(v?.disabled ? [`已禁用 ${v.disabled} 个`] : []), + ].join(','), 'skills.details': 'Skill 详情', 'skills.extension': '所属扩展', 'skills.filter.all': '全部', @@ -3621,15 +3726,21 @@ const ZH: Messages = { 'skills.filter.extension': '扩展', 'skills.filter.label': '按来源筛选 Skills', 'skills.filter.project': '工作区设置', - 'skills.filter.user': '用户设置', + 'skills.filter.status.all': '全部', + 'skills.filter.status.disabled': '已禁用', + 'skills.filter.status.enabled': '已启用', + 'skills.filter.status.label': '按状态筛选技能', + 'skills.filter.user': '全局设置', 'skills.hint': '提示', 'skills.invocation': '调用方式', 'skills.level': '作用域', 'skills.level.bundled': '内置', 'skills.level.extension': '扩展', 'skills.level.project': '项目', - 'skills.level.user': '用户', + 'skills.level.user': '全局', 'skills.loading': '正在加载 skills...', + 'skills.manualReference': '手动引用', + 'skills.manualReferenceHint': '模型无法自动识别,需手动引用 skill', 'skills.model': '模型', 'skills.modelAccess': '模型调用', 'skills.modelAccess.disabled': '不可由模型调用', @@ -3637,17 +3748,23 @@ const ZH: Messages = { 'skills.modelInvocable': '模型可用', 'skills.noDescription': '暂无描述', 'skills.noMatches': '没有匹配的 Skill。', - 'skills.run': '运行 skill', + 'skills.notToggleable': '此 Skill 不支持启用或禁用。', + 'skills.run': '引用 skill', 'skills.search': '搜索 Skills…', 'skills.status': '状态', 'skills.status.disabled': '已禁用', + 'skills.status.enabled': '已启用', + 'skills.toggleFailed': '更新 Skill 失败。', + 'skills.error.inactiveExtension': + '该技能所属扩展当前未启用,请先启用对应扩展。', + 'skills.toggleUnsupported': '当前连接的 daemon 不支持启用或禁用 Skill。', 'skills.title': 'Skills', 'plugins.extensions': '扩展', 'plugins.agents': '智能体', 'plugins.mcp': 'MCP', 'plugins.mcpLoadFailed': 'MCP 状态加载失败', 'plugins.sections': '插件分类', - 'plugins.skills': 'Skills', + 'plugins.skills': '技能', 'plugins.title': '插件', 'plugins.tools': '工具', 'stats.accepted': '已接受:', diff --git a/packages/webui/src/daemon/workspace/DaemonWorkspaceProvider.test.tsx b/packages/webui/src/daemon/workspace/DaemonWorkspaceProvider.test.tsx index c43cba05c22..1b9db2287f8 100644 --- a/packages/webui/src/daemon/workspace/DaemonWorkspaceProvider.test.tsx +++ b/packages/webui/src/daemon/workspace/DaemonWorkspaceProvider.test.tsx @@ -25,6 +25,9 @@ const sdkMocks = vi.hoisted(() => { const workspaceMcpResources = vi.fn(); const restartMcpServer = vi.fn(); const workspaceSkills = vi.fn(); + const setWorkspaceSkillEnabled = vi.fn(); + const installWorkspaceSkill = vi.fn(); + const deleteWorkspaceSkill = vi.fn(); const workspaceAcpStatus = vi.fn(); const workspaceAcpPreheat = vi.fn(); const workspaceTools = vi.fn(); @@ -51,6 +54,9 @@ const sdkMocks = vi.hoisted(() => { workspaceMcpResources = workspaceMcpResources; restartMcpServer = restartMcpServer; workspaceSkills = workspaceSkills; + setWorkspaceSkillEnabled = setWorkspaceSkillEnabled; + installWorkspaceSkill = installWorkspaceSkill; + deleteWorkspaceSkill = deleteWorkspaceSkill; workspaceAcpStatus = workspaceAcpStatus; workspaceAcpPreheat = workspaceAcpPreheat; workspaceTools = workspaceTools; @@ -78,6 +84,9 @@ const sdkMocks = vi.hoisted(() => { workspaceMcpResources, restartMcpServer, workspaceSkills, + setWorkspaceSkillEnabled, + installWorkspaceSkill, + deleteWorkspaceSkill, workspaceAcpStatus, workspaceAcpPreheat, workspaceTools, @@ -128,6 +137,27 @@ const sdkMocks = vi.hoisted(() => { initialized: true, skills: [], }); + setWorkspaceSkillEnabled.mockReset(); + setWorkspaceSkillEnabled.mockResolvedValue({ + skillName: 'review', + enabled: false, + changed: true, + activation: 'applied', + sessionsRefreshed: 1, + sessionsFailed: 0, + }); + installWorkspaceSkill.mockReset(); + installWorkspaceSkill.mockResolvedValue({ + skillName: 'review', + scope: 'workspace', + installedPath: '/mock-workspace/.qwen/skills/review/SKILL.md', + }); + deleteWorkspaceSkill.mockReset(); + deleteWorkspaceSkill.mockResolvedValue({ + skillName: 'review', + scope: 'workspace', + deleted: true, + }); workspaceAcpStatus.mockReset(); workspaceAcpStatus.mockResolvedValue({ channelLive: true }); workspaceAcpPreheat.mockReset(); @@ -352,8 +382,17 @@ describe('DaemonWorkspaceProvider', () => { expect(typeof actions?.loadMcpStatus).toBe('function'); expect(typeof actions?.reloadMcp).toBe('function'); expect(typeof actions?.loadSkillsStatus).toBe('function'); + expect(typeof actions?.setWorkspaceSkillEnabled).toBe('function'); + expect(typeof actions?.installWorkspaceSkill).toBe('function'); + expect(typeof actions?.deleteWorkspaceSkill).toBe('function'); expect(typeof actions?.listAgents).toBe('function'); expect(typeof actions?.globWorkspace).toBe('function'); + + await actions?.setWorkspaceSkillEnabled('review', false); + expect(sdkMocks.setWorkspaceSkillEnabled).toHaveBeenCalledWith( + 'review', + false, + ); }); it('useOptionalDaemonWorkspace returns undefined without provider', async () => { diff --git a/packages/webui/src/daemon/workspace/actions.ts b/packages/webui/src/daemon/workspace/actions.ts index 1048ad996d9..00765fefcba 100644 --- a/packages/webui/src/daemon/workspace/actions.ts +++ b/packages/webui/src/daemon/workspace/actions.ts @@ -292,6 +292,30 @@ export function createDaemonWorkspaceActions({ ); }, + async setWorkspaceSkillEnabled(skillName, enabled) { + const client = requireClient(getClient, 'Set skill enabled failed'); + return withActionTimeout( + client.setWorkspaceSkillEnabled(skillName, enabled), + 'Set skill enabled timed out', + ); + }, + + async installWorkspaceSkill(request) { + const client = requireClient(getClient, 'Install skill failed'); + return withActionTimeout( + client.installWorkspaceSkill(request), + 'Install skill timed out', + ); + }, + + async deleteWorkspaceSkill(skillName, scope) { + const client = requireClient(getClient, 'Delete skill failed'); + return withActionTimeout( + client.deleteWorkspaceSkill(skillName, scope), + 'Delete skill timed out', + ); + }, + async loadExtensionsStatus() { const client = requireClient(getClient, 'Load extensions failed'); return withActionTimeout( diff --git a/packages/webui/src/daemon/workspace/hooks/useDaemonSkills.ts b/packages/webui/src/daemon/workspace/hooks/useDaemonSkills.ts index cc0ef143847..88b2322a0c9 100644 --- a/packages/webui/src/daemon/workspace/hooks/useDaemonSkills.ts +++ b/packages/webui/src/daemon/workspace/hooks/useDaemonSkills.ts @@ -20,5 +20,8 @@ export function useDaemonSkills(options: DaemonResourceOptions = {}) { ...result, status: result.data, skills: result.data?.skills ?? [], + setEnabled: workspaceActions.setWorkspaceSkillEnabled, + install: workspaceActions.installWorkspaceSkill, + remove: workspaceActions.deleteWorkspaceSkill, }; } diff --git a/packages/webui/src/daemon/workspace/types.ts b/packages/webui/src/daemon/workspace/types.ts index eea1ef3b490..1768979b9a9 100644 --- a/packages/webui/src/daemon/workspace/types.ts +++ b/packages/webui/src/daemon/workspace/types.ts @@ -55,6 +55,10 @@ import type { DaemonWorkspacePreflightStatus, DaemonWorkspaceProvidersStatus, DaemonWorkspaceSkillsStatus, + DaemonSkillToggleResult, + DaemonSkillInstallRequest, + DaemonSkillMutationResult, + DaemonSkillScope, DaemonWorkspaceToolsStatus, DaemonWorkspaceSettingsStatus, DaemonSettingUpdateResult, @@ -323,8 +327,19 @@ export interface DaemonWorkspaceActions { heatmapDays?: number; }): Promise; - // Skills (read-only) + // Skills loadSkillsStatus(): Promise; + setWorkspaceSkillEnabled( + skillName: string, + enabled: boolean, + ): Promise; + installWorkspaceSkill( + request: DaemonSkillInstallRequest, + ): Promise; + deleteWorkspaceSkill( + skillName: string, + scope: DaemonSkillScope, + ): Promise; // Extensions loadExtensionsStatus(): Promise;