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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions .plans/skills-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Problem

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:
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:

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

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

Global skills can symlink to shared agent skills for deduplication.

### Current discovery

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.
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.

### Current invocation

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

## Design goals

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

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

---
Expand Down
153 changes: 138 additions & 15 deletions apps/server/src/skills/SkillService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,26 @@
* @module SkillService
*/
import type {
SkillCatalogResult,
SkillCreateResult,
SkillImportResult,
SkillInstallResult,
SkillListResult,
SkillReadResult,
SkillSearchResult,
} from "@okcode/contracts";
import { Effect, Layer, Schema, ServiceMap } from "effect";
import {
ensureSystemSkillsInstalled,
importSkill,
installBundledSkill,
listSkills,
readSkill,
searchSkills,
createSkill,
deleteSkill,
} from "@okcode/shared/skill";
import { listBundledSkills } from "@okcode/shared/skillCatalog";

/**
* SkillServiceError - Tagged error for skill service failures.
Expand All @@ -41,6 +48,10 @@ export class SkillServiceError extends Schema.TaggedErrorClass<SkillServiceError
* SkillServiceShape - Service API for skill CRUD and search operations.
*/
export interface SkillServiceShape {
readonly catalog: (input: {
readonly cwd?: string | undefined;
}) => Effect.Effect<SkillCatalogResult, SkillServiceError>;

/**
* List all installed skills.
*/
Expand All @@ -64,6 +75,8 @@ export interface SkillServiceShape {
readonly description: string;
readonly scope: "global" | "project";
readonly cwd?: string | undefined;
readonly tags?: readonly string[] | undefined;
readonly template?: "blank" | "docs-helper" | "automation-helper" | "review-helper" | undefined;
}) => Effect.Effect<SkillCreateResult, SkillServiceError>;

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

readonly install: (input: {
readonly id:
| "pdf"
| "spreadsheet"
| "doc"
| "playwright"
| "github"
| "skill-creator"
| "image-gen"
| "plugin-creator"
| "skill-installer"
| "openclaw-docs"
| "openai-docs"
| "anthropic-docs";
readonly scope: "global" | "project";
readonly cwd?: string | undefined;
}) => Effect.Effect<SkillInstallResult, SkillServiceError>;

readonly uninstall: (input: {
readonly name: string;
readonly scope: "global" | "project";
readonly cwd?: string | undefined;
}) => Effect.Effect<void, SkillServiceError>;

readonly importSkill: (input: {
readonly path: string;
readonly scope: "global" | "project";
readonly cwd?: string | undefined;
}) => Effect.Effect<SkillImportResult, SkillServiceError>;

/**
* Search skills by query.
*/
Expand All @@ -91,19 +134,60 @@ export class SkillService extends ServiceMap.Service<SkillService, SkillServiceS
"okcode/skills/SkillService",
) {}

function toSkillEntry(entry: ReturnType<typeof listSkills>[number]) {
return {
name: entry.name,
scope: entry.scope,
description: entry.description,
tags: entry.tags,
path: entry.path,
catalogId: entry.catalogId,
origin: entry.origin,
system: entry.system,
mutable: entry.mutable,
supplementaryFiles: entry.supplementaryFiles,
};
}

const catalogEntries = listBundledSkills();
ensureSystemSkillsInstalled();

export const SkillServiceLive = Layer.succeed(SkillService, {
catalog: (input) =>
Effect.try({
try: () => {
const installed = listSkills(input.cwd);
return {
skills: catalogEntries.map((catalogSkill) => {
const installedEntry = installed.find(
(entry) =>
entry.catalogId === catalogSkill.entry.id || entry.name === catalogSkill.skillName,
);
return Object.assign({}, catalogSkill.entry, {
installed: Boolean(installedEntry),
installedScope: installedEntry?.scope ?? null,
path: installedEntry?.path ?? null,
catalogId: installedEntry?.catalogId ?? catalogSkill.entry.id,
origin: installedEntry?.origin ?? null,
drifted: false,
});
}),
};
},
catch: (cause) =>
new SkillServiceError({
operation: "catalog",
detail: cause instanceof Error ? cause.message : String(cause),
cause: cause instanceof Error ? cause : undefined,
}),
}),

list: (input) =>
Effect.try({
try: () => {
const entries = listSkills(input.cwd);
return {
skills: entries.map((e) => ({
name: e.name,
scope: e.scope,
description: e.description,
tags: e.tags,
path: e.path,
})),
skills: entries.map(toSkillEntry),
};
},
catch: (cause) =>
Expand All @@ -128,6 +212,11 @@ export const SkillServiceLive = Layer.succeed(SkillService, {
content: result.content.raw,
path: result.path,
tags: result.tags,
catalogId: result.catalogId,
origin: result.origin,
system: result.system,
mutable: result.mutable,
supplementaryFiles: result.supplementaryFiles,
};
},
catch: (cause) =>
Expand All @@ -140,7 +229,14 @@ export const SkillServiceLive = Layer.succeed(SkillService, {

create: (input) =>
Effect.try({
try: () => createSkill(input.name, input.description, input.scope, input.cwd),
try: () =>
createSkill(
input.name,
input.description,
input.scope,
{ tags: input.tags, template: input.template },
input.cwd,
),
catch: (cause) =>
new SkillServiceError({
operation: "create",
Expand All @@ -149,6 +245,39 @@ export const SkillServiceLive = Layer.succeed(SkillService, {
}),
}),

install: (input) =>
Effect.try({
try: () => installBundledSkill(input.id, input.scope, input.cwd),
catch: (cause) =>
new SkillServiceError({
operation: "install",
detail: cause instanceof Error ? cause.message : String(cause),
cause: cause instanceof Error ? cause : undefined,
}),
}),

uninstall: (input) =>
Effect.try({
try: () => deleteSkill(input.name, input.scope, input.cwd),
catch: (cause) =>
new SkillServiceError({
operation: "uninstall",
detail: cause instanceof Error ? cause.message : String(cause),
cause: cause instanceof Error ? cause : undefined,
}),
}),

importSkill: (input) =>
Effect.try({
try: () => importSkill(input.path, input.scope, input.cwd),
catch: (cause) =>
new SkillServiceError({
operation: "import",
detail: cause instanceof Error ? cause.message : String(cause),
cause: cause instanceof Error ? cause : undefined,
}),
}),

delete: (input) =>
Effect.try({
try: () => deleteSkill(input.name, input.scope, input.cwd),
Expand All @@ -165,13 +294,7 @@ export const SkillServiceLive = Layer.succeed(SkillService, {
try: () => {
const entries = searchSkills(input.query, input.cwd);
return {
skills: entries.map((e) => ({
name: e.name,
scope: e.scope,
description: e.description,
tags: e.tags,
path: e.path,
})),
skills: entries.map(toSkillEntry),
};
},
catch: (cause) =>
Expand Down
20 changes: 20 additions & 0 deletions apps/server/src/wsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1316,6 +1316,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
return yield* skillService.list(body);
}

case WS_METHODS.skillCatalog: {
const body = stripRequestTag(request.body);
return yield* skillService.catalog(body);
}

case WS_METHODS.skillRead: {
const body = stripRequestTag(request.body);
return yield* skillService.read(body);
Expand All @@ -1331,6 +1336,21 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
return yield* skillService.delete(body);
}

case WS_METHODS.skillInstall: {
const body = stripRequestTag(request.body);
return yield* skillService.install(body);
}

case WS_METHODS.skillUninstall: {
const body = stripRequestTag(request.body);
return yield* skillService.uninstall(body);
}

case WS_METHODS.skillImport: {
const body = stripRequestTag(request.body);
return yield* skillService.importSkill(body);
}

case WS_METHODS.skillSearch: {
const body = stripRequestTag(request.body);
return yield* skillService.search(body);
Expand Down
Loading
Loading