@operor/skills 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/src/catalog.ts ADDED
@@ -0,0 +1,213 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { resolve, dirname } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ // ─── Catalog Schema ─────────────────────────────────────────────────────────
6
+
7
+ /** A single MCP skill entry in the catalog. */
8
+ export interface MCPSkillCatalogEntry {
9
+ type?: 'mcp';
10
+ name: string;
11
+ displayName: string;
12
+ description: string;
13
+ category: SkillCategory;
14
+ tags: string[];
15
+ transport: 'stdio' | 'http' | 'sse';
16
+ package?: string;
17
+ command?: string;
18
+ args?: string[];
19
+ url?: string;
20
+ envVars: Record<string, EnvVarSpec>;
21
+ tools: SkillToolSummary[];
22
+ maturity: 'official' | 'community' | 'experimental';
23
+ vendor: string;
24
+ docsUrl?: string;
25
+ version?: string;
26
+ updatedAt?: string;
27
+ }
28
+
29
+ /** A prompt skill entry in the catalog — behavioral instructions, no tools. */
30
+ export interface PromptSkillCatalogEntry {
31
+ type: 'prompt';
32
+ name: string;
33
+ displayName: string;
34
+ description: string;
35
+ category: SkillCategory;
36
+ tags: string[];
37
+ content: string;
38
+ maturity: 'official' | 'community' | 'experimental';
39
+ vendor: string;
40
+ version?: string;
41
+ updatedAt?: string;
42
+ }
43
+
44
+ /** A single skill entry in the catalog — the "app store" listing. */
45
+ export type SkillCatalogEntry = MCPSkillCatalogEntry | PromptSkillCatalogEntry;
46
+
47
+ export function isPromptCatalogEntry(entry: SkillCatalogEntry): entry is PromptSkillCatalogEntry {
48
+ return (entry as any).type === 'prompt';
49
+ }
50
+
51
+ export type SkillCategory =
52
+ | 'commerce'
53
+ | 'payments'
54
+ | 'crm'
55
+ | 'support'
56
+ | 'marketing'
57
+ | 'search'
58
+ | 'analytics'
59
+ | 'communication'
60
+ | 'productivity'
61
+ | 'developer'
62
+ | 'other';
63
+
64
+ /** Describes an env var a skill needs. */
65
+ export interface EnvVarSpec {
66
+ /** What this env var is for */
67
+ description: string;
68
+ /** Whether the skill cannot function without it */
69
+ required: boolean;
70
+ /** Hint for the setup wizard (e.g. "sk-..." or "https://...") */
71
+ placeholder?: string;
72
+ }
73
+
74
+ /** Summary of a tool exposed by a skill — for catalog display, not runtime. */
75
+ export interface SkillToolSummary {
76
+ /** Tool name as exposed by the MCP server */
77
+ name: string;
78
+ /** What this tool does */
79
+ description: string;
80
+ }
81
+
82
+ /** The top-level catalog file structure. */
83
+ export interface SkillCatalog {
84
+ /** Schema version for forward compatibility */
85
+ version: number;
86
+ /** When the catalog was last generated */
87
+ updatedAt: string;
88
+ /** The skill entries */
89
+ skills: SkillCatalogEntry[];
90
+ }
91
+
92
+ // ─── Catalog Loader ─────────────────────────────────────────────────────────
93
+
94
+ /**
95
+ * Load the built-in skill catalog shipped with @operor/skills.
96
+ * Falls back to empty catalog if file is missing.
97
+ */
98
+ export function loadSkillCatalog(): SkillCatalog {
99
+ const catalogPath = resolve(
100
+ dirname(fileURLToPath(import.meta.url)),
101
+ '..',
102
+ 'skill-catalog.json',
103
+ );
104
+
105
+ if (!existsSync(catalogPath)) {
106
+ return { version: 1, updatedAt: new Date().toISOString(), skills: [] };
107
+ }
108
+
109
+ const raw = readFileSync(catalogPath, 'utf-8');
110
+ return JSON.parse(raw) as SkillCatalog;
111
+ }
112
+
113
+ /**
114
+ * Load a custom/override catalog from a specific path.
115
+ */
116
+ export function loadSkillCatalogFrom(filePath: string): SkillCatalog {
117
+ const raw = readFileSync(filePath, 'utf-8');
118
+ return JSON.parse(raw) as SkillCatalog;
119
+ }
120
+
121
+ // ─── Catalog Query Helpers ──────────────────────────────────────────────────
122
+
123
+ /** Filter options for querying the catalog. */
124
+ export interface CatalogFilter {
125
+ category?: SkillCategory;
126
+ maturity?: SkillCatalogEntry['maturity'];
127
+ search?: string;
128
+ tags?: string[];
129
+ }
130
+
131
+ /**
132
+ * Query the catalog with optional filters.
133
+ * Search matches against name, displayName, description, and tags.
134
+ */
135
+ export function querySkillCatalog(
136
+ catalog: SkillCatalog,
137
+ filter?: CatalogFilter,
138
+ ): SkillCatalogEntry[] {
139
+ let results = catalog.skills;
140
+
141
+ if (filter?.category) {
142
+ results = results.filter((s) => s.category === filter.category);
143
+ }
144
+
145
+ if (filter?.maturity) {
146
+ results = results.filter((s) => s.maturity === filter.maturity);
147
+ }
148
+
149
+ if (filter?.tags && filter.tags.length > 0) {
150
+ const filterTags = filter.tags.map((t) => t.toLowerCase());
151
+ results = results.filter((s) =>
152
+ filterTags.some((ft) => s.tags.some((st) => st.toLowerCase().includes(ft))),
153
+ );
154
+ }
155
+
156
+ if (filter?.search) {
157
+ const q = filter.search.toLowerCase();
158
+ results = results.filter(
159
+ (s) =>
160
+ s.name.toLowerCase().includes(q) ||
161
+ s.displayName.toLowerCase().includes(q) ||
162
+ s.description.toLowerCase().includes(q) ||
163
+ s.tags.some((t) => t.toLowerCase().includes(q)),
164
+ );
165
+ }
166
+
167
+ return results;
168
+ }
169
+
170
+ /**
171
+ * Look up a single skill by name.
172
+ */
173
+ export function findSkillInCatalog(
174
+ catalog: SkillCatalog,
175
+ name: string,
176
+ ): SkillCatalogEntry | undefined {
177
+ return catalog.skills.find((s) => s.name === name);
178
+ }
179
+
180
+ /**
181
+ * Convert a catalog entry into a config suitable for mcp.json.
182
+ * Handles both MCP and prompt skill entries.
183
+ */
184
+ export function catalogEntryToConfig(entry: SkillCatalogEntry): Record<string, any> {
185
+ if (isPromptCatalogEntry(entry)) {
186
+ return { type: 'prompt', name: entry.name, content: entry.content, enabled: true };
187
+ }
188
+
189
+ const config: Record<string, any> = {
190
+ name: entry.name,
191
+ transport: entry.transport,
192
+ enabled: true,
193
+ };
194
+
195
+ if (entry.transport === 'stdio') {
196
+ config.command = entry.command ?? 'npx';
197
+ config.args = entry.args ?? (entry.package ? ['-y', entry.package] : []);
198
+ }
199
+
200
+ if ((entry.transport === 'http' || entry.transport === 'sse') && entry.url) {
201
+ config.url = entry.url;
202
+ }
203
+
204
+ const envKeys = Object.keys(entry.envVars);
205
+ if (envKeys.length > 0) {
206
+ config.env = {};
207
+ for (const key of envKeys) {
208
+ config.env[key] = `\${${key}}`;
209
+ }
210
+ }
211
+
212
+ return config;
213
+ }
package/src/config.ts ADDED
@@ -0,0 +1,95 @@
1
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+
4
+ export interface MCPSkillConfig {
5
+ name: string;
6
+ transport: 'stdio' | 'http' | 'sse';
7
+ command?: string;
8
+ args?: string[];
9
+ env?: Record<string, string>;
10
+ url?: string;
11
+ headers?: Record<string, string>;
12
+ toolPrefix?: string;
13
+ enabled?: boolean;
14
+ }
15
+
16
+ export interface PromptSkillConfig {
17
+ type: 'prompt';
18
+ name: string;
19
+ summary?: string;
20
+ version?: string;
21
+ tags?: string[];
22
+ author?: string;
23
+ content: string;
24
+ enabled?: boolean;
25
+ }
26
+
27
+ export type AnySkillConfig = (MCPSkillConfig & { type?: 'mcp' }) | PromptSkillConfig;
28
+
29
+ export function isPromptSkillConfig(config: AnySkillConfig): config is PromptSkillConfig {
30
+ return (config as any).type === 'prompt';
31
+ }
32
+
33
+ export function isMCPSkillConfig(config: AnySkillConfig): config is MCPSkillConfig & { type?: 'mcp' } {
34
+ return !isPromptSkillConfig(config);
35
+ }
36
+
37
+ export interface SkillsConfig {
38
+ skills: AnySkillConfig[];
39
+ }
40
+
41
+ /**
42
+ * Resolve `${ENV_VAR}` placeholders in a string value.
43
+ */
44
+ export function resolveEnvVars(value: string): string {
45
+ return value.replace(/\$\{([^}]+)\}/g, (_match, envVar) => {
46
+ return process.env[envVar] ?? '';
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Resolve env vars in all values of a record.
52
+ */
53
+ export function resolveEnvRecord(record: Record<string, string>): Record<string, string> {
54
+ const resolved: Record<string, string> = {};
55
+ for (const [key, value] of Object.entries(record)) {
56
+ resolved[key] = resolveEnvVars(value);
57
+ }
58
+ return resolved;
59
+ }
60
+
61
+ /**
62
+ * Load skills config from `mcp.json` at the given root (defaults to cwd).
63
+ * Returns empty config if file doesn't exist.
64
+ */
65
+ export function loadSkillsConfig(root?: string): SkillsConfig {
66
+ const configPath = resolve(root ?? process.cwd(), 'mcp.json');
67
+
68
+ if (!existsSync(configPath)) {
69
+ return { skills: [] };
70
+ }
71
+
72
+ const raw = readFileSync(configPath, 'utf-8');
73
+
74
+ let parsed: any;
75
+ try {
76
+ parsed = JSON.parse(raw);
77
+ } catch {
78
+ throw new Error(`Invalid JSON in ${configPath}`);
79
+ }
80
+
81
+ if (!parsed || typeof parsed !== 'object') {
82
+ return { skills: [] };
83
+ }
84
+
85
+ const skills: AnySkillConfig[] = Array.isArray(parsed.skills) ? parsed.skills : [];
86
+ return { skills };
87
+ }
88
+
89
+ /**
90
+ * Save skills config to `mcp.json` at the given root (defaults to cwd).
91
+ */
92
+ export function saveSkillsConfig(config: SkillsConfig, root?: string): void {
93
+ const configPath = resolve(root ?? process.cwd(), 'mcp.json');
94
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
95
+ }
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ export { MCPSkill } from './MCPSkill.js';
2
+ export { PromptSkill } from './PromptSkill.js';
3
+ export { SkillManager } from './SkillManager.js';
4
+ export {
5
+ loadSkillsConfig,
6
+ saveSkillsConfig,
7
+ resolveEnvVars,
8
+ resolveEnvRecord,
9
+ isPromptSkillConfig,
10
+ isMCPSkillConfig,
11
+ } from './config.js';
12
+ export type { MCPSkillConfig, PromptSkillConfig, AnySkillConfig, SkillsConfig } from './config.js';
13
+ export {
14
+ loadSkillCatalog,
15
+ loadSkillCatalogFrom,
16
+ querySkillCatalog,
17
+ findSkillInCatalog,
18
+ catalogEntryToConfig,
19
+ isPromptCatalogEntry,
20
+ } from './catalog.js';
21
+ export type {
22
+ SkillCatalog,
23
+ SkillCatalogEntry,
24
+ MCPSkillCatalogEntry,
25
+ PromptSkillCatalogEntry,
26
+ SkillCategory,
27
+ SkillToolSummary,
28
+ EnvVarSpec,
29
+ CatalogFilter,
30
+ } from './catalog.js';
@@ -0,0 +1,221 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ loadSkillCatalog,
4
+ loadSkillCatalogFrom,
5
+ querySkillCatalog,
6
+ findSkillInCatalog,
7
+ catalogEntryToConfig,
8
+ } from '../src/catalog.js';
9
+ import type { SkillCatalog, SkillCatalogEntry } from '../src/catalog.js';
10
+ import { resolve, dirname } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+
15
+ // ─── Fixtures ────────────────────────────────────────────────────────────────
16
+
17
+ function makeEntry(overrides: Partial<SkillCatalogEntry> = {}): SkillCatalogEntry {
18
+ return {
19
+ name: 'test-skill',
20
+ displayName: 'Test Skill',
21
+ description: 'A test skill for unit tests',
22
+ category: 'commerce',
23
+ tags: ['test', 'ecommerce'],
24
+ transport: 'stdio',
25
+ package: 'test-mcp',
26
+ command: 'npx',
27
+ args: ['-y', 'test-mcp'],
28
+ envVars: {
29
+ TEST_API_KEY: { description: 'API key', required: true, placeholder: 'sk_...' },
30
+ },
31
+ tools: [{ name: 'do_thing', description: 'Does a thing' }],
32
+ maturity: 'community',
33
+ vendor: 'test-vendor',
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ function makeCatalog(skills: SkillCatalogEntry[]): SkillCatalog {
39
+ return { version: 1, updatedAt: '2026-01-01T00:00:00.000Z', skills };
40
+ }
41
+
42
+ // ─── Tests ───────────────────────────────────────────────────────────────────
43
+
44
+ describe('loadSkillCatalog', () => {
45
+ it('should load the built-in catalog', () => {
46
+ const catalog = loadSkillCatalog();
47
+ expect(catalog.version).toBe(1);
48
+ expect(catalog.skills.length).toBeGreaterThan(0);
49
+ expect(catalog.skills[0]).toHaveProperty('name');
50
+ expect(catalog.skills[0]).toHaveProperty('transport');
51
+ });
52
+
53
+ it('should include expected skills', () => {
54
+ const catalog = loadSkillCatalog();
55
+ const names = catalog.skills.map((s) => s.name);
56
+ expect(names).toContain('shopify');
57
+ expect(names).toContain('stripe');
58
+ });
59
+ });
60
+
61
+ describe('loadSkillCatalogFrom', () => {
62
+ it('should load catalog from a custom path', () => {
63
+ const catalogPath = resolve(__dirname, '..', 'skill-catalog.json');
64
+ const catalog = loadSkillCatalogFrom(catalogPath);
65
+ expect(catalog.version).toBe(1);
66
+ expect(catalog.skills.length).toBeGreaterThan(0);
67
+ });
68
+
69
+ it('should throw on missing file', () => {
70
+ expect(() => loadSkillCatalogFrom('/nonexistent/catalog.json')).toThrow();
71
+ });
72
+ });
73
+
74
+ describe('querySkillCatalog', () => {
75
+ const shopify = makeEntry({ name: 'shopify', category: 'commerce', tags: ['ecommerce', 'shopify'], maturity: 'community' });
76
+ const stripe = makeEntry({ name: 'stripe', category: 'payments', tags: ['payments', 'billing'], maturity: 'official' });
77
+ const zendesk = makeEntry({ name: 'zendesk', displayName: 'Zendesk', category: 'support', tags: ['support', 'tickets'], maturity: 'community' });
78
+ const catalog = makeCatalog([shopify, stripe, zendesk]);
79
+
80
+ it('should return all skills with no filter', () => {
81
+ const results = querySkillCatalog(catalog);
82
+ expect(results).toHaveLength(3);
83
+ });
84
+
85
+ it('should filter by category', () => {
86
+ const results = querySkillCatalog(catalog, { category: 'commerce' });
87
+ expect(results).toHaveLength(1);
88
+ expect(results[0].name).toBe('shopify');
89
+ });
90
+
91
+ it('should filter by maturity', () => {
92
+ const results = querySkillCatalog(catalog, { maturity: 'official' });
93
+ expect(results).toHaveLength(1);
94
+ expect(results[0].name).toBe('stripe');
95
+ });
96
+
97
+ it('should filter by tags', () => {
98
+ const results = querySkillCatalog(catalog, { tags: ['support'] });
99
+ expect(results).toHaveLength(1);
100
+ expect(results[0].name).toBe('zendesk');
101
+ });
102
+
103
+ it('should search by name', () => {
104
+ const results = querySkillCatalog(catalog, { search: 'stripe' });
105
+ expect(results).toHaveLength(1);
106
+ expect(results[0].name).toBe('stripe');
107
+ });
108
+
109
+ it('should search by displayName', () => {
110
+ const results = querySkillCatalog(catalog, { search: 'Zendesk' });
111
+ expect(results).toHaveLength(1);
112
+ expect(results[0].name).toBe('zendesk');
113
+ });
114
+
115
+ it('should search case-insensitively', () => {
116
+ const results = querySkillCatalog(catalog, { search: 'SHOPIFY' });
117
+ expect(results).toHaveLength(1);
118
+ expect(results[0].name).toBe('shopify');
119
+ });
120
+
121
+ it('should combine category and search filters', () => {
122
+ const results = querySkillCatalog(catalog, { category: 'commerce', search: 'shopify' });
123
+ expect(results).toHaveLength(1);
124
+ expect(results[0].name).toBe('shopify');
125
+ });
126
+
127
+ it('should return empty for non-matching filters', () => {
128
+ const results = querySkillCatalog(catalog, { search: 'nonexistent' });
129
+ expect(results).toHaveLength(0);
130
+ });
131
+ });
132
+
133
+ describe('findSkillInCatalog', () => {
134
+ const catalog = makeCatalog([
135
+ makeEntry({ name: 'shopify' }),
136
+ makeEntry({ name: 'stripe' }),
137
+ ]);
138
+
139
+ it('should find a skill by name', () => {
140
+ const skill = findSkillInCatalog(catalog, 'shopify');
141
+ expect(skill).toBeDefined();
142
+ expect(skill!.name).toBe('shopify');
143
+ });
144
+
145
+ it('should return undefined for unknown skill', () => {
146
+ const skill = findSkillInCatalog(catalog, 'unknown');
147
+ expect(skill).toBeUndefined();
148
+ });
149
+ });
150
+
151
+ describe('catalogEntryToConfig', () => {
152
+ it('should convert a stdio skill to config', () => {
153
+ const entry = makeEntry({
154
+ name: 'shopify',
155
+ transport: 'stdio',
156
+ command: 'npx',
157
+ args: ['-y', 'shopify-mcp'],
158
+ envVars: {
159
+ SHOPIFY_TOKEN: { description: 'Token', required: true },
160
+ },
161
+ });
162
+
163
+ const config = catalogEntryToConfig(entry);
164
+ expect(config.name).toBe('shopify');
165
+ expect(config.transport).toBe('stdio');
166
+ expect(config.command).toBe('npx');
167
+ expect(config.args).toEqual(['-y', 'shopify-mcp']);
168
+ expect(config.env).toEqual({ SHOPIFY_TOKEN: '${SHOPIFY_TOKEN}' });
169
+ expect(config.enabled).toBe(true);
170
+ });
171
+
172
+ it('should default command to npx and args from package', () => {
173
+ const entry = makeEntry({
174
+ name: 'test',
175
+ transport: 'stdio',
176
+ package: 'test-mcp',
177
+ command: undefined,
178
+ args: undefined,
179
+ });
180
+
181
+ const config = catalogEntryToConfig(entry);
182
+ expect(config.command).toBe('npx');
183
+ expect(config.args).toEqual(['-y', 'test-mcp']);
184
+ });
185
+
186
+ it('should convert an http skill to config', () => {
187
+ const entry = makeEntry({
188
+ name: 'remote-skill',
189
+ transport: 'http',
190
+ url: 'https://api.example.com/mcp',
191
+ envVars: {},
192
+ });
193
+
194
+ const config = catalogEntryToConfig(entry);
195
+ expect(config.transport).toBe('http');
196
+ expect(config.url).toBe('https://api.example.com/mcp');
197
+ expect(config.command).toBeUndefined();
198
+ expect(config.env).toBeUndefined();
199
+ });
200
+
201
+ it('should omit env when no envVars', () => {
202
+ const entry = makeEntry({ envVars: {} });
203
+ const config = catalogEntryToConfig(entry);
204
+ expect(config.env).toBeUndefined();
205
+ });
206
+
207
+ it('should map multiple env vars to placeholder syntax', () => {
208
+ const entry = makeEntry({
209
+ envVars: {
210
+ API_KEY: { description: 'Key', required: true },
211
+ API_SECRET: { description: 'Secret', required: true },
212
+ },
213
+ });
214
+
215
+ const config = catalogEntryToConfig(entry);
216
+ expect(config.env).toEqual({
217
+ API_KEY: '${API_KEY}',
218
+ API_SECRET: '${API_SECRET}',
219
+ });
220
+ });
221
+ });