Files
openwork/apps/server/src/skills.ts
Omar McAdam 2b91b4d777 refactor: repo folder structure (#1038)
* refactor(repo): move OpenWork apps into apps and ee layout

Rebase the monorepo layout migration onto the latest dev changes so the moved app, desktop, share, and cloud surfaces keep working from their new paths. Carry the latest deeplink, token persistence, build, Vercel, and docs updates forward to avoid stale references and broken deploy tooling.

* chore(repo): drop generated desktop artifacts

Ignore the moved Tauri target and sidecar paths so local cargo checks do not pollute the branch. Remove the accidentally committed outputs from the repo while keeping the layout migration intact.

* fix(release): drop built server cli artifact

Stop tracking the locally built apps/server/cli binary so generated server outputs do not leak into commits. Also update the release workflow to check the published scoped package name for @openwork/server before deciding whether npm publish is needed.

* fix(workspace): add stable CLI bin wrappers

Point the server and router package bins at committed wrapper scripts so workspace installs can create shims before dist outputs exist. Keep the wrappers compatible with built binaries and source checkouts to avoid Vercel install warnings without changing runtime behavior.
2026-03-19 11:41:38 -07:00

201 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readdir, readFile, writeFile, mkdir, rm } from "node:fs/promises";
import type { Dirent } from "node:fs";
import { join, resolve } from "node:path";
import { homedir } from "node:os";
import type { SkillItem } from "./types.js";
import { parseFrontmatter, buildFrontmatter } from "./frontmatter.js";
import { exists } from "./utils.js";
import { validateDescription, validateSkillName } from "./validators.js";
import { ApiError } from "./errors.js";
import { projectSkillsDir } from "./workspace-files.js";
async function findWorkspaceRoots(workspaceRoot: string): Promise<string[]> {
const roots: string[] = [];
let current = resolve(workspaceRoot);
while (true) {
roots.push(current);
const gitPath = join(current, ".git");
if (await exists(gitPath)) break;
const parent = resolve(current, "..");
if (parent === current) break;
current = parent;
}
return roots;
}
const extractTriggerFromBody = (body: string) => {
const lines = body.split(/\r?\n/);
let inWhenSection = false;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (/^#{1,6}\s+/.test(trimmed)) {
const heading = trimmed.replace(/^#{1,6}\s+/, "").trim();
inWhenSection = /^when to use$/i.test(heading);
continue;
}
if (!inWhenSection) continue;
const cleaned = trimmed
.replace(/^[-*+]\s+/, "")
.replace(/^\d+[.)]\s+/, "")
.trim();
if (cleaned) return cleaned;
}
return "";
};
async function parseSkillEntry(
skillPath: string,
entryName: string,
scope: "project" | "global",
): Promise<SkillItem | null> {
const content = await readFile(skillPath, "utf8");
const { data, body } = parseFrontmatter(content);
const name = typeof data.name === "string" ? data.name : entryName;
const description = typeof data.description === "string" ? data.description : "";
const trigger =
typeof data.trigger === "string"
? data.trigger
: typeof data.when === "string"
? data.when
: extractTriggerFromBody(body);
try {
validateSkillName(name);
validateDescription(description);
} catch {
return null;
}
if (name !== entryName) return null;
return {
name,
description,
path: skillPath,
scope,
trigger: trigger.trim() || undefined,
};
}
async function listSkillsInDir(dir: string, scope: "project" | "global"): Promise<SkillItem[]> {
if (!(await exists(dir))) return [];
const entries = await readdir(dir, { withFileTypes: true });
const items: SkillItem[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillPath = join(dir, entry.name, "SKILL.md");
if (await exists(skillPath)) {
// Direct skill: <dir>/<name>/SKILL.md
const item = await parseSkillEntry(skillPath, entry.name, scope);
if (item) items.push(item);
} else {
// Domain/category folder: <dir>/<domain>/<name>/SKILL.md scan one level deeper.
// This supports the convention where global skills are organised as
// skills/<domain>/<skill-name>/SKILL.md
// in addition to the flat skills/<skill-name>/SKILL.md layout.
const domainDir = join(dir, entry.name);
let subEntries: Dirent[];
try {
subEntries = await readdir(domainDir, { withFileTypes: true });
} catch {
continue;
}
for (const subEntry of subEntries) {
if (!subEntry.isDirectory()) continue;
const subSkillPath = join(domainDir, subEntry.name, "SKILL.md");
if (!(await exists(subSkillPath))) continue;
const item = await parseSkillEntry(subSkillPath, subEntry.name, scope);
if (item) items.push(item);
}
}
}
return items;
}
export async function listSkills(workspaceRoot: string, includeGlobal: boolean): Promise<SkillItem[]> {
const roots = await findWorkspaceRoots(workspaceRoot);
const items: SkillItem[] = [];
for (const root of roots) {
const opencodeDir = join(root, ".opencode", "skills");
const claudeDir = join(root, ".claude", "skills");
items.push(...(await listSkillsInDir(opencodeDir, "project")));
items.push(...(await listSkillsInDir(claudeDir, "project")));
}
if (includeGlobal) {
const globalOpenWork = join(homedir(), ".config", "opencode", "skills");
const globalClaude = join(homedir(), ".claude", "skills");
const globalAgents = join(homedir(), ".agents", "skills");
const globalAgentLegacy = join(homedir(), ".agent", "skills");
items.push(...(await listSkillsInDir(globalOpenWork, "global")));
items.push(...(await listSkillsInDir(globalClaude, "global")));
items.push(...(await listSkillsInDir(globalAgents, "global")));
items.push(...(await listSkillsInDir(globalAgentLegacy, "global")));
}
const seen = new Set<string>();
return items.filter((item) => {
if (seen.has(item.name)) return false;
seen.add(item.name);
return true;
});
}
export async function upsertSkill(
workspaceRoot: string,
payload: { name: string; content: string; description?: string },
): Promise<{ path: string; action: "added" | "updated" }> {
const name = payload.name.trim();
validateSkillName(name);
if (!payload.content) {
throw new ApiError(400, "invalid_skill_content", "Skill content is required");
}
let content = payload.content;
const { data, body } = parseFrontmatter(payload.content);
if (Object.keys(data).length > 0) {
const frontmatterName = typeof data.name === "string" ? data.name : "";
const frontmatterDescription = typeof data.description === "string" ? data.description : "";
if (frontmatterName && frontmatterName !== name) {
throw new ApiError(400, "invalid_skill_name", "Skill frontmatter name must match payload name");
}
validateDescription(frontmatterDescription || payload.description);
const nextDescription = frontmatterDescription || payload.description || "";
const frontmatter = buildFrontmatter({
...data,
name,
description: nextDescription,
});
content = frontmatter + body.replace(/^\n/, "");
} else {
validateDescription(payload.description);
const frontmatter = buildFrontmatter({ name, description: payload.description });
content = frontmatter + payload.content.replace(/^\n/, "");
}
const baseDir = projectSkillsDir(workspaceRoot);
const skillDir = join(baseDir, name);
await mkdir(skillDir, { recursive: true });
const skillPath = join(skillDir, "SKILL.md");
const existed = await exists(skillPath);
await writeFile(skillPath, content.endsWith("\n") ? content : content + "\n", "utf8");
return { path: skillPath, action: existed ? "updated" : "added" };
}
export async function deleteSkill(workspaceRoot: string, name: string): Promise<{ path: string }> {
const trimmed = name.trim();
validateSkillName(trimmed);
const baseDir = projectSkillsDir(workspaceRoot);
const skillDir = join(baseDir, trimmed);
const skillPath = join(skillDir, "SKILL.md");
if (!(await exists(skillPath))) {
throw new ApiError(404, "skill_not_found", `Skill not found: ${trimmed}`);
}
await rm(skillDir, { recursive: true, force: true });
return { path: skillDir };
}