feat(skills): list workspace skills without a session (#1392)
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions

- add GET /api/v1/workspaces/{workspace_id}/skills backed by a listWorkspaceSkills core RPC and ISkillService.listForWorkDir, reusing the session skill-loading path so results match a new session
- web: populate the composer slash menu from workspace skills before a session exists, then fall back to session skills once one is active
This commit is contained in:
Haozhe 2026-07-05 18:05:15 +08:00 committed by GitHub
parent 083d0caf05
commit 4963c9016f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 243 additions and 15 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
web: Show available skills in the composer before a session is created.

View file

@ -717,8 +717,9 @@ export class DaemonKimiWebApi implements KimiWebApi {
}
// -------------------------------------------------------------------------
// Skills — session-scoped slash-invocable skills
// Skills — slash-invocable skills (session- or workspace-scoped)
// GET /sessions/{id}/skills → { skills: WireSkillDescriptor[] }
// GET /workspaces/{id}/skills → { skills: WireSkillDescriptor[] } (no session)
// POST /sessions/{id}/skills/{name}:activate body { args? } → { activated, skill_name }
// -------------------------------------------------------------------------
@ -733,6 +734,17 @@ export class DaemonKimiWebApi implements KimiWebApi {
}));
}
async listSkillsForWorkspace(workspaceId: string): Promise<AppSkill[]> {
const data = await this.http.get<{ skills: WireSkillDescriptor[] }>(
`/workspaces/${encodeURIComponent(workspaceId)}/skills`,
);
return (data.skills ?? []).map((s) => ({
name: s.name,
description: s.description,
source: s.source,
}));
}
async activateSkill(
sessionId: string,
skillName: string,

View file

@ -675,6 +675,8 @@ export interface KimiWebApi {
respondQuestion(sessionId: string, questionId: string, response: QuestionResponse): Promise<{ resolved: true; resolvedAt: string }>;
dismissQuestion(sessionId: string, questionId: string): Promise<{ dismissed: true; dismissedAt: string }>;
listSkills(sessionId: string): Promise<AppSkill[]>;
/** List skills for a workspace (no session required) — GET /workspaces/{id}/skills. */
listSkillsForWorkspace(workspaceId: string): Promise<AppSkill[]>;
activateSkill(sessionId: string, skillName: string, args?: string): Promise<{ activated: true; skillName: string }>;
listTasks(sessionId: string, status?: AppTaskStatus): Promise<AppTask[]>;
getTask(sessionId: string, taskId: string, input?: { withOutput?: boolean; outputBytes?: number }): Promise<AppTask>;

View file

@ -90,6 +90,9 @@ export function useModelProviderState(
// Session-scoped skills (slash-invocable). Loaded lazily per session; the active
// session's list feeds the composer's `/` menu.
const skillsBySession = ref<Record<string, AppSkill[]>>({});
// Workspace-scoped skills, used to populate the `/` menu before a session exists
// (onboarding composer). Keyed by workspace id; loaded once per workspace.
const skillsByWorkspace = ref<Record<string, AppSkill[]>>({});
const providers = ref<AppProvider[]>([]);
// Model picked while in the "new session draft" state (onboarding composer —
@ -127,6 +130,17 @@ export function useModelProviderState(
}
}
async function loadSkillsForWorkspace(workspaceId: string): Promise<void> {
try {
const api = getKimiWebApi();
const list = await api.listSkillsForWorkspace(workspaceId);
skillsByWorkspace.value = { ...skillsByWorkspace.value, [workspaceId]: list };
} catch {
// Side data; an older daemon without /workspaces/{id}/skills just yields
// no slash-skills for the onboarding composer.
}
}
/** Load models (cached — call again to force refresh) */
async function loadModels(): Promise<void> {
try {
@ -383,8 +397,10 @@ export function useModelProviderState(
providers,
draftModel,
skillsBySession,
skillsByWorkspace,
// actions
loadSkillsForSession,
loadSkillsForWorkspace,
loadModels,
refreshOAuthProviderModels,
loadProviders,

View file

@ -1627,11 +1627,13 @@ const sessions = computed<Session[]>(() => {
const activeSessionId = computed<string>(() => rawState.activeSessionId ?? '');
/** Slash-invocable skills for the active session (feeds the composer `/` menu). */
/** Slash-invocable skills for the composer `/` menu — the active session's skills,
* or, before a session exists, the active workspace's skills. */
const skills = computed<AppSkill[]>(() => {
const sid = rawState.activeSessionId;
if (!sid) return [];
return modelProvider.skillsBySession.value[sid] ?? [];
if (sid) return modelProvider.skillsBySession.value[sid] ?? [];
const wid = activeWorkspaceId.value;
return wid ? (modelProvider.skillsByWorkspace.value[wid] ?? []) : [];
});
const isSending = computed<boolean>(() => {
@ -2048,6 +2050,21 @@ const activeWorkspaceId = computed<string | null>(() => {
return list[0]?.id ?? null;
});
// Pre-warm workspace-scoped skills so the onboarding composer's `/` menu is
// populated before a session exists. Loaded once per workspace (guard mirrors
// the per-session guard in refreshSessionSidecars); session skills take over
// via refreshSessionSidecars once a session is created.
watch(
activeWorkspaceId,
(id) => {
if (!id) return;
if (!Object.prototype.hasOwnProperty.call(modelProvider.skillsByWorkspace.value, id)) {
void modelProvider.loadSkillsForWorkspace(id);
}
},
{ immediate: true },
);
/** The active workspace as a sidebar view (or null when none). */
const visibleWorkspace = computed<WorkspaceView | null>(() => {
const id = activeWorkspaceId.value;

View file

@ -274,6 +274,10 @@ export interface ActivateSkillPayload {
readonly args?: string | undefined;
}
export interface ListWorkspaceSkillsPayload {
readonly workDir: string;
}
export interface ActivatePluginCommandPayload {
readonly pluginId: string;
readonly commandName: string;
@ -453,6 +457,7 @@ export interface CoreAPI extends SessionAPIWithId {
forkSession: (payload: ForkSessionPayload) => ResumeSessionResult;
listSessions: (payload: ListSessionsPayload) => readonly SessionSummary[];
exportSession: (payload: ExportSessionPayload) => ExportSessionResult;
listWorkspaceSkills: (payload: ListWorkspaceSkillsPayload) => Promise<readonly SkillSummary[]>;
listPlugins: (payload: EmptyPayload) => readonly PluginSummary[];
installPlugin: (payload: InstallPluginPayload) => PluginSummary;
setPluginEnabled: (payload: SetPluginEnabledPayload) => void;

View file

@ -35,6 +35,12 @@ import type { Logger } from '../logging/types';
import { resolveSessionMcpConfig, mergeCallerMcpServers, type SessionMcpConfig } from '../mcp';
import { Session, type SessionMeta, type SessionSkillConfig } from '../session';
import { exportSessionDirectory } from '../session/export';
import {
registerBuiltinSkills,
SessionSkillRegistry,
resolveSkillRoots,
summarizeSkill,
} from '../skill';
import {
ProviderManager, type BearerTokenProvider,
type OAuthTokenProviderResolver
@ -80,6 +86,7 @@ import type {
GetPluginInfoPayload,
InstallPluginPayload,
ListSessionsPayload,
ListWorkspaceSkillsPayload,
McpServerInfo,
McpStartupMetrics,
PluginInfo,
@ -757,6 +764,37 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
return this.sessionApi(sessionId).listSkills(payload);
}
/**
* List the skills available for a workspace working directory without
* requiring a session. Mirrors `Session.loadSkills` exactly (same roots,
* same discovery order, same built-ins) so the result matches what a new
* session created in `workDir` would see. Used to populate the composer
* skill menu before a session exists.
*/
async listWorkspaceSkills({
workDir,
}: ListWorkspaceSkillsPayload): Promise<readonly SkillSummary[]> {
const cwd = requiredWorkDir('listWorkspaceSkills', workDir);
await this.pluginsReady;
const skills = this.resolveSessionSkillConfig(this.reloadProviderManager());
const roots = await resolveSkillRoots({
paths: {
userHomeDir: skills.userHomeDir ?? this.userHomeDir,
brandHomeDir: skills.brandHomeDir ?? this.homeDir,
workDir: cwd,
},
explicitDirs: skills.explicitDirs,
extraDirs: skills.extraDirs,
pluginSkillRoots: skills.pluginSkillRoots,
mergeAllAvailableSkills: skills.mergeAllAvailableSkills,
builtinDir: skills.builtinDir,
});
const registry = new SessionSkillRegistry({});
await registry.loadRoots(roots);
registerBuiltinSkills(registry);
return registry.listSkills().map(summarizeSkill);
}
listPluginCommands({
sessionId,
...payload

View file

@ -7,18 +7,23 @@
*
* **CoreAPI surface used**:
* - `core.rpc.listSkills({sessionId}) => readonly SkillSummary[]`
* (packages/agent-core/src/rpc/core-api.ts:347, SessionAPI).
* (packages/agent-core/src/rpc/core-api.ts, SessionAPI) session-scoped.
* - `core.rpc.listWorkspaceSkills({workDir}) => Promise<readonly SkillSummary[]>`
* (CoreAPI) workspace-cwd-scoped, no session required; mirrors the roots
* a new session would scan.
* - `core.rpc.activateSkill({sessionId, agentId, name, args})`
* (line 324, AgentAPI) renders the skill prompt and starts a turn with a
* (AgentAPI) renders the skill prompt and starts a turn with a
* `skill_activation` origin (trigger 'user-slash'), mirroring the TUI's
* slash-command path. It does NOT go through `IPromptService`, so no
* `prompt_id` is minted; clients observe progress via `skill.activated` +
* `turn.*` events on the WS stream.
*
* **Session scoping**: the skill registry is per-session (project skills are
* discovered from the session cwd), so both methods are session-scoped and the
* impl resumes the session before dispatching sessions that exist on disk
* but are not in the active map after a daemon restart still resolve.
* **Scoping**: the skill registry is per-session (project skills are
* discovered from the session cwd). `list`/`activate` are session-scoped, so
* the impl resumes the session before dispatching sessions that exist on
* disk but are not in the active map after a daemon restart still resolve.
* `listForWorkDir` is workspace-cwd-scoped instead: it scans the same roots a
* new session would, without creating or resuming one.
*
* **Error model**:
* - `SkillSessionNotFoundError` is NOT defined here the impl throws the
@ -68,6 +73,13 @@ export interface ISkillService {
*/
list(sessionId: string): Promise<readonly SkillDescriptor[]>;
/**
* Return the skills available for a workspace working directory (project +
* user + extra + builtin) without requiring a session. Used to populate the
* composer skill menu before a session is created.
*/
listForWorkDir(workDir: string): Promise<readonly SkillDescriptor[]>;
/**
* Activate a skill by name in a session the REST analogue of typing
* `/<skill> <args>`. Starts a turn on the session's main agent. Throws

View file

@ -31,6 +31,11 @@ export class SkillService extends Disposable implements ISkillService {
return raw.map(toProtocolSkill);
}
async listForWorkDir(workDir: string): Promise<readonly SkillDescriptor[]> {
const raw = await this.core.rpc.listWorkspaceSkills({ workDir });
return raw.map(toProtocolSkill);
}
async activate(sessionId: string, skillName: string, args?: string): Promise<void> {
await this._requireLoadedSession(sessionId);
try {

View file

@ -1,15 +1,22 @@
/**
* `/sessions/{session_id}/skills*` REST routes.
* `/skills` REST routes (session- and workspace-scoped).
*
* 2 endpoints:
* 3 endpoints:
*
* GET /sessions/{session_id}/skills data: {skills: SkillDescriptor[]}
* GET /workspaces/{workspace_id}/skills data: {skills: SkillDescriptor[]}
* POST /sessions/{session_id}/skills/{skill_name}:activate body: {args?} data: {activated: true, skill_name}
*
* Skills are session-scoped: the registry is built per session (project
* skills are discovered from the session cwd), so the list lives under
* The session list is session-scoped: the registry is built per session
* (project skills are discovered from the session cwd), so it lives under
* `/sessions/{session_id}` rather than as a global collection like `/tools`.
*
* The workspace list (`/workspaces/{workspace_id}/skills`) is the
* session-less counterpart: it scans the same roots a new session in that
* workspace cwd would, so clients can populate the composer skill menu before
* a session exists. The workspace id is resolved to its root via
* `IWorkspaceRegistry.resolveRoot`.
*
* Activation is the REST analogue of typing `/<skill> <args>` in the TUI: it
* renders the skill prompt and starts a turn on the session's main agent with
* a `skill_activation` origin. No prompt_id is minted (the turn bypasses
@ -17,6 +24,7 @@
* `turn.*` events on the WS stream.
*
* **Error mapping**:
* - `WorkspaceNotFoundError` envelope `code: 40410 workspace.not_found`.
* - `SessionNotFoundError` envelope `code: 40401 session.not_found`.
* - `SkillNotFoundError` envelope `code: 40415 skill.not_found`.
* - `SkillNotActivatableError` envelope `code: 40912 skill.not_activatable`.
@ -34,8 +42,9 @@ import {
activateSkillRequestSchema,
activateSkillResultSchema,
listSkillsResponseSchema,
workspaceIdParamSchema,
} from '@moonshot-ai/protocol';
import { ISkillService, SessionNotFoundError, SkillNotActivatableError, SkillNotFoundError, type IInstantiationService } from '@moonshot-ai/agent-core';
import { ISkillService, IWorkspaceRegistry, SessionNotFoundError, SkillNotActivatableError, SkillNotFoundError, WorkspaceNotFoundError, type IInstantiationService } from '@moonshot-ai/agent-core';
import { z } from 'zod';
@ -102,6 +111,45 @@ export function registerSkillsRoutes(
listSkillsRoute.handler as Parameters<SkillsRouteHost['get']>[2],
);
// GET /workspaces/{workspace_id}/skills --------------------------------
const listWorkspaceSkillsRoute = defineRoute(
{
method: 'GET',
path: '/workspaces/{workspace_id}/skills',
params: workspaceIdParamSchema,
success: { data: listSkillsResponseSchema },
errors: {
[ErrorCode.WORKSPACE_NOT_FOUND]: {},
},
description: 'List the skills available to a workspace (no session required)',
tags: ['skills'],
operationId: 'listWorkspaceSkills',
},
async (req, reply) => {
try {
const { workspace_id } = req.params;
// Resolve both services from the accessor synchronously: the DI
// accessor is only valid during the synchronous invocation of the
// callback, so no `a.get(...)` may follow an `await`.
const skills = await ix.invokeFunction((a) => {
const registry = a.get(IWorkspaceRegistry);
const skillService = a.get(ISkillService);
return registry.resolveRoot(workspace_id).then((workDir) =>
skillService.listForWorkDir(workDir),
);
});
reply.send(okEnvelope({ skills }, req.id));
} catch (err) {
sendMappedError(reply, req.id, err);
}
},
);
app.get(
listWorkspaceSkillsRoute.path,
listWorkspaceSkillsRoute.options,
listWorkspaceSkillsRoute.handler as Parameters<SkillsRouteHost['get']>[2],
);
// POST /sessions/{session_id}/skills/{skill_name}:activate --------------
const activateSkillRoute = defineRoute(
{
@ -169,6 +217,10 @@ function sendMappedError(
requestId: string,
err: unknown,
): void {
if (err instanceof WorkspaceNotFoundError) {
reply.send(errEnvelope(ErrorCode.WORKSPACE_NOT_FOUND, err.message, requestId));
return;
}
if (err instanceof SessionNotFoundError) {
reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId));
return;

View file

@ -177,6 +177,10 @@ exports[`API surface snapshot > matches the documented v1 route table and meta e
"GET",
"/api/v1/workspaces",
],
[
"GET",
"/api/v1/workspaces/{workspace_id}/skills",
],
[
"GET",
"/asyncapi.json",

View file

@ -129,6 +129,19 @@ async function createSession(r: RunningServer): Promise<string> {
return env.data.id;
}
async function registerWorkspace(r: RunningServer): Promise<string> {
const res = await appOf(r).inject({
method: 'POST',
url: '/api/v1/workspaces',
payload: { root: workspaceDir },
});
const env = envelopeOf<{ id: string }>(res.json());
if (env.code !== 0 || env.data === null) {
throw new Error(`register workspace failed: ${JSON.stringify(env)}`);
}
return env.data.id;
}
/** Seed a project skill bundle at `<cwd>/.kimi-code/skills/<name>/SKILL.md`. */
function seedProjectSkill(name: string, frontmatterType?: string): void {
const dir = join(workspaceDir, '.kimi-code', 'skills', name);
@ -261,3 +274,50 @@ describe('POST /api/v1/sessions/{sid}/skills/{name}:activate', () => {
expect(env.code).toBe(40001);
});
});
describe('GET /api/v1/workspaces/{wid}/skills', () => {
it('lists skills for a workspace without creating a session', async () => {
seedProjectSkill('e2e-greeting');
const r = await bootDaemon();
const wid = await registerWorkspace(r);
const res = await appOf(r).inject({
method: 'GET',
url: `/api/v1/workspaces/${wid}/skills`,
});
expect(res.statusCode).toBe(200);
const env = envelopeOf<unknown>(res.json());
expect(env.code).toBe(0);
const parsed = listSkillsResponseSchema.parse(env.data);
const seeded = parsed.skills.find((s) => s.name === 'e2e-greeting');
expect(seeded).toBeDefined();
expect(seeded?.source).toBe('project');
expect(seeded?.description).toBe('e2e test skill e2e-greeting');
});
it('matches the session listing for the same cwd', async () => {
seedProjectSkill('e2e-greeting');
const r = await bootDaemon();
const wid = await registerWorkspace(r);
const sid = await createSession(r);
const [wsRes, sessRes] = await Promise.all([
appOf(r).inject({ method: 'GET', url: `/api/v1/workspaces/${wid}/skills` }),
appOf(r).inject({ method: 'GET', url: `/api/v1/sessions/${sid}/skills` }),
]);
const wsSkills = listSkillsResponseSchema.parse(envelopeOf<unknown>(wsRes.json()).data).skills;
const sessSkills = listSkillsResponseSchema.parse(
envelopeOf<unknown>(sessRes.json()).data,
).skills;
const names = (xs: readonly { name: string }[]) => xs.map((s) => s.name).toSorted();
expect(names(wsSkills)).toEqual(names(sessSkills));
});
it('returns 40410 for an unknown workspace', async () => {
const r = await bootDaemon();
const res = await appOf(r).inject({
method: 'GET',
url: '/api/v1/workspaces/wd_does-not-exist_000000000000/skills',
});
const env = envelopeOf<unknown>(res.json());
expect(env.code).toBe(40410);
});
});