@zibby/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/LICENSE +21 -0
- package/README.md +228 -0
- package/package.json +53 -0
- package/src/browser.js +64 -0
- package/src/function-skill.js +160 -0
- package/src/github.js +54 -0
- package/src/index.js +35 -0
- package/src/jira.js +110 -0
- package/src/memory.js +124 -0
- package/src/slack.js +112 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zibby
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# @zibby/skills
|
|
2
|
+
|
|
3
|
+
Skill definitions for the Zibby test automation framework.
|
|
4
|
+
|
|
5
|
+
A **skill** is the contract between a workflow node and a tool. It tells the framework what the tool does, how to start it, and what it needs. The framework never hardcodes any skill by name — it reads the skill definition and wires things up generically for both Claude and Cursor agents.
|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @zibby/skills
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Import the package to register all built-in skills:
|
|
14
|
+
|
|
15
|
+
```javascript
|
|
16
|
+
import '@zibby/skills';
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## The `skill()` factory
|
|
20
|
+
|
|
21
|
+
One function to create any skill. Auto-detects the type and auto-registers.
|
|
22
|
+
|
|
23
|
+
### Function skill
|
|
24
|
+
|
|
25
|
+
One skill = one tool. Flat, no nesting.
|
|
26
|
+
|
|
27
|
+
```javascript
|
|
28
|
+
import { skill } from '@zibby/skills';
|
|
29
|
+
|
|
30
|
+
export const add = skill('add', {
|
|
31
|
+
description: 'Add two numbers',
|
|
32
|
+
input: { a: 'number', b: 'number' },
|
|
33
|
+
handler: async ({ a, b }) => ({ result: a + b })
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Use it in a node:
|
|
38
|
+
|
|
39
|
+
```javascript
|
|
40
|
+
export const mathNode = {
|
|
41
|
+
name: 'do_math',
|
|
42
|
+
skills: ['add'],
|
|
43
|
+
prompt: (state) => `Add ${state.a} and ${state.b}`,
|
|
44
|
+
};
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### MCP skill
|
|
48
|
+
|
|
49
|
+
For wrapping existing MCP server packages:
|
|
50
|
+
|
|
51
|
+
```javascript
|
|
52
|
+
import { skill } from '@zibby/skills';
|
|
53
|
+
|
|
54
|
+
export const linear = skill('linear', {
|
|
55
|
+
envKeys: ['LINEAR_API_KEY'],
|
|
56
|
+
description: 'Linear issue tracker',
|
|
57
|
+
resolve() {
|
|
58
|
+
if (!process.env.LINEAR_API_KEY) return null;
|
|
59
|
+
return {
|
|
60
|
+
command: 'npx',
|
|
61
|
+
args: ['-y', '@modelcontextprotocol/server-linear'],
|
|
62
|
+
env: { LINEAR_API_KEY: process.env.LINEAR_API_KEY },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Built-in skills
|
|
69
|
+
|
|
70
|
+
| ID | Server | MCP Package |
|
|
71
|
+
|----|--------|-------------|
|
|
72
|
+
| `browser` | `playwright` | `@zibby/mcp-browser` / `@playwright/mcp` |
|
|
73
|
+
| `jira` | `jira` | `@zibby/mcp-jira` |
|
|
74
|
+
| `github` | `github` | `@modelcontextprotocol/server-github` |
|
|
75
|
+
| `slack` | `slack` | `@modelcontextprotocol/server-slack` |
|
|
76
|
+
|
|
77
|
+
## Function skill API
|
|
78
|
+
|
|
79
|
+
```javascript
|
|
80
|
+
skill(id, { description, input, handler })
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
- `id` — Unique skill identifier (used in `skills: ['add']`)
|
|
84
|
+
- `description` — What the tool does (shown to the LLM)
|
|
85
|
+
- `input` — Parameter definitions:
|
|
86
|
+
|
|
87
|
+
```javascript
|
|
88
|
+
{
|
|
89
|
+
param: { type: 'string' }, // full form
|
|
90
|
+
other: 'number', // shorthand
|
|
91
|
+
optional: { type: 'string', required: false },
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
- `handler` — The function that runs when the tool is called:
|
|
96
|
+
|
|
97
|
+
```javascript
|
|
98
|
+
handler: async ({ param, other }) => {
|
|
99
|
+
return { result: 'something' }; // any JSON-serializable value
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Handler rules
|
|
104
|
+
|
|
105
|
+
- Must be `async` (or return a Promise)
|
|
106
|
+
- Receives one object argument with the input parameters
|
|
107
|
+
- Must return a JSON-serializable value
|
|
108
|
+
- Has full access to imports, closures, and the module scope
|
|
109
|
+
- Runs in a child process (the function bridge)
|
|
110
|
+
|
|
111
|
+
### More examples
|
|
112
|
+
|
|
113
|
+
```javascript
|
|
114
|
+
import { skill } from '@zibby/skills';
|
|
115
|
+
|
|
116
|
+
export const fetchUrl = skill('fetch_url', {
|
|
117
|
+
description: 'Fetch a URL and return the response body',
|
|
118
|
+
input: { url: 'string' },
|
|
119
|
+
handler: async ({ url }) => {
|
|
120
|
+
const res = await fetch(url);
|
|
121
|
+
return { status: res.status, body: await res.text() };
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
export const healthCheck = skill('health_check', {
|
|
126
|
+
description: 'Check if the service is running',
|
|
127
|
+
handler: async () => ({ status: 'ok', timestamp: Date.now() })
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## MCP skill API
|
|
132
|
+
|
|
133
|
+
```javascript
|
|
134
|
+
skill(id, config)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Config object:
|
|
138
|
+
|
|
139
|
+
| Property | Required | Description |
|
|
140
|
+
|---|---|---|
|
|
141
|
+
| `resolve(options)` | Yes | Returns `{ command, args, env }` or `null` |
|
|
142
|
+
| `serverName` | No | MCP server name (defaults to `id`) |
|
|
143
|
+
| `allowedTools` | No | Tool patterns (defaults to `['mcp__<serverName>__*']`) |
|
|
144
|
+
| `envKeys` | No | Env vars the skill needs |
|
|
145
|
+
| `description` | No | Human-readable description |
|
|
146
|
+
| `tools` | No | Tool schemas for compile-time validation |
|
|
147
|
+
| `cursorKey` | No | Override key in `~/.cursor/mcp.json` |
|
|
148
|
+
| `sessionEnvKey` | No | Env var for session artifact paths (Cursor only) |
|
|
149
|
+
|
|
150
|
+
### Advanced example: custom binary with fallback
|
|
151
|
+
|
|
152
|
+
```javascript
|
|
153
|
+
import { skill } from '@zibby/skills';
|
|
154
|
+
import { createRequire } from 'module';
|
|
155
|
+
|
|
156
|
+
const _require = createRequire(import.meta.url);
|
|
157
|
+
|
|
158
|
+
export const database = skill('database', {
|
|
159
|
+
envKeys: ['DATABASE_URL'],
|
|
160
|
+
description: 'Database query MCP server',
|
|
161
|
+
resolve({ sessionPath } = {}) {
|
|
162
|
+
let bin;
|
|
163
|
+
try { bin = _require.resolve('@myorg/mcp-database/server.js'); }
|
|
164
|
+
catch { bin = null; }
|
|
165
|
+
|
|
166
|
+
if (bin) {
|
|
167
|
+
return {
|
|
168
|
+
command: 'node',
|
|
169
|
+
args: [bin, '--read-only'],
|
|
170
|
+
env: { DATABASE_URL: process.env.DATABASE_URL },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
command: 'npx',
|
|
176
|
+
args: ['-y', '@myorg/mcp-database', '--read-only'],
|
|
177
|
+
env: { DATABASE_URL: process.env.DATABASE_URL },
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## How it works under the hood
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
Node definition Skill definition Agent strategy
|
|
187
|
+
───────────── ──────────────── ──────────────
|
|
188
|
+
skills: ['add'] ──► getSkill('add') ──► strategy-specific setup
|
|
189
|
+
│
|
|
190
|
+
▼
|
|
191
|
+
skill.resolve()
|
|
192
|
+
│
|
|
193
|
+
▼
|
|
194
|
+
{ command, args, env }
|
|
195
|
+
│
|
|
196
|
+
┌────────┴────────┐
|
|
197
|
+
▼ ▼
|
|
198
|
+
Claude SDK Cursor CLI
|
|
199
|
+
────────── ──────────
|
|
200
|
+
In-memory Writes to
|
|
201
|
+
mcpServers ~/.cursor/mcp.json
|
|
202
|
+
param to before spawning
|
|
203
|
+
query() `agent` CLI
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
**Claude**: The SDK receives `mcpServers` as a parameter. It spawns the MCP server as a child process, connects via stdio, routes tool calls through it.
|
|
207
|
+
|
|
208
|
+
**Cursor**: The framework writes `~/.cursor/mcp.json` to disk before spawning the `agent` CLI. Cursor reads that file and manages MCP servers itself.
|
|
209
|
+
|
|
210
|
+
The strategies never reference any skill by name. They loop over the skill definitions and call `resolve()` on each.
|
|
211
|
+
|
|
212
|
+
## API
|
|
213
|
+
|
|
214
|
+
```javascript
|
|
215
|
+
import {
|
|
216
|
+
skill, // Unified factory — auto-detects type, auto-registers
|
|
217
|
+
registerSkill, // Register a raw skill definition
|
|
218
|
+
getSkill, // Get a skill by ID
|
|
219
|
+
hasSkill, // Check if a skill is registered
|
|
220
|
+
getAllSkills, // Get all registered skills (Map)
|
|
221
|
+
listSkillIds, // Get array of registered skill IDs
|
|
222
|
+
SKILLS, // Built-in skill ID constants
|
|
223
|
+
} from '@zibby/skills';
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## License
|
|
227
|
+
|
|
228
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zibby/skills",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Built-in skill definitions for Zibby test automation framework",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./browser": "./src/browser.js",
|
|
10
|
+
"./jira": "./src/jira.js",
|
|
11
|
+
"./github": "./src/github.js",
|
|
12
|
+
"./slack": "./src/slack.js",
|
|
13
|
+
"./memory": "./src/memory.js",
|
|
14
|
+
"./function": "./src/function-skill.js"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"lint": "eslint .",
|
|
18
|
+
"lint:fix": "eslint --fix ."
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"zibby",
|
|
22
|
+
"skills",
|
|
23
|
+
"mcp",
|
|
24
|
+
"automation",
|
|
25
|
+
"testing"
|
|
26
|
+
],
|
|
27
|
+
"author": "Zibby",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"homepage": "https://zibby.app",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://github.com/ZibbyHQ/zibby-agent"
|
|
33
|
+
},
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/ZibbyHQ/zibby-agent/issues"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"src/",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=18.0.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@zibby/core": ">=0.1.0"
|
|
47
|
+
},
|
|
48
|
+
"optionalDependencies": {
|
|
49
|
+
"@zibby/mcp-jira": "*",
|
|
50
|
+
"@zibby/mcp-browser": "*",
|
|
51
|
+
"@zibby/mcp-memory": "*"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/browser.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser Skill
|
|
3
|
+
*
|
|
4
|
+
* Provides Playwright-based browser automation via MCP.
|
|
5
|
+
* Resolves to @zibby/mcp-browser (if installed) or @playwright/mcp as fallback.
|
|
6
|
+
*
|
|
7
|
+
* Call resolve({ sessionPath, workspace }) to get a ready-to-use MCP server
|
|
8
|
+
* config with session-specific args (video dir, viewport, etc.).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createRequire } from 'module';
|
|
12
|
+
|
|
13
|
+
const _require = createRequire(import.meta.url);
|
|
14
|
+
|
|
15
|
+
function resolveBrowserBin() {
|
|
16
|
+
if (process.env.MCP_BROWSER_PATH) return process.env.MCP_BROWSER_PATH;
|
|
17
|
+
try {
|
|
18
|
+
return _require.resolve('@zibby/mcp-browser/bin/mcp-browser-zibby.js');
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const VIDEO_RESOLUTION = '1280x720';
|
|
25
|
+
const VIEWPORT_SIZE = '1280x720';
|
|
26
|
+
|
|
27
|
+
export const browserSkill = {
|
|
28
|
+
id: 'browser',
|
|
29
|
+
serverName: 'playwright',
|
|
30
|
+
cursorKey: 'playwright-official',
|
|
31
|
+
allowedTools: ['mcp__playwright__*'],
|
|
32
|
+
sessionEnvKey: 'ZIBBY_SESSION_INFO',
|
|
33
|
+
description: 'Playwright Browser MCP Server',
|
|
34
|
+
envKeys: [],
|
|
35
|
+
tools: [],
|
|
36
|
+
|
|
37
|
+
resolve({ sessionPath, workspace } = {}) {
|
|
38
|
+
const bin = resolveBrowserBin();
|
|
39
|
+
const outputDir = sessionPath || workspace || 'test-results';
|
|
40
|
+
|
|
41
|
+
if (bin) {
|
|
42
|
+
return {
|
|
43
|
+
command: 'node',
|
|
44
|
+
args: [
|
|
45
|
+
bin,
|
|
46
|
+
`--save-video=${VIDEO_RESOLUTION}`,
|
|
47
|
+
`--viewport-size=${VIEWPORT_SIZE}`,
|
|
48
|
+
`--output-dir=${outputDir}`,
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
command: 'npx',
|
|
55
|
+
args: [
|
|
56
|
+
'-y',
|
|
57
|
+
'@playwright/mcp',
|
|
58
|
+
`--save-video=${VIDEO_RESOLUTION}`,
|
|
59
|
+
`--viewport-size=${VIEWPORT_SIZE}`,
|
|
60
|
+
'--output-dir', outputDir,
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified Skill Factory
|
|
3
|
+
*
|
|
4
|
+
* Function skill (one skill = one tool, flat):
|
|
5
|
+
*
|
|
6
|
+
* import { skill } from '@zibby/skills';
|
|
7
|
+
*
|
|
8
|
+
* export const add = skill('add', {
|
|
9
|
+
* description: 'Add two numbers',
|
|
10
|
+
* input: { a: 'number', b: 'number' },
|
|
11
|
+
* handler: async ({ a, b }) => ({ result: a + b })
|
|
12
|
+
* });
|
|
13
|
+
*
|
|
14
|
+
* MCP skill:
|
|
15
|
+
*
|
|
16
|
+
* import { skill } from '@zibby/skills';
|
|
17
|
+
*
|
|
18
|
+
* export const linear = skill('linear', {
|
|
19
|
+
* resolve() {
|
|
20
|
+
* if (!process.env.LINEAR_API_KEY) return null;
|
|
21
|
+
* return { command: 'npx', args: ['-y', '@linear/mcp-server'],
|
|
22
|
+
* env: { LINEAR_API_KEY: process.env.LINEAR_API_KEY } };
|
|
23
|
+
* }
|
|
24
|
+
* });
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { createRequire } from 'module';
|
|
28
|
+
import { fileURLToPath } from 'url';
|
|
29
|
+
import { registerHandlers } from '@zibby/core/framework/function-skill-registry.js';
|
|
30
|
+
import { registerSkill } from '@zibby/core/framework/skill-registry.js';
|
|
31
|
+
|
|
32
|
+
const _require = createRequire(import.meta.url);
|
|
33
|
+
|
|
34
|
+
function resolveBridgePath() {
|
|
35
|
+
try {
|
|
36
|
+
return _require.resolve('@zibby/core/framework/function-bridge.js');
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const _selfUrl = import.meta.url;
|
|
43
|
+
|
|
44
|
+
function getCallerFile() {
|
|
45
|
+
const original = Error.prepareStackTrace;
|
|
46
|
+
try {
|
|
47
|
+
Error.prepareStackTrace = (_, stack) => stack;
|
|
48
|
+
const err = new Error();
|
|
49
|
+
const stack = err.stack;
|
|
50
|
+
for (let i = 2; i < stack.length; i++) {
|
|
51
|
+
const file = stack[i].getFileName();
|
|
52
|
+
if (file && file !== _selfUrl && !file.startsWith('node:')) {
|
|
53
|
+
return file.startsWith('file://') ? fileURLToPath(file) : file;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
} finally {
|
|
58
|
+
Error.prepareStackTrace = original;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function buildInputSchema(input) {
|
|
63
|
+
if (!input || typeof input !== 'object') {
|
|
64
|
+
return { type: 'object', properties: {}, required: [] };
|
|
65
|
+
}
|
|
66
|
+
const properties = {};
|
|
67
|
+
const required = [];
|
|
68
|
+
for (const [key, def] of Object.entries(input)) {
|
|
69
|
+
if (typeof def === 'string') {
|
|
70
|
+
properties[key] = { type: def };
|
|
71
|
+
required.push(key);
|
|
72
|
+
} else {
|
|
73
|
+
const { required: isRequired, ...rest } = def;
|
|
74
|
+
properties[key] = rest;
|
|
75
|
+
if (isRequired !== false) required.push(key);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { type: 'object', properties, required };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function buildFunctionSkill(id, modulePath, config) {
|
|
82
|
+
if (typeof config.handler !== 'function') {
|
|
83
|
+
throw new Error(`Skill "${id}" must have a handler function`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const handlers = { [id]: config.handler };
|
|
87
|
+
const tools = [{
|
|
88
|
+
name: id,
|
|
89
|
+
description: config.description || '',
|
|
90
|
+
input_schema: buildInputSchema(config.input),
|
|
91
|
+
}];
|
|
92
|
+
|
|
93
|
+
registerHandlers(id, handlers, tools);
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
id,
|
|
97
|
+
type: 'function',
|
|
98
|
+
serverName: id,
|
|
99
|
+
allowedTools: [`mcp__${id}__*`],
|
|
100
|
+
description: config.description || `Function skill: ${id}`,
|
|
101
|
+
envKeys: [],
|
|
102
|
+
tools,
|
|
103
|
+
resolve() {
|
|
104
|
+
const bridge = resolveBridgePath();
|
|
105
|
+
if (!bridge) return null;
|
|
106
|
+
return { command: 'node', args: [bridge, modulePath, id] };
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function buildMcpSkill(id, config) {
|
|
112
|
+
return {
|
|
113
|
+
id,
|
|
114
|
+
type: 'mcp',
|
|
115
|
+
serverName: config.serverName || id,
|
|
116
|
+
allowedTools: config.allowedTools || [`mcp__${config.serverName || id}__*`],
|
|
117
|
+
description: config.description || `MCP skill: ${id}`,
|
|
118
|
+
envKeys: config.envKeys || [],
|
|
119
|
+
tools: config.tools || [],
|
|
120
|
+
resolve: config.resolve,
|
|
121
|
+
...(config.cursorKey && { cursorKey: config.cursorKey }),
|
|
122
|
+
...(config.sessionEnvKey && { sessionEnvKey: config.sessionEnvKey }),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Create and register a skill.
|
|
128
|
+
*
|
|
129
|
+
* Function skill: skill(id, { description, input, handler })
|
|
130
|
+
* MCP skill: skill(id, { resolve(), serverName?, ... })
|
|
131
|
+
*
|
|
132
|
+
* @param {string} id — Unique skill identifier
|
|
133
|
+
* @param {Object} config — Skill definition
|
|
134
|
+
* @returns {Object} A registered skill object
|
|
135
|
+
*/
|
|
136
|
+
export function skill(id, config) {
|
|
137
|
+
let skillObj;
|
|
138
|
+
|
|
139
|
+
if ('handler' in config) {
|
|
140
|
+
if (typeof config.handler !== 'function') {
|
|
141
|
+
throw new Error(`Skill "${id}" must have a handler function`);
|
|
142
|
+
}
|
|
143
|
+
const callerFile = getCallerFile();
|
|
144
|
+
if (!callerFile) {
|
|
145
|
+
throw new Error(`Could not resolve caller file for skill "${id}".`);
|
|
146
|
+
}
|
|
147
|
+
skillObj = buildFunctionSkill(id, callerFile, config);
|
|
148
|
+
} else if (typeof config.resolve === 'function') {
|
|
149
|
+
skillObj = buildMcpSkill(id, config);
|
|
150
|
+
} else {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`Skill "${id}" must have either a handler (function skill) or resolve (MCP skill).`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
registerSkill(skillObj);
|
|
157
|
+
return skillObj;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export const functionSkill = skill;
|
package/src/github.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Skill
|
|
3
|
+
*
|
|
4
|
+
* Provides GitHub issue and repository management via MCP.
|
|
5
|
+
* Requires GITHUB_TOKEN environment variable.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const githubSkill = {
|
|
9
|
+
id: 'github',
|
|
10
|
+
serverName: 'github',
|
|
11
|
+
allowedTools: ['mcp__github__*'],
|
|
12
|
+
envKeys: ['GITHUB_TOKEN'],
|
|
13
|
+
description: 'GitHub MCP Server',
|
|
14
|
+
|
|
15
|
+
resolve() {
|
|
16
|
+
const env = {};
|
|
17
|
+
for (const key of this.envKeys) {
|
|
18
|
+
if (process.env[key]) env[key] = process.env[key];
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
command: 'npx',
|
|
22
|
+
args: ['-y', '@modelcontextprotocol/server-github@latest'],
|
|
23
|
+
env,
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
tools: [
|
|
28
|
+
{
|
|
29
|
+
name: 'github_create_issue',
|
|
30
|
+
description: 'Create a GitHub issue',
|
|
31
|
+
input_schema: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
properties: {
|
|
34
|
+
owner: { type: 'string', description: 'Repository owner' },
|
|
35
|
+
repo: { type: 'string', description: 'Repository name' },
|
|
36
|
+
title: { type: 'string', description: 'Issue title' },
|
|
37
|
+
body: { type: 'string', description: 'Issue body (markdown)' }
|
|
38
|
+
},
|
|
39
|
+
required: ['owner', 'repo', 'title']
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'github_search_issues',
|
|
44
|
+
description: 'Search GitHub issues',
|
|
45
|
+
input_schema: {
|
|
46
|
+
type: 'object',
|
|
47
|
+
properties: {
|
|
48
|
+
query: { type: 'string', description: 'GitHub search query' }
|
|
49
|
+
},
|
|
50
|
+
required: ['query']
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
};
|
package/src/index.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zibby/skills — Built-in skill catalog
|
|
3
|
+
*
|
|
4
|
+
* Importing this module registers all built-in skills with the core
|
|
5
|
+
* skill registry. Users and community packages can register additional
|
|
6
|
+
* skills via registerSkill().
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { registerSkill } from '@zibby/core/framework/skill-registry.js';
|
|
10
|
+
import { browserSkill } from './browser.js';
|
|
11
|
+
import { jiraSkill } from './jira.js';
|
|
12
|
+
import { githubSkill } from './github.js';
|
|
13
|
+
import { slackSkill } from './slack.js';
|
|
14
|
+
import { memorySkill } from './memory.js';
|
|
15
|
+
|
|
16
|
+
registerSkill(browserSkill);
|
|
17
|
+
registerSkill(jiraSkill);
|
|
18
|
+
registerSkill(githubSkill);
|
|
19
|
+
registerSkill(slackSkill);
|
|
20
|
+
registerSkill(memorySkill);
|
|
21
|
+
|
|
22
|
+
// Backward-compat alias: MCP_SERVER_REGISTRY used 'slack_notify' as the key
|
|
23
|
+
registerSkill({ ...slackSkill, id: 'slack_notify' });
|
|
24
|
+
|
|
25
|
+
export const SKILLS = {
|
|
26
|
+
BROWSER: 'browser',
|
|
27
|
+
JIRA: 'jira',
|
|
28
|
+
GITHUB: 'github',
|
|
29
|
+
SLACK: 'slack',
|
|
30
|
+
MEMORY: 'memory',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export { browserSkill, jiraSkill, githubSkill, slackSkill, memorySkill };
|
|
34
|
+
export { skill, functionSkill } from './function-skill.js';
|
|
35
|
+
export { registerSkill, getSkill, hasSkill, getAllSkills, listSkillIds } from '@zibby/core/framework/skill-registry.js';
|
package/src/jira.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jira Skill
|
|
3
|
+
*
|
|
4
|
+
* Provides Jira issue management via MCP.
|
|
5
|
+
* Requires ATLASSIAN_ACCESS_TOKEN and ATLASSIAN_CLOUD_ID environment variables.
|
|
6
|
+
*
|
|
7
|
+
* Call resolve() to get a ready-to-use MCP server config with env vars injected.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createRequire } from 'module';
|
|
11
|
+
|
|
12
|
+
const _require = createRequire(import.meta.url);
|
|
13
|
+
|
|
14
|
+
function resolveJiraBin() {
|
|
15
|
+
if (process.env.MCP_JIRA_PATH) return process.env.MCP_JIRA_PATH;
|
|
16
|
+
try {
|
|
17
|
+
return _require.resolve('@zibby/mcp-jira/index.js');
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const jiraSkill = {
|
|
24
|
+
id: 'jira',
|
|
25
|
+
serverName: 'jira',
|
|
26
|
+
allowedTools: ['mcp__jira__*'],
|
|
27
|
+
envKeys: ['ATLASSIAN_ACCESS_TOKEN', 'ATLASSIAN_CLOUD_ID'],
|
|
28
|
+
description: 'Zibby Jira MCP Server (OAuth Bearer)',
|
|
29
|
+
|
|
30
|
+
resolve() {
|
|
31
|
+
const bin = resolveJiraBin();
|
|
32
|
+
if (!bin) return null;
|
|
33
|
+
|
|
34
|
+
const env = {};
|
|
35
|
+
for (const key of this.envKeys) {
|
|
36
|
+
if (process.env[key]) env[key] = process.env[key];
|
|
37
|
+
}
|
|
38
|
+
if (process.env.ATLASSIAN_INSTANCE_URL) {
|
|
39
|
+
env.ATLASSIAN_INSTANCE_URL = process.env.ATLASSIAN_INSTANCE_URL;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
command: 'node',
|
|
44
|
+
args: [bin],
|
|
45
|
+
env,
|
|
46
|
+
description: this.description,
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
tools: [
|
|
50
|
+
{
|
|
51
|
+
name: 'jira_search',
|
|
52
|
+
description: 'Search Jira issues using JQL',
|
|
53
|
+
input_schema: {
|
|
54
|
+
type: 'object',
|
|
55
|
+
properties: {
|
|
56
|
+
jql: { type: 'string', description: 'JQL query string, e.g. "project = PROJ AND status = Open"' },
|
|
57
|
+
maxResults: { type: 'number', description: 'Max results to return (default 20)' }
|
|
58
|
+
},
|
|
59
|
+
required: ['jql']
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'jira_get_issue',
|
|
64
|
+
description: 'Get details of a specific Jira issue',
|
|
65
|
+
input_schema: {
|
|
66
|
+
type: 'object',
|
|
67
|
+
properties: {
|
|
68
|
+
issueKey: { type: 'string', description: 'Issue key, e.g. PROJ-123' }
|
|
69
|
+
},
|
|
70
|
+
required: ['issueKey']
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: 'jira_add_comment',
|
|
75
|
+
description: 'Add a comment to a Jira issue',
|
|
76
|
+
input_schema: {
|
|
77
|
+
type: 'object',
|
|
78
|
+
properties: {
|
|
79
|
+
issueKey: { type: 'string', description: 'Issue key, e.g. PROJ-123' },
|
|
80
|
+
body: { type: 'string', description: 'Comment text (plain text)' }
|
|
81
|
+
},
|
|
82
|
+
required: ['issueKey', 'body']
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'jira_edit_issue',
|
|
87
|
+
description: 'Update fields on a Jira issue (summary, story points, labels, priority)',
|
|
88
|
+
input_schema: {
|
|
89
|
+
type: 'object',
|
|
90
|
+
properties: {
|
|
91
|
+
issueKey: { type: 'string', description: 'Issue key, e.g. PROJ-123' },
|
|
92
|
+
fields: { type: 'object', description: 'Object of field names to values, e.g. {"summary": "New title", "customfield_10016": 5}', additionalProperties: true }
|
|
93
|
+
},
|
|
94
|
+
required: ['issueKey', 'fields']
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: 'jira_transition_issue',
|
|
99
|
+
description: 'Move a Jira issue to a different status',
|
|
100
|
+
input_schema: {
|
|
101
|
+
type: 'object',
|
|
102
|
+
properties: {
|
|
103
|
+
issueKey: { type: 'string', description: 'Issue key, e.g. PROJ-123' },
|
|
104
|
+
transitionId: { type: 'string', description: 'Transition ID to perform. Omit to list available transitions.' }
|
|
105
|
+
},
|
|
106
|
+
required: ['issueKey']
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
]
|
|
110
|
+
};
|
package/src/memory.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory Skill
|
|
3
|
+
*
|
|
4
|
+
* Provides test memory database tools via MCP (Dolt-backed).
|
|
5
|
+
* The AI agent can query test history, selector stability, page model,
|
|
6
|
+
* and navigation patterns from previous runs.
|
|
7
|
+
*
|
|
8
|
+
* Activated when ZIBBY_MEMORY=1 (set by `zibby run --mem`).
|
|
9
|
+
* Returns null from resolve() otherwise, so the framework skips it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createRequire } from 'module';
|
|
13
|
+
import { execFileSync } from 'child_process';
|
|
14
|
+
import { join } from 'path';
|
|
15
|
+
import { existsSync } from 'fs';
|
|
16
|
+
|
|
17
|
+
const _require = createRequire(import.meta.url);
|
|
18
|
+
|
|
19
|
+
function resolveMemoryBin() {
|
|
20
|
+
if (process.env.MCP_MEMORY_PATH) return process.env.MCP_MEMORY_PATH;
|
|
21
|
+
try {
|
|
22
|
+
return _require.resolve('@zibby/mcp-memory/index.js');
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const memorySkill = {
|
|
29
|
+
id: 'memory',
|
|
30
|
+
serverName: 'memory',
|
|
31
|
+
allowedTools: ['mcp__memory__*'],
|
|
32
|
+
envKeys: [],
|
|
33
|
+
description: 'Zibby Memory MCP Server (test history, selectors, page model)',
|
|
34
|
+
|
|
35
|
+
resolve() {
|
|
36
|
+
if (!process.env.ZIBBY_MEMORY) return null;
|
|
37
|
+
|
|
38
|
+
const bin = resolveMemoryBin();
|
|
39
|
+
if (!bin) return null;
|
|
40
|
+
|
|
41
|
+
const dbPath = join(process.cwd(), '.zibby', 'memory');
|
|
42
|
+
if (!existsSync(join(dbPath, '.dolt'))) return null;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const raw = execFileSync('dolt', ['sql', '-q', 'SELECT COUNT(*) AS cnt FROM test_runs', '-r', 'json'], {
|
|
46
|
+
cwd: dbPath, encoding: 'utf-8', timeout: 5_000,
|
|
47
|
+
});
|
|
48
|
+
const rows = JSON.parse(raw.trim()).rows || [];
|
|
49
|
+
if (!rows[0] || rows[0].cnt === 0) {
|
|
50
|
+
console.log('[memory] Database empty — memory tools activate after first completed run');
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
command: 'node',
|
|
59
|
+
args: [bin, '--db-path', dbPath],
|
|
60
|
+
description: this.description,
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
tools: [
|
|
65
|
+
{
|
|
66
|
+
name: 'memory_get_test_history',
|
|
67
|
+
description: 'Query recent test runs with pass/fail results and timing',
|
|
68
|
+
input_schema: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
properties: {
|
|
71
|
+
specPath: { type: 'string', description: 'Filter by spec path (substring match)' },
|
|
72
|
+
limit: { type: 'number', description: 'Max results (default 10)' },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: 'memory_get_selectors',
|
|
78
|
+
description: 'Query known selectors for a page with stability metrics',
|
|
79
|
+
input_schema: {
|
|
80
|
+
type: 'object',
|
|
81
|
+
properties: {
|
|
82
|
+
pageUrl: { type: 'string', description: 'Filter by page URL (substring match)' },
|
|
83
|
+
limit: { type: 'number', description: 'Max results (default 20)' },
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'memory_get_page_model',
|
|
89
|
+
description: 'Query page structure — elements, roles, selectors',
|
|
90
|
+
input_schema: {
|
|
91
|
+
type: 'object',
|
|
92
|
+
properties: {
|
|
93
|
+
url: { type: 'string', description: 'Filter by page URL (substring match)' },
|
|
94
|
+
limit: { type: 'number', description: 'Max results (default 20)' },
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: 'memory_get_navigation',
|
|
100
|
+
description: 'Query known page-to-page transitions',
|
|
101
|
+
input_schema: {
|
|
102
|
+
type: 'object',
|
|
103
|
+
properties: {
|
|
104
|
+
fromUrl: { type: 'string', description: 'Filter by source URL (substring match)' },
|
|
105
|
+
limit: { type: 'number', description: 'Max results (default 20)' },
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: 'memory_save_insight',
|
|
111
|
+
description: 'Save a useful observation for future runs (selector tips, timing, workarounds)',
|
|
112
|
+
input_schema: {
|
|
113
|
+
type: 'object',
|
|
114
|
+
properties: {
|
|
115
|
+
category: { type: 'string', enum: ['selector_tip', 'timing', 'navigation', 'workaround', 'flaky', 'general'], description: 'Type of insight' },
|
|
116
|
+
content: { type: 'string', description: 'The insight text — be specific and actionable' },
|
|
117
|
+
specPath: { type: 'string', description: 'Related spec path' },
|
|
118
|
+
sessionId: { type: 'string', description: 'Current session ID' },
|
|
119
|
+
},
|
|
120
|
+
required: ['category', 'content'],
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
};
|
package/src/slack.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack Skill
|
|
3
|
+
*
|
|
4
|
+
* Provides Slack messaging and channel management via MCP.
|
|
5
|
+
* Requires SLACK_BOT_TOKEN and SLACK_TEAM_ID environment variables.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const slackSkill = {
|
|
9
|
+
id: 'slack',
|
|
10
|
+
serverName: 'slack',
|
|
11
|
+
allowedTools: ['mcp__slack__*'],
|
|
12
|
+
envKeys: ['SLACK_BOT_TOKEN', 'SLACK_TEAM_ID'],
|
|
13
|
+
description: 'Slack MCP Server',
|
|
14
|
+
|
|
15
|
+
resolve() {
|
|
16
|
+
const env = {};
|
|
17
|
+
for (const key of this.envKeys) {
|
|
18
|
+
if (process.env[key]) env[key] = process.env[key];
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
command: 'npx',
|
|
22
|
+
args: ['-y', '@modelcontextprotocol/server-slack@latest'],
|
|
23
|
+
env,
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
tools: [
|
|
28
|
+
{
|
|
29
|
+
name: 'slack_list_channels',
|
|
30
|
+
description: 'List public channels in the workspace',
|
|
31
|
+
input_schema: { type: 'object', properties: {}, required: [] }
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'slack_post_message',
|
|
35
|
+
description: 'Post a message to a Slack channel or DM',
|
|
36
|
+
input_schema: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
properties: {
|
|
39
|
+
channel: { type: 'string', description: 'Channel ID or name' },
|
|
40
|
+
text: { type: 'string', description: 'Message text' }
|
|
41
|
+
},
|
|
42
|
+
required: ['channel', 'text']
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'slack_reply_to_thread',
|
|
47
|
+
description: 'Reply to a specific message thread',
|
|
48
|
+
input_schema: {
|
|
49
|
+
type: 'object',
|
|
50
|
+
properties: {
|
|
51
|
+
channel: { type: 'string', description: 'Channel ID' },
|
|
52
|
+
thread_ts: { type: 'string', description: 'Thread timestamp to reply to' },
|
|
53
|
+
text: { type: 'string', description: 'Reply text' }
|
|
54
|
+
},
|
|
55
|
+
required: ['channel', 'thread_ts', 'text']
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'slack_add_reaction',
|
|
60
|
+
description: 'Add an emoji reaction to a message',
|
|
61
|
+
input_schema: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
properties: {
|
|
64
|
+
channel: { type: 'string', description: 'Channel ID' },
|
|
65
|
+
timestamp: { type: 'string', description: 'Message timestamp' },
|
|
66
|
+
reaction: { type: 'string', description: 'Emoji name without colons' }
|
|
67
|
+
},
|
|
68
|
+
required: ['channel', 'timestamp', 'reaction']
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'slack_get_channel_history',
|
|
73
|
+
description: 'Get recent messages from a channel',
|
|
74
|
+
input_schema: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
properties: {
|
|
77
|
+
channel: { type: 'string', description: 'Channel ID' },
|
|
78
|
+
limit: { type: 'number', description: 'Number of messages to return' }
|
|
79
|
+
},
|
|
80
|
+
required: ['channel']
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: 'slack_get_thread_replies',
|
|
85
|
+
description: 'Get all replies in a message thread',
|
|
86
|
+
input_schema: {
|
|
87
|
+
type: 'object',
|
|
88
|
+
properties: {
|
|
89
|
+
channel: { type: 'string', description: 'Channel ID' },
|
|
90
|
+
thread_ts: { type: 'string', description: 'Thread timestamp' }
|
|
91
|
+
},
|
|
92
|
+
required: ['channel', 'thread_ts']
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'slack_get_users',
|
|
97
|
+
description: 'List workspace users with basic profiles',
|
|
98
|
+
input_schema: { type: 'object', properties: {}, required: [] }
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: 'slack_get_user_profile',
|
|
102
|
+
description: 'Get detailed profile for a specific user',
|
|
103
|
+
input_schema: {
|
|
104
|
+
type: 'object',
|
|
105
|
+
properties: {
|
|
106
|
+
user_id: { type: 'string', description: 'Slack user ID' }
|
|
107
|
+
},
|
|
108
|
+
required: ['user_id']
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
]
|
|
112
|
+
};
|