openwork-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/skills.js ADDED
@@ -0,0 +1,143 @@
1
+ import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { parseFrontmatter, buildFrontmatter } from "./frontmatter.js";
5
+ import { exists } from "./utils.js";
6
+ import { validateDescription, validateSkillName } from "./validators.js";
7
+ import { ApiError } from "./errors.js";
8
+ import { projectSkillsDir } from "./workspace-files.js";
9
+ async function findWorkspaceRoots(workspaceRoot) {
10
+ const roots = [];
11
+ let current = resolve(workspaceRoot);
12
+ while (true) {
13
+ roots.push(current);
14
+ const gitPath = join(current, ".git");
15
+ if (await exists(gitPath))
16
+ break;
17
+ const parent = resolve(current, "..");
18
+ if (parent === current)
19
+ break;
20
+ current = parent;
21
+ }
22
+ return roots;
23
+ }
24
+ const extractTriggerFromBody = (body) => {
25
+ const lines = body.split(/\r?\n/);
26
+ let inWhenSection = false;
27
+ for (const line of lines) {
28
+ const trimmed = line.trim();
29
+ if (!trimmed)
30
+ continue;
31
+ if (/^#{1,6}\s+/.test(trimmed)) {
32
+ const heading = trimmed.replace(/^#{1,6}\s+/, "").trim();
33
+ inWhenSection = /^when to use$/i.test(heading);
34
+ continue;
35
+ }
36
+ if (!inWhenSection)
37
+ continue;
38
+ const cleaned = trimmed
39
+ .replace(/^[-*+]\s+/, "")
40
+ .replace(/^\d+[.)]\s+/, "")
41
+ .trim();
42
+ if (cleaned)
43
+ return cleaned;
44
+ }
45
+ return "";
46
+ };
47
+ async function listSkillsInDir(dir, scope) {
48
+ if (!(await exists(dir)))
49
+ return [];
50
+ const entries = await readdir(dir, { withFileTypes: true });
51
+ const items = [];
52
+ for (const entry of entries) {
53
+ if (!entry.isDirectory())
54
+ continue;
55
+ const skillPath = join(dir, entry.name, "SKILL.md");
56
+ if (!(await exists(skillPath)))
57
+ continue;
58
+ const content = await readFile(skillPath, "utf8");
59
+ const { data, body } = parseFrontmatter(content);
60
+ const name = typeof data.name === "string" ? data.name : entry.name;
61
+ const description = typeof data.description === "string" ? data.description : "";
62
+ const trigger = typeof data.trigger === "string"
63
+ ? data.trigger
64
+ : typeof data.when === "string"
65
+ ? data.when
66
+ : extractTriggerFromBody(body);
67
+ try {
68
+ validateSkillName(name);
69
+ validateDescription(description);
70
+ }
71
+ catch {
72
+ continue;
73
+ }
74
+ if (name !== entry.name)
75
+ continue;
76
+ items.push({
77
+ name,
78
+ description,
79
+ path: skillPath,
80
+ scope,
81
+ trigger: trigger.trim() || undefined,
82
+ });
83
+ }
84
+ return items;
85
+ }
86
+ export async function listSkills(workspaceRoot, includeGlobal) {
87
+ const roots = await findWorkspaceRoots(workspaceRoot);
88
+ const items = [];
89
+ for (const root of roots) {
90
+ const opencodeDir = join(root, ".opencode", "skills");
91
+ const claudeDir = join(root, ".claude", "skills");
92
+ items.push(...(await listSkillsInDir(opencodeDir, "project")));
93
+ items.push(...(await listSkillsInDir(claudeDir, "project")));
94
+ }
95
+ if (includeGlobal) {
96
+ const globalOpenWork = join(homedir(), ".config", "opencode", "skills");
97
+ const globalClaude = join(homedir(), ".claude", "skills");
98
+ items.push(...(await listSkillsInDir(globalOpenWork, "global")));
99
+ items.push(...(await listSkillsInDir(globalClaude, "global")));
100
+ }
101
+ const seen = new Set();
102
+ return items.filter((item) => {
103
+ if (seen.has(item.name))
104
+ return false;
105
+ seen.add(item.name);
106
+ return true;
107
+ });
108
+ }
109
+ export async function upsertSkill(workspaceRoot, payload) {
110
+ const name = payload.name.trim();
111
+ validateSkillName(name);
112
+ if (!payload.content) {
113
+ throw new ApiError(400, "invalid_skill_content", "Skill content is required");
114
+ }
115
+ let content = payload.content;
116
+ const { data, body } = parseFrontmatter(payload.content);
117
+ if (Object.keys(data).length > 0) {
118
+ const frontmatterName = typeof data.name === "string" ? data.name : "";
119
+ const frontmatterDescription = typeof data.description === "string" ? data.description : "";
120
+ if (frontmatterName && frontmatterName !== name) {
121
+ throw new ApiError(400, "invalid_skill_name", "Skill frontmatter name must match payload name");
122
+ }
123
+ validateDescription(frontmatterDescription || payload.description);
124
+ const nextDescription = frontmatterDescription || payload.description || "";
125
+ const frontmatter = buildFrontmatter({
126
+ ...data,
127
+ name,
128
+ description: nextDescription,
129
+ });
130
+ content = frontmatter + body.replace(/^\n/, "");
131
+ }
132
+ else {
133
+ validateDescription(payload.description);
134
+ const frontmatter = buildFrontmatter({ name, description: payload.description });
135
+ content = frontmatter + payload.content.replace(/^\n/, "");
136
+ }
137
+ const baseDir = projectSkillsDir(workspaceRoot);
138
+ const skillDir = join(baseDir, name);
139
+ await mkdir(skillDir, { recursive: true });
140
+ const skillPath = join(skillDir, "SKILL.md");
141
+ await writeFile(skillPath, content.endsWith("\n") ? content : content + "\n", "utf8");
142
+ return skillPath;
143
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/utils.js ADDED
@@ -0,0 +1,51 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, stat } from "node:fs/promises";
3
+ export async function exists(path) {
4
+ try {
5
+ await stat(path);
6
+ return true;
7
+ }
8
+ catch {
9
+ return false;
10
+ }
11
+ }
12
+ export async function ensureDir(path) {
13
+ await mkdir(path, { recursive: true });
14
+ }
15
+ export async function readJsonFile(path) {
16
+ try {
17
+ const raw = await readFile(path, "utf8");
18
+ return JSON.parse(raw);
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ export function hashToken(token) {
25
+ return createHash("sha256").update(token).digest("hex");
26
+ }
27
+ export function shortId() {
28
+ return randomUUID();
29
+ }
30
+ export function parseList(input) {
31
+ if (!input)
32
+ return [];
33
+ const trimmed = input.trim();
34
+ if (!trimmed)
35
+ return [];
36
+ if (trimmed.startsWith("[")) {
37
+ try {
38
+ const parsed = JSON.parse(trimmed);
39
+ if (Array.isArray(parsed)) {
40
+ return parsed.map((item) => String(item)).filter(Boolean);
41
+ }
42
+ }
43
+ catch {
44
+ return [];
45
+ }
46
+ }
47
+ return trimmed
48
+ .split(/[,;]/)
49
+ .map((value) => value.trim())
50
+ .filter(Boolean);
51
+ }
@@ -0,0 +1,51 @@
1
+ import { ApiError } from "./errors.js";
2
+ const SKILL_NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
3
+ const COMMAND_NAME_REGEX = /^[A-Za-z0-9_-]+$/;
4
+ const MCP_NAME_REGEX = /^[A-Za-z0-9_-]+$/;
5
+ export function validateSkillName(name) {
6
+ if (!name || name.length < 1 || name.length > 64 || !SKILL_NAME_REGEX.test(name)) {
7
+ throw new ApiError(400, "invalid_skill_name", "Skill name must be kebab-case (1-64 chars)");
8
+ }
9
+ }
10
+ export function validateDescription(description) {
11
+ if (!description || description.length < 1 || description.length > 1024) {
12
+ throw new ApiError(422, "invalid_description", "Description must be 1-1024 characters");
13
+ }
14
+ }
15
+ export function validatePluginSpec(spec) {
16
+ if (!spec || spec.trim().length === 0) {
17
+ throw new ApiError(400, "invalid_plugin_spec", "Plugin spec is required");
18
+ }
19
+ }
20
+ export function sanitizeCommandName(name) {
21
+ const trimmed = name.trim().replace(/^\/+/, "");
22
+ return trimmed;
23
+ }
24
+ export function validateCommandName(name) {
25
+ if (!name || !COMMAND_NAME_REGEX.test(name)) {
26
+ throw new ApiError(400, "invalid_command_name", "Command name must be alphanumeric with _ or -");
27
+ }
28
+ }
29
+ export function validateMcpName(name) {
30
+ if (!name || name.startsWith("-") || !MCP_NAME_REGEX.test(name)) {
31
+ throw new ApiError(400, "invalid_mcp_name", "MCP name must be alphanumeric and not start with -");
32
+ }
33
+ }
34
+ export function validateMcpConfig(config) {
35
+ const type = config.type;
36
+ if (type !== "local" && type !== "remote") {
37
+ throw new ApiError(400, "invalid_mcp_config", "MCP config type must be local or remote");
38
+ }
39
+ if (type === "local") {
40
+ const command = config.command;
41
+ if (!Array.isArray(command) || command.length === 0) {
42
+ throw new ApiError(400, "invalid_mcp_config", "Local MCP requires command array");
43
+ }
44
+ }
45
+ if (type === "remote") {
46
+ const url = config.url;
47
+ if (!url || typeof url !== "string") {
48
+ throw new ApiError(400, "invalid_mcp_config", "Remote MCP requires url");
49
+ }
50
+ }
51
+ }
@@ -0,0 +1,23 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ export function opencodeConfigPath(workspaceRoot) {
4
+ const jsoncPath = join(workspaceRoot, "opencode.jsonc");
5
+ const jsonPath = join(workspaceRoot, "opencode.json");
6
+ if (existsSync(jsoncPath))
7
+ return jsoncPath;
8
+ if (existsSync(jsonPath))
9
+ return jsonPath;
10
+ return jsoncPath;
11
+ }
12
+ export function openworkConfigPath(workspaceRoot) {
13
+ return join(workspaceRoot, ".opencode", "openwork.json");
14
+ }
15
+ export function projectSkillsDir(workspaceRoot) {
16
+ return join(workspaceRoot, ".opencode", "skills");
17
+ }
18
+ export function projectCommandsDir(workspaceRoot) {
19
+ return join(workspaceRoot, ".opencode", "commands");
20
+ }
21
+ export function projectPluginsDir(workspaceRoot) {
22
+ return join(workspaceRoot, ".opencode", "plugins");
23
+ }
@@ -0,0 +1,21 @@
1
+ import { createHash } from "node:crypto";
2
+ import { basename, resolve } from "node:path";
3
+ export function workspaceIdForPath(path) {
4
+ const hash = createHash("sha256").update(path).digest("hex");
5
+ return `ws_${hash.slice(0, 12)}`;
6
+ }
7
+ export function buildWorkspaceInfos(workspaces, cwd) {
8
+ return workspaces.map((workspace) => {
9
+ const resolvedPath = resolve(cwd, workspace.path);
10
+ return {
11
+ id: workspaceIdForPath(resolvedPath),
12
+ name: workspace.name ?? basename(resolvedPath),
13
+ path: resolvedPath,
14
+ workspaceType: workspace.workspaceType ?? "local",
15
+ baseUrl: workspace.baseUrl,
16
+ directory: workspace.directory,
17
+ opencodeUsername: workspace.opencodeUsername,
18
+ opencodePassword: workspace.opencodePassword,
19
+ };
20
+ });
21
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "openwork-server",
3
+ "version": "0.1.0",
4
+ "description": "Filesystem-backed API for OpenWork remote clients",
5
+ "type": "module",
6
+ "bin": {
7
+ "openwork-server": "dist/cli.js"
8
+ },
9
+ "scripts": {
10
+ "dev": "bun src/cli.ts",
11
+ "build": "tsc -p tsconfig.json",
12
+ "build:bin": "bun ./script/build.ts --outdir dist/bin",
13
+ "build:bin:all": "bun ./script/build.ts --outdir dist/bin --target bun-darwin-arm64 --target bun-darwin-x64 --target bun-linux-x64 --target bun-windows-x64",
14
+ "start": "bun dist/cli.js",
15
+ "typecheck": "tsc -p tsconfig.json --noEmit"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md"
20
+ ],
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/different-ai/openwork.git",
24
+ "directory": "packages/server"
25
+ },
26
+ "homepage": "https://github.com/different-ai/openwork/tree/dev/packages/server",
27
+ "bugs": {
28
+ "url": "https://github.com/different-ai/openwork/issues"
29
+ },
30
+ "keywords": [
31
+ "openwork",
32
+ "server",
33
+ "opencode",
34
+ "headless",
35
+ "cli"
36
+ ],
37
+ "license": "MIT",
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "dependencies": {
42
+ "jsonc-parser": "^3.2.1",
43
+ "minimatch": "^10.0.1",
44
+ "yaml": "^2.6.1"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^22.10.2",
48
+ "@types/minimatch": "^5.1.2",
49
+ "bun-types": "^1.3.6",
50
+ "typescript": "^5.6.3"
51
+ },
52
+ "packageManager": "pnpm@10.27.0"
53
+ }