jobly-mcp 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 +42 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +311 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jobly.ai.vn
|
|
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,42 @@
|
|
|
1
|
+
# jobly-mcp
|
|
2
|
+
|
|
3
|
+
A CLI to add the [JoblyAI](https://github.com/JoblyAI) MCP server to your OpenCode configuration.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx jobly-mcp
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The CLI will:
|
|
12
|
+
1. Prompt for your JoblyAI API key (masked input)
|
|
13
|
+
2. Ask whether to install globally or in the current project
|
|
14
|
+
3. Write the MCP server config to `opencode.json` (or `opencode.jsonc`)
|
|
15
|
+
|
|
16
|
+
## Uninstall
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx jobly-mcp --uninstall
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Flags
|
|
23
|
+
|
|
24
|
+
| Flag | Description |
|
|
25
|
+
|------|-------------|
|
|
26
|
+
| `-u, --uninstall` | Remove the jobly-mcp entry instead of adding it |
|
|
27
|
+
| `-y, --yes` | Skip confirmation prompts (overwrite, comment-loss) |
|
|
28
|
+
| `-V, --version` | Print version |
|
|
29
|
+
| `-h, --help` | Print help |
|
|
30
|
+
|
|
31
|
+
## Where configs are written
|
|
32
|
+
|
|
33
|
+
| Scope | Path |
|
|
34
|
+
|-------|------|
|
|
35
|
+
| Global | `~/.config/opencode/opencode.jsonc` (or `$XDG_CONFIG_HOME/opencode/opencode.jsonc`) |
|
|
36
|
+
| Local | `<nearest-git-root>/opencode.json` |
|
|
37
|
+
|
|
38
|
+
## Requirements
|
|
39
|
+
|
|
40
|
+
- Node.js 20+
|
|
41
|
+
- An OpenCode installation
|
|
42
|
+
- A JoblyAI API key (starts with `jobly_sk_`)
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/setup.ts
|
|
7
|
+
import fs4 from "fs";
|
|
8
|
+
|
|
9
|
+
// src/opencode/config-paths.ts
|
|
10
|
+
import os from "os";
|
|
11
|
+
import path from "path";
|
|
12
|
+
import fs from "fs";
|
|
13
|
+
function getGlobalConfigDir() {
|
|
14
|
+
const home = os.homedir();
|
|
15
|
+
const xdg = process.env.XDG_CONFIG_HOME ?? path.join(home, ".config");
|
|
16
|
+
return path.join(xdg, "opencode");
|
|
17
|
+
}
|
|
18
|
+
function getLocalConfigDir() {
|
|
19
|
+
let dir = process.cwd();
|
|
20
|
+
while (true) {
|
|
21
|
+
if (fs.existsSync(path.join(dir, ".git"))) return dir;
|
|
22
|
+
const parent = path.dirname(dir);
|
|
23
|
+
if (parent === dir) return process.cwd();
|
|
24
|
+
dir = parent;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function resolveConfigFile(scope) {
|
|
28
|
+
const dir = scope === "global" ? getGlobalConfigDir() : getLocalConfigDir();
|
|
29
|
+
const jsonc = path.join(dir, "opencode.jsonc");
|
|
30
|
+
const json = path.join(dir, "opencode.json");
|
|
31
|
+
if (fs.existsSync(jsonc)) return jsonc;
|
|
32
|
+
if (fs.existsSync(json)) return json;
|
|
33
|
+
return scope === "global" ? jsonc : json;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// src/opencode/read-config.ts
|
|
37
|
+
import fs2 from "fs";
|
|
38
|
+
import stripJsonComments from "strip-json-comments";
|
|
39
|
+
function readConfig(filePath) {
|
|
40
|
+
if (!fs2.existsSync(filePath)) {
|
|
41
|
+
return { kind: "missing" };
|
|
42
|
+
}
|
|
43
|
+
const raw = fs2.readFileSync(filePath, "utf8");
|
|
44
|
+
const stripped = stripJsonComments(raw);
|
|
45
|
+
const hadComments = stripped !== raw;
|
|
46
|
+
try {
|
|
47
|
+
const config = JSON.parse(stripped);
|
|
48
|
+
return { kind: "ok", config, raw, hadComments };
|
|
49
|
+
} catch (err) {
|
|
50
|
+
return { kind: "invalid", error: err.message };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// src/opencode/write-config.ts
|
|
55
|
+
import fs3 from "fs";
|
|
56
|
+
import path2 from "path";
|
|
57
|
+
|
|
58
|
+
// src/opencode/types.ts
|
|
59
|
+
var JOBLY_MCP_KEY = "jobly-mcp";
|
|
60
|
+
var JOBLY_MCP_URL = "https://jobly.ai.vn/api/mcp";
|
|
61
|
+
var OPENCODE_SCHEMA_URL = "https://opencode.ai/config.json";
|
|
62
|
+
function buildJoblyMcpEntry(apiKey) {
|
|
63
|
+
return {
|
|
64
|
+
type: "remote",
|
|
65
|
+
url: JOBLY_MCP_URL,
|
|
66
|
+
enabled: true,
|
|
67
|
+
headers: {
|
|
68
|
+
Authorization: `Bearer ${apiKey}`
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function createNewConfig(entry) {
|
|
73
|
+
return {
|
|
74
|
+
$schema: OPENCODE_SCHEMA_URL,
|
|
75
|
+
mcp: {
|
|
76
|
+
[JOBLY_MCP_KEY]: entry
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// src/opencode/write-config.ts
|
|
82
|
+
function hasMcpEntry(config) {
|
|
83
|
+
return Boolean(config.mcp?.[JOBLY_MCP_KEY]);
|
|
84
|
+
}
|
|
85
|
+
function setMcpEntry(config, entry) {
|
|
86
|
+
return {
|
|
87
|
+
...config,
|
|
88
|
+
mcp: {
|
|
89
|
+
...config.mcp ?? {},
|
|
90
|
+
[JOBLY_MCP_KEY]: entry
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function removeMcpEntry(config) {
|
|
95
|
+
if (!config.mcp) return config;
|
|
96
|
+
const rest = {};
|
|
97
|
+
for (const [key, value] of Object.entries(config.mcp)) {
|
|
98
|
+
if (key !== JOBLY_MCP_KEY) rest[key] = value;
|
|
99
|
+
}
|
|
100
|
+
return { ...config, mcp: rest };
|
|
101
|
+
}
|
|
102
|
+
async function writeConfig(filePath, config) {
|
|
103
|
+
const dir = path2.dirname(filePath);
|
|
104
|
+
if (!fs3.existsSync(dir)) {
|
|
105
|
+
fs3.mkdirSync(dir, { recursive: true });
|
|
106
|
+
}
|
|
107
|
+
const tmp = filePath + ".tmp";
|
|
108
|
+
const content = JSON.stringify(config, null, 2) + "\n";
|
|
109
|
+
fs3.writeFileSync(tmp, content, "utf8");
|
|
110
|
+
fs3.renameSync(tmp, filePath);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// src/prompts/api-key.ts
|
|
114
|
+
import { password } from "@inquirer/prompts";
|
|
115
|
+
var API_KEY_REGEX = /^jobly_sk_[A-Za-z0-9]{20,}$/;
|
|
116
|
+
function validateApiKey(input) {
|
|
117
|
+
const trimmed = input.trim();
|
|
118
|
+
if (!trimmed) return "API key cannot be empty";
|
|
119
|
+
if (trimmed === "xxx" || trimmed === "your-key-here") return "That looks like a placeholder, not a real key";
|
|
120
|
+
if (!API_KEY_REGEX.test(trimmed)) {
|
|
121
|
+
return 'Key must start with "jobly_sk_" followed by at least 20 alphanumeric characters';
|
|
122
|
+
}
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
async function promptApiKey() {
|
|
126
|
+
const answer = await password({
|
|
127
|
+
message: "Enter your JoblyAI API key:",
|
|
128
|
+
mask: "*",
|
|
129
|
+
validate: validateApiKey
|
|
130
|
+
});
|
|
131
|
+
return answer.trim();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/prompts/scope.ts
|
|
135
|
+
import { select } from "@inquirer/prompts";
|
|
136
|
+
async function promptScope() {
|
|
137
|
+
return select({
|
|
138
|
+
message: "Where should the config be installed?",
|
|
139
|
+
choices: [
|
|
140
|
+
{
|
|
141
|
+
name: "Global (~/.config/opencode/)",
|
|
142
|
+
value: "global",
|
|
143
|
+
description: "Available in all projects on this machine"
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: "Local (current project)",
|
|
147
|
+
value: "local",
|
|
148
|
+
description: "Written to the nearest git root as opencode.json"
|
|
149
|
+
}
|
|
150
|
+
]
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// src/prompts/overwrite.ts
|
|
155
|
+
import { confirm } from "@inquirer/prompts";
|
|
156
|
+
async function promptOverwrite() {
|
|
157
|
+
return confirm({
|
|
158
|
+
message: "jobly-mcp is already configured. Overwrite?",
|
|
159
|
+
default: false
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// src/prompts/confirm-comment-loss.ts
|
|
164
|
+
import { confirm as confirm2 } from "@inquirer/prompts";
|
|
165
|
+
async function promptConfirmCommentLoss() {
|
|
166
|
+
return confirm2({
|
|
167
|
+
message: "This file contains comments that will be lost when rewriting. Continue?",
|
|
168
|
+
default: false
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// src/prompts/invalid-config.ts
|
|
173
|
+
import { select as select2 } from "@inquirer/prompts";
|
|
174
|
+
async function promptInvalidConfigAction() {
|
|
175
|
+
return select2({
|
|
176
|
+
message: "opencode.json contains invalid JSON. What do you want to do?",
|
|
177
|
+
choices: [
|
|
178
|
+
{
|
|
179
|
+
name: "Abort (recommended)",
|
|
180
|
+
value: "abort",
|
|
181
|
+
description: "Exit without making changes. Fix the file manually first."
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: "Back up the file and continue with a fresh config",
|
|
185
|
+
value: "backup",
|
|
186
|
+
description: "Renames the broken file to .bak-<timestamp> and writes a new one"
|
|
187
|
+
}
|
|
188
|
+
]
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// src/utils/logger.ts
|
|
193
|
+
import pc from "picocolors";
|
|
194
|
+
var logger = {
|
|
195
|
+
info: (msg) => console.log(pc.cyan("\u2139") + " " + msg),
|
|
196
|
+
success: (msg) => console.log(pc.green("\u2713") + " " + msg),
|
|
197
|
+
warn: (msg) => console.warn(pc.yellow("\u26A0") + " " + msg),
|
|
198
|
+
error: (msg) => console.error(pc.red("\u2717") + " " + msg),
|
|
199
|
+
step: (msg) => console.log(pc.bold("\n" + msg))
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// src/commands/setup.ts
|
|
203
|
+
async function runSetup(opts) {
|
|
204
|
+
const apiKey = await promptApiKey();
|
|
205
|
+
const scope = await promptScope();
|
|
206
|
+
const filePath = resolveConfigFile(scope);
|
|
207
|
+
const result = readConfig(filePath);
|
|
208
|
+
let config;
|
|
209
|
+
if (result.kind === "missing") {
|
|
210
|
+
config = createNewConfig(buildJoblyMcpEntry(apiKey));
|
|
211
|
+
} else if (result.kind === "invalid") {
|
|
212
|
+
logger.warn(`opencode.json contains invalid JSON: ${result.error}`);
|
|
213
|
+
const action = await promptInvalidConfigAction();
|
|
214
|
+
if (action === "abort") {
|
|
215
|
+
logger.error("Aborted. Fix the file manually and try again.");
|
|
216
|
+
process.exit(3);
|
|
217
|
+
}
|
|
218
|
+
const backupPath = `${filePath}.bak-${Date.now()}`;
|
|
219
|
+
fs4.renameSync(filePath, backupPath);
|
|
220
|
+
logger.info(`Backed up to ${backupPath}`);
|
|
221
|
+
config = createNewConfig(buildJoblyMcpEntry(apiKey));
|
|
222
|
+
} else {
|
|
223
|
+
config = result.config;
|
|
224
|
+
const hadComments = result.hadComments;
|
|
225
|
+
if (hasMcpEntry(config) && !opts.force) {
|
|
226
|
+
const overwrite = await promptOverwrite();
|
|
227
|
+
if (!overwrite) {
|
|
228
|
+
logger.info("Aborted, no changes made.");
|
|
229
|
+
process.exit(130);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (hadComments && !opts.force) {
|
|
233
|
+
const confirm3 = await promptConfirmCommentLoss();
|
|
234
|
+
if (!confirm3) {
|
|
235
|
+
logger.info("Aborted, no changes made.");
|
|
236
|
+
process.exit(130);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
config = setMcpEntry(config, buildJoblyMcpEntry(apiKey));
|
|
240
|
+
}
|
|
241
|
+
await writeConfig(filePath, config);
|
|
242
|
+
logger.success(`jobly-mcp added to ${filePath}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// src/commands/uninstall.ts
|
|
246
|
+
async function runUninstall(opts) {
|
|
247
|
+
const scope = await promptScope();
|
|
248
|
+
const filePath = resolveConfigFile(scope);
|
|
249
|
+
const result = readConfig(filePath);
|
|
250
|
+
if (result.kind === "missing") {
|
|
251
|
+
logger.info("jobly-mcp is not configured.");
|
|
252
|
+
process.exit(0);
|
|
253
|
+
}
|
|
254
|
+
if (result.kind === "invalid") {
|
|
255
|
+
logger.warn(`opencode.json contains invalid JSON: ${result.error}`);
|
|
256
|
+
const action = await promptInvalidConfigAction();
|
|
257
|
+
if (action === "abort") {
|
|
258
|
+
logger.error("Aborted. Fix the file manually and try again.");
|
|
259
|
+
process.exit(3);
|
|
260
|
+
}
|
|
261
|
+
logger.info("Preserving the broken file. jobly-mcp not removed.");
|
|
262
|
+
process.exit(0);
|
|
263
|
+
}
|
|
264
|
+
let config = result.config;
|
|
265
|
+
const hadComments = result.hadComments;
|
|
266
|
+
if (!hasMcpEntry(config)) {
|
|
267
|
+
logger.info("jobly-mcp is not configured.");
|
|
268
|
+
process.exit(0);
|
|
269
|
+
}
|
|
270
|
+
if (hadComments && !opts.force) {
|
|
271
|
+
const confirm3 = await promptConfirmCommentLoss();
|
|
272
|
+
if (!confirm3) {
|
|
273
|
+
logger.info("Aborted, no changes made.");
|
|
274
|
+
process.exit(130);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
config = removeMcpEntry(config);
|
|
278
|
+
await writeConfig(filePath, config);
|
|
279
|
+
logger.success(`jobly-mcp removed from ${filePath}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// src/utils/exit.ts
|
|
283
|
+
function isCancelError(err) {
|
|
284
|
+
return err instanceof Error && err.name === "ExitPromptError";
|
|
285
|
+
}
|
|
286
|
+
function handleCliError(err) {
|
|
287
|
+
if (isCancelError(err)) {
|
|
288
|
+
console.log("");
|
|
289
|
+
process.exit(130);
|
|
290
|
+
}
|
|
291
|
+
if (err instanceof Error) {
|
|
292
|
+
logger.error(err.message);
|
|
293
|
+
} else {
|
|
294
|
+
logger.error("An unknown error occurred");
|
|
295
|
+
}
|
|
296
|
+
if (process.env.DEBUG === "1") {
|
|
297
|
+
console.error(err);
|
|
298
|
+
}
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// src/cli.ts
|
|
303
|
+
var program = new Command();
|
|
304
|
+
program.name("jobly-mcp").description("Setup JoblyAI MCP server in your OpenCode config").version("0.1.0").option("-u, --uninstall", "Remove the jobly-mcp entry instead of adding it").option("-y, --yes", "Skip confirmation prompts (overwrite, comment-loss)").action(async (opts) => {
|
|
305
|
+
if (opts.uninstall) {
|
|
306
|
+
await runUninstall({ force: opts.yes });
|
|
307
|
+
} else {
|
|
308
|
+
await runSetup({ force: opts.yes });
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
program.parseAsync(process.argv).catch(handleCliError);
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "jobly-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Setup JoblyAI MCP server in your OpenCode config",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"jobly-mcp": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/cli.js",
|
|
10
|
+
"types": "./dist/cli.d.ts",
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"author": "jobly.ai.vn",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/jobly-ai/jobly-mcp.git"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://jobly.ai.vn",
|
|
26
|
+
"keywords": [
|
|
27
|
+
"jobly",
|
|
28
|
+
"joblyai",
|
|
29
|
+
"mcp",
|
|
30
|
+
"model-context-protocol",
|
|
31
|
+
"opencode",
|
|
32
|
+
"cli"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsup",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"test:watch": "vitest",
|
|
38
|
+
"test:coverage": "vitest run --coverage",
|
|
39
|
+
"prepublishOnly": "npm run build"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@inquirer/prompts": "^8.5.2",
|
|
43
|
+
"commander": "^15.0.0",
|
|
44
|
+
"picocolors": "^1.1.1",
|
|
45
|
+
"strip-json-comments": "^5.0.3"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^26.0.1",
|
|
49
|
+
"@vitest/coverage-v8": "^4.1.9",
|
|
50
|
+
"tsup": "^8.5.1",
|
|
51
|
+
"typescript": "^6.0.3",
|
|
52
|
+
"vitest": "^4.1.9"
|
|
53
|
+
}
|
|
54
|
+
}
|