Skip to content

Commit af8e99e

Browse files
authored
Add skills library, install flow, and management UI (#143)
* Add skills library and management workflows - Expose bundled skill catalog and install/import APIs - Add skills page, create dialog, and composer shortcuts - Wire slash commands to browse, create, install, and uninstall skills * Switch global skills to ~/.okcode with legacy fallback - Update skill creation and system docs to use ~/.okcode/skills - Preserve read compatibility with existing ~/.claude/skills installs - Add tests for skill precedence and bundled skill installation
1 parent 833df83 commit af8e99e

36 files changed

Lines changed: 2956 additions & 126 deletions

File tree

.plans/skills-system.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Problem
44

5-
Skills (slash commands backed by markdown definitions) currently exist as an external convention: markdown files placed in `~/.claude/skills/<name>/SKILL.md` that Claude discovers and surfaces as `/slash-commands`. There is no first-class support in OK Code for:
5+
Skills (slash commands backed by markdown definitions) currently exist as an external convention: markdown files placed in `~/.okcode/skills/<name>/SKILL.md` that Claude discovers and surfaces as `/slash-commands`, with legacy `~/.claude/skills/<name>/SKILL.md` still readable during migration. There is no first-class support in OK Code for:
66

77
1. Creating new skills (scaffolding, validation, editing)
88
2. Storing skills at workspace vs global scope
@@ -40,15 +40,15 @@ Body follows a loose convention:
4040

4141
| Scope | Path | Purpose |
4242
|-------|------|---------|
43-
| User/global | `~/.claude/skills/<name>/SKILL.md` | Available in all projects |
43+
| User/global | `~/.okcode/skills/<name>/SKILL.md` | Available in all projects |
4444
| Shared agent | `~/.agents/skills/<name>/SKILL.md` | Shared across agent tools |
4545
| (missing) | `<project>/.claude/skills/<name>/SKILL.md` | Project-scoped skills |
4646

4747
Global skills can symlink to shared agent skills for deduplication.
4848

4949
### Current discovery
5050

51-
Claude Code discovers skills at startup by scanning `~/.claude/skills/` and presents them in the system prompt as available slash commands. There is no project-level discovery, no registry, no search.
51+
OK Code should treat `~/.okcode/skills/` as the canonical global skill directory while preserving read compatibility with legacy `~/.claude/skills/` installs. There is no project-level discovery, no registry, no search.
5252

5353
### Current invocation
5454

@@ -58,7 +58,7 @@ Skills are invoked via the `Skill` tool, which takes `skill: "<name>"` and optio
5858

5959
## Design goals
6060

61-
1. **Two-tier scoping**: skills live at global (`~/.claude/skills/`) or project (`.claude/skills/`) scope, with clear precedence rules.
61+
1. **Two-tier scoping**: skills live at global (`~/.okcode/skills/`, with legacy fallback from `~/.claude/skills/`) or project (`.claude/skills/`) scope, with clear precedence rules.
6262
2. **Scaffold-first authoring**: `okcode skill create` (or UI equivalent) generates valid skill structure with frontmatter, required sections, and optional supplementary files.
6363
3. **Discoverability**: skills can be browsed, searched, and imported from a registry (local directory, git repo, or future remote registry).
6464
4. **Zero-config invocation**: existing `/skill-name` slash command convention continues to work; new skills are immediately available after creation.
@@ -117,7 +117,8 @@ Skills are resolved with project scope taking precedence over global scope:
117117
```
118118
Resolution order:
119119
1. <project-root>/.claude/skills/<name>/SKILL.md (project scope)
120-
2. ~/.claude/skills/<name>/SKILL.md (global scope)
120+
2. ~/.okcode/skills/<name>/SKILL.md (canonical global scope)
121+
3. ~/.claude/skills/<name>/SKILL.md (legacy global fallback)
121122
```
122123

123124
If the same skill name exists in both scopes, the project-scoped version wins. This allows projects to override or customize global skills.
@@ -423,7 +424,7 @@ Recommended phasing:
423424
2. Skill versioning or dependency resolution between skills.
424425
3. Skill permissions or access control (all installed skills are available).
425426
4. Skill marketplace or monetization.
426-
5. Breaking backwards compatibility with existing `~/.claude/skills/` layout.
427+
5. Breaking backwards compatibility with existing `~/.claude/skills/` layout; legacy installs should remain readable while `.okcode` becomes the canonical write target.
427428
6. Auto-updating skills from remote sources.
428429

429430
---

apps/server/src/skills/SkillService.ts

Lines changed: 138 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,26 @@
77
* @module SkillService
88
*/
99
import type {
10+
SkillCatalogResult,
1011
SkillCreateResult,
12+
SkillImportResult,
13+
SkillInstallResult,
1114
SkillListResult,
1215
SkillReadResult,
1316
SkillSearchResult,
1417
} from "@okcode/contracts";
1518
import { Effect, Layer, Schema, ServiceMap } from "effect";
1619
import {
20+
ensureSystemSkillsInstalled,
21+
importSkill,
22+
installBundledSkill,
1723
listSkills,
1824
readSkill,
1925
searchSkills,
2026
createSkill,
2127
deleteSkill,
2228
} from "@okcode/shared/skill";
29+
import { listBundledSkills } from "@okcode/shared/skillCatalog";
2330

2431
/**
2532
* SkillServiceError - Tagged error for skill service failures.
@@ -41,6 +48,10 @@ export class SkillServiceError extends Schema.TaggedErrorClass<SkillServiceError
4148
* SkillServiceShape - Service API for skill CRUD and search operations.
4249
*/
4350
export interface SkillServiceShape {
51+
readonly catalog: (input: {
52+
readonly cwd?: string | undefined;
53+
}) => Effect.Effect<SkillCatalogResult, SkillServiceError>;
54+
4455
/**
4556
* List all installed skills.
4657
*/
@@ -64,6 +75,8 @@ export interface SkillServiceShape {
6475
readonly description: string;
6576
readonly scope: "global" | "project";
6677
readonly cwd?: string | undefined;
78+
readonly tags?: readonly string[] | undefined;
79+
readonly template?: "blank" | "docs-helper" | "automation-helper" | "review-helper" | undefined;
6780
}) => Effect.Effect<SkillCreateResult, SkillServiceError>;
6881

6982
/**
@@ -75,6 +88,36 @@ export interface SkillServiceShape {
7588
readonly cwd?: string | undefined;
7689
}) => Effect.Effect<void, SkillServiceError>;
7790

91+
readonly install: (input: {
92+
readonly id:
93+
| "pdf"
94+
| "spreadsheet"
95+
| "doc"
96+
| "playwright"
97+
| "github"
98+
| "skill-creator"
99+
| "image-gen"
100+
| "plugin-creator"
101+
| "skill-installer"
102+
| "openclaw-docs"
103+
| "openai-docs"
104+
| "anthropic-docs";
105+
readonly scope: "global" | "project";
106+
readonly cwd?: string | undefined;
107+
}) => Effect.Effect<SkillInstallResult, SkillServiceError>;
108+
109+
readonly uninstall: (input: {
110+
readonly name: string;
111+
readonly scope: "global" | "project";
112+
readonly cwd?: string | undefined;
113+
}) => Effect.Effect<void, SkillServiceError>;
114+
115+
readonly importSkill: (input: {
116+
readonly path: string;
117+
readonly scope: "global" | "project";
118+
readonly cwd?: string | undefined;
119+
}) => Effect.Effect<SkillImportResult, SkillServiceError>;
120+
78121
/**
79122
* Search skills by query.
80123
*/
@@ -91,19 +134,60 @@ export class SkillService extends ServiceMap.Service<SkillService, SkillServiceS
91134
"okcode/skills/SkillService",
92135
) {}
93136

137+
function toSkillEntry(entry: ReturnType<typeof listSkills>[number]) {
138+
return {
139+
name: entry.name,
140+
scope: entry.scope,
141+
description: entry.description,
142+
tags: entry.tags,
143+
path: entry.path,
144+
catalogId: entry.catalogId,
145+
origin: entry.origin,
146+
system: entry.system,
147+
mutable: entry.mutable,
148+
supplementaryFiles: entry.supplementaryFiles,
149+
};
150+
}
151+
152+
const catalogEntries = listBundledSkills();
153+
ensureSystemSkillsInstalled();
154+
94155
export const SkillServiceLive = Layer.succeed(SkillService, {
156+
catalog: (input) =>
157+
Effect.try({
158+
try: () => {
159+
const installed = listSkills(input.cwd);
160+
return {
161+
skills: catalogEntries.map((catalogSkill) => {
162+
const installedEntry = installed.find(
163+
(entry) =>
164+
entry.catalogId === catalogSkill.entry.id || entry.name === catalogSkill.skillName,
165+
);
166+
return Object.assign({}, catalogSkill.entry, {
167+
installed: Boolean(installedEntry),
168+
installedScope: installedEntry?.scope ?? null,
169+
path: installedEntry?.path ?? null,
170+
catalogId: installedEntry?.catalogId ?? catalogSkill.entry.id,
171+
origin: installedEntry?.origin ?? null,
172+
drifted: false,
173+
});
174+
}),
175+
};
176+
},
177+
catch: (cause) =>
178+
new SkillServiceError({
179+
operation: "catalog",
180+
detail: cause instanceof Error ? cause.message : String(cause),
181+
cause: cause instanceof Error ? cause : undefined,
182+
}),
183+
}),
184+
95185
list: (input) =>
96186
Effect.try({
97187
try: () => {
98188
const entries = listSkills(input.cwd);
99189
return {
100-
skills: entries.map((e) => ({
101-
name: e.name,
102-
scope: e.scope,
103-
description: e.description,
104-
tags: e.tags,
105-
path: e.path,
106-
})),
190+
skills: entries.map(toSkillEntry),
107191
};
108192
},
109193
catch: (cause) =>
@@ -128,6 +212,11 @@ export const SkillServiceLive = Layer.succeed(SkillService, {
128212
content: result.content.raw,
129213
path: result.path,
130214
tags: result.tags,
215+
catalogId: result.catalogId,
216+
origin: result.origin,
217+
system: result.system,
218+
mutable: result.mutable,
219+
supplementaryFiles: result.supplementaryFiles,
131220
};
132221
},
133222
catch: (cause) =>
@@ -140,7 +229,14 @@ export const SkillServiceLive = Layer.succeed(SkillService, {
140229

141230
create: (input) =>
142231
Effect.try({
143-
try: () => createSkill(input.name, input.description, input.scope, input.cwd),
232+
try: () =>
233+
createSkill(
234+
input.name,
235+
input.description,
236+
input.scope,
237+
{ tags: input.tags, template: input.template },
238+
input.cwd,
239+
),
144240
catch: (cause) =>
145241
new SkillServiceError({
146242
operation: "create",
@@ -149,6 +245,39 @@ export const SkillServiceLive = Layer.succeed(SkillService, {
149245
}),
150246
}),
151247

248+
install: (input) =>
249+
Effect.try({
250+
try: () => installBundledSkill(input.id, input.scope, input.cwd),
251+
catch: (cause) =>
252+
new SkillServiceError({
253+
operation: "install",
254+
detail: cause instanceof Error ? cause.message : String(cause),
255+
cause: cause instanceof Error ? cause : undefined,
256+
}),
257+
}),
258+
259+
uninstall: (input) =>
260+
Effect.try({
261+
try: () => deleteSkill(input.name, input.scope, input.cwd),
262+
catch: (cause) =>
263+
new SkillServiceError({
264+
operation: "uninstall",
265+
detail: cause instanceof Error ? cause.message : String(cause),
266+
cause: cause instanceof Error ? cause : undefined,
267+
}),
268+
}),
269+
270+
importSkill: (input) =>
271+
Effect.try({
272+
try: () => importSkill(input.path, input.scope, input.cwd),
273+
catch: (cause) =>
274+
new SkillServiceError({
275+
operation: "import",
276+
detail: cause instanceof Error ? cause.message : String(cause),
277+
cause: cause instanceof Error ? cause : undefined,
278+
}),
279+
}),
280+
152281
delete: (input) =>
153282
Effect.try({
154283
try: () => deleteSkill(input.name, input.scope, input.cwd),
@@ -165,13 +294,7 @@ export const SkillServiceLive = Layer.succeed(SkillService, {
165294
try: () => {
166295
const entries = searchSkills(input.query, input.cwd);
167296
return {
168-
skills: entries.map((e) => ({
169-
name: e.name,
170-
scope: e.scope,
171-
description: e.description,
172-
tags: e.tags,
173-
path: e.path,
174-
})),
297+
skills: entries.map(toSkillEntry),
175298
};
176299
},
177300
catch: (cause) =>

apps/server/src/wsServer.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1316,6 +1316,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
13161316
return yield* skillService.list(body);
13171317
}
13181318

1319+
case WS_METHODS.skillCatalog: {
1320+
const body = stripRequestTag(request.body);
1321+
return yield* skillService.catalog(body);
1322+
}
1323+
13191324
case WS_METHODS.skillRead: {
13201325
const body = stripRequestTag(request.body);
13211326
return yield* skillService.read(body);
@@ -1331,6 +1336,21 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
13311336
return yield* skillService.delete(body);
13321337
}
13331338

1339+
case WS_METHODS.skillInstall: {
1340+
const body = stripRequestTag(request.body);
1341+
return yield* skillService.install(body);
1342+
}
1343+
1344+
case WS_METHODS.skillUninstall: {
1345+
const body = stripRequestTag(request.body);
1346+
return yield* skillService.uninstall(body);
1347+
}
1348+
1349+
case WS_METHODS.skillImport: {
1350+
const body = stripRequestTag(request.body);
1351+
return yield* skillService.importSkill(body);
1352+
}
1353+
13341354
case WS_METHODS.skillSearch: {
13351355
const body = stripRequestTag(request.body);
13361356
return yield* skillService.search(body);

0 commit comments

Comments
 (0)