@novalinkai/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/README.md +65 -0
- package/dist/cli.js +723 -0
- package/package.json +40 -0
- package/skill/SKILL.md +78 -0
package/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# @novalinkai/mcp
|
|
2
|
+
|
|
3
|
+
Connect Claude Code, Cursor, Antigravity, VS Code and Windsurf to [Novalink](https://novalink.live). Your coding agent can then plan AI workflows, build and test them, publish them, and wire them into your codebase.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx @novalinkai/mcp install
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Any package runner works:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pnpm dlx @novalinkai/mcp install
|
|
13
|
+
yarn dlx @novalinkai/mcp install
|
|
14
|
+
bunx @novalinkai/mcp install
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The installer finds your editors, adds the Novalink MCP server (`https://api.novalink.live/mcp`) to each one, and adds the `/novalink-workflows` guide. The first time the agent uses Novalink, your browser opens so you can sign in and choose what it may do. The installer never writes secrets.
|
|
18
|
+
|
|
19
|
+
## What gets written
|
|
20
|
+
|
|
21
|
+
| Editor | Server config | Guide |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| Claude Code | `.mcp.json`, or `claude mcp add` for `--scope user` | `.claude/skills/novalink-workflows/SKILL.md` |
|
|
24
|
+
| Cursor | `.cursor/mcp.json` | `.cursor/commands/` and `.cursor/rules/` |
|
|
25
|
+
| Antigravity | `~/.gemini/antigravity/mcp_config.json` | `.agent/rules/` and `.agent/workflows/` |
|
|
26
|
+
| VS Code | `.vscode/mcp.json` | `.github/prompts/novalink-workflows.prompt.md` |
|
|
27
|
+
| Windsurf | `~/.codeium/windsurf/mcp_config.json` | `.windsurf/rules/` |
|
|
28
|
+
|
|
29
|
+
Existing servers in these files are kept. Files with comments are left alone, and the installer prints the entry for you to add by hand.
|
|
30
|
+
|
|
31
|
+
## Options
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
npx @novalinkai/mcp install --client claude-code,cursor # choose editors
|
|
35
|
+
npx @novalinkai/mcp install --scope user # every project, not just this repo
|
|
36
|
+
npx @novalinkai/mcp install --bridge # connect through the local stdio bridge
|
|
37
|
+
npx @novalinkai/mcp install --dry-run # show changes only
|
|
38
|
+
npx @novalinkai/mcp uninstall # remove them again
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## The stdio bridge
|
|
42
|
+
|
|
43
|
+
Some editors cannot sign in to a remote MCP server themselves. For those, `npx @novalinkai/mcp serve` runs locally over stdio and relays to Novalink. It runs the OAuth sign-in in your browser and keeps tokens in `~/.config/novalink/credentials.json`, which only you can read.
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
npx @novalinkai/mcp login # sign in ahead of time
|
|
47
|
+
npx @novalinkai/mcp status
|
|
48
|
+
npx @novalinkai/mcp logout # revoke and forget the tokens
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Set `NOVALINK_MCP_URL` (or `NOVALINK_API_URL`) to point at another deployment, such as `http://localhost:8000/mcp` in development.
|
|
52
|
+
|
|
53
|
+
## Permissions
|
|
54
|
+
|
|
55
|
+
At sign-in you choose what the agent may do:
|
|
56
|
+
|
|
57
|
+
- **workflows:read**: see workflows, node types, connections and credential names.
|
|
58
|
+
- **workflows:write**: plan, create and edit drafts.
|
|
59
|
+
- **workflows:publish**: publish versions and choose the active one.
|
|
60
|
+
- **runs:write**: run workflows.
|
|
61
|
+
- **api_keys:write**: create API keys limited to chosen workflows.
|
|
62
|
+
|
|
63
|
+
To disconnect an agent at any time, go to **API keys > Connected agents** in Novalink.
|
|
64
|
+
|
|
65
|
+
Docs: https://novalink.live/developers/mcp
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { spawn, spawnSync } from "child_process";
|
|
5
|
+
import { existsSync as existsSync3 } from "fs";
|
|
6
|
+
import { homedir as homedir2 } from "os";
|
|
7
|
+
import { delimiter, join as join3, relative } from "path";
|
|
8
|
+
import { createInterface as createInterface2 } from "readline/promises";
|
|
9
|
+
import { parseArgs } from "util";
|
|
10
|
+
|
|
11
|
+
// src/auth.ts
|
|
12
|
+
import { createHash, randomBytes } from "crypto";
|
|
13
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
14
|
+
import { createServer } from "http";
|
|
15
|
+
import { homedir } from "os";
|
|
16
|
+
import { dirname, join } from "path";
|
|
17
|
+
|
|
18
|
+
// src/constants.ts
|
|
19
|
+
var PACKAGE = "@novalinkai/mcp";
|
|
20
|
+
var VERSION = "0.1.0";
|
|
21
|
+
var SERVER_NAME = "novalink";
|
|
22
|
+
var SKILL_NAME = "novalink-workflows";
|
|
23
|
+
var DEFAULT_URL = "https://api.novalink.live/mcp";
|
|
24
|
+
var DOCS_URL = "https://novalink.live/developers/mcp";
|
|
25
|
+
function serverUrl(flag, env = process.env) {
|
|
26
|
+
if (flag) return flag.replace(/\/$/, "");
|
|
27
|
+
if (env.NOVALINK_MCP_URL) return env.NOVALINK_MCP_URL.replace(/\/$/, "");
|
|
28
|
+
if (env.NOVALINK_API_URL) return `${env.NOVALINK_API_URL.replace(/\/$/, "")}/mcp`;
|
|
29
|
+
return DEFAULT_URL;
|
|
30
|
+
}
|
|
31
|
+
function log(message) {
|
|
32
|
+
process.stderr.write(`${message}
|
|
33
|
+
`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// src/auth.ts
|
|
37
|
+
var REFRESH_MARGIN_MS = 6e4;
|
|
38
|
+
function defaultStorePath(env = process.env, platform = process.platform) {
|
|
39
|
+
if (env.NOVALINK_CONFIG_DIR) return join(env.NOVALINK_CONFIG_DIR, "credentials.json");
|
|
40
|
+
if (platform === "win32" && env.APPDATA) return join(env.APPDATA, "novalink", "credentials.json");
|
|
41
|
+
return join(env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "novalink", "credentials.json");
|
|
42
|
+
}
|
|
43
|
+
var TokenStore = class {
|
|
44
|
+
constructor(path) {
|
|
45
|
+
this.path = path;
|
|
46
|
+
}
|
|
47
|
+
path;
|
|
48
|
+
readAll() {
|
|
49
|
+
if (!existsSync(this.path)) return {};
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(readFileSync(this.path, "utf8"));
|
|
52
|
+
} catch {
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
get(url) {
|
|
57
|
+
return this.readAll()[url] ?? {};
|
|
58
|
+
}
|
|
59
|
+
set(url, tokens) {
|
|
60
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 448 });
|
|
61
|
+
writeFileSync(this.path, `${JSON.stringify({ ...this.readAll(), [url]: tokens }, null, 2)}
|
|
62
|
+
`, { mode: 384 });
|
|
63
|
+
chmodSync(this.path, 384);
|
|
64
|
+
}
|
|
65
|
+
delete(url) {
|
|
66
|
+
const all = this.readAll();
|
|
67
|
+
if (!(url in all)) return;
|
|
68
|
+
delete all[url];
|
|
69
|
+
writeFileSync(this.path, `${JSON.stringify(all, null, 2)}
|
|
70
|
+
`, { mode: 384 });
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
var b64url = (buffer) => buffer.toString("base64url");
|
|
74
|
+
function pkce() {
|
|
75
|
+
const verifier = b64url(randomBytes(48));
|
|
76
|
+
return { verifier, challenge: b64url(createHash("sha256").update(verifier).digest()) };
|
|
77
|
+
}
|
|
78
|
+
async function firstJson(fetchFn, urls) {
|
|
79
|
+
for (const url of urls) {
|
|
80
|
+
try {
|
|
81
|
+
const response = await fetchFn(url, { headers: { Accept: "application/json" } });
|
|
82
|
+
if (response.ok) return await response.json();
|
|
83
|
+
} catch {
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return void 0;
|
|
87
|
+
}
|
|
88
|
+
async function discover(url, fetchFn) {
|
|
89
|
+
const resource = new URL(url);
|
|
90
|
+
const path = resource.pathname === "/" ? "" : resource.pathname;
|
|
91
|
+
const prm = await firstJson(fetchFn, [
|
|
92
|
+
`${resource.origin}/.well-known/oauth-protected-resource${path}`,
|
|
93
|
+
`${resource.origin}/.well-known/oauth-protected-resource`
|
|
94
|
+
]);
|
|
95
|
+
const servers = prm?.authorization_servers;
|
|
96
|
+
const issuer = new URL(Array.isArray(servers) && typeof servers[0] === "string" ? servers[0] : resource.origin);
|
|
97
|
+
const issuerPath = issuer.pathname === "/" ? "" : issuer.pathname.replace(/\/$/, "");
|
|
98
|
+
const metadata = await firstJson(fetchFn, [
|
|
99
|
+
`${issuer.origin}/.well-known/oauth-authorization-server${issuerPath}`,
|
|
100
|
+
...issuerPath ? [`${issuer.origin}${issuerPath}/.well-known/oauth-authorization-server`] : [],
|
|
101
|
+
`${issuer.origin}/.well-known/openid-configuration${issuerPath}`
|
|
102
|
+
]);
|
|
103
|
+
if (!metadata?.authorization_endpoint || !metadata.token_endpoint) {
|
|
104
|
+
throw new Error(`Could not find the sign-in endpoints for ${url}. Check the URL, or that the server is up.`);
|
|
105
|
+
}
|
|
106
|
+
return metadata;
|
|
107
|
+
}
|
|
108
|
+
async function postForm(fetchFn, url, form) {
|
|
109
|
+
const response = await fetchFn(url, {
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
112
|
+
body: new URLSearchParams(form).toString()
|
|
113
|
+
});
|
|
114
|
+
const body = await response.json().catch(() => ({}));
|
|
115
|
+
if (!response.ok) {
|
|
116
|
+
throw new Error(String(body.error_description ?? body.error ?? `sign-in failed with ${response.status}`));
|
|
117
|
+
}
|
|
118
|
+
return body;
|
|
119
|
+
}
|
|
120
|
+
function toTokens(clientId, body) {
|
|
121
|
+
return {
|
|
122
|
+
clientId,
|
|
123
|
+
accessToken: String(body.access_token),
|
|
124
|
+
refreshToken: body.refresh_token ? String(body.refresh_token) : void 0,
|
|
125
|
+
expiresAt: Date.now() + Number(body.expires_in ?? 3600) * 1e3,
|
|
126
|
+
scope: body.scope ? String(body.scope) : void 0
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
var DONE_PAGE = (message) => `<!doctype html><meta charset="utf-8"><title>Novalink</title><body style="font:16px system-ui;padding:3rem;max-width:32rem;margin:auto"><h1 style="font-size:1.25rem">${message}</h1><p>You can close this tab and return to your editor.</p></body>`;
|
|
130
|
+
async function login(url, deps) {
|
|
131
|
+
const metadata = await discover(url, deps.fetch);
|
|
132
|
+
const { verifier, challenge } = pkce();
|
|
133
|
+
const state = b64url(randomBytes(16));
|
|
134
|
+
let settle;
|
|
135
|
+
const received = new Promise((resolve, reject) => settle = { resolve, reject });
|
|
136
|
+
const server = createServer((request, response) => {
|
|
137
|
+
const query = new URL(request.url ?? "/", "http://127.0.0.1").searchParams;
|
|
138
|
+
if (!request.url?.startsWith("/callback")) {
|
|
139
|
+
response.writeHead(404).end();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const error = query.get("error");
|
|
143
|
+
const ok = !error && query.get("state") === state && query.get("code");
|
|
144
|
+
response.writeHead(ok ? 200 : 400, { "Content-Type": "text/html; charset=utf-8" });
|
|
145
|
+
response.end(DONE_PAGE(ok ? "Novalink is connected" : "Novalink was not connected"));
|
|
146
|
+
if (error) settle?.reject(new Error(query.get("error_description") ?? error));
|
|
147
|
+
else if (query.get("state") !== state) settle?.reject(new Error("The sign-in response did not match this request."));
|
|
148
|
+
else settle?.resolve(query.get("code") ?? "");
|
|
149
|
+
});
|
|
150
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
151
|
+
const address = server.address();
|
|
152
|
+
const redirectUri = `http://127.0.0.1:${typeof address === "object" && address ? address.port : 0}/callback`;
|
|
153
|
+
try {
|
|
154
|
+
let clientId = deps.store.get(url).clientId;
|
|
155
|
+
if (!clientId) {
|
|
156
|
+
if (!metadata.registration_endpoint) throw new Error("This server does not support client registration.");
|
|
157
|
+
const response = await deps.fetch(metadata.registration_endpoint, {
|
|
158
|
+
method: "POST",
|
|
159
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
160
|
+
body: JSON.stringify({
|
|
161
|
+
client_name: `Novalink MCP CLI ${VERSION}`,
|
|
162
|
+
redirect_uris: [redirectUri],
|
|
163
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
164
|
+
response_types: ["code"],
|
|
165
|
+
token_endpoint_auth_method: "none"
|
|
166
|
+
})
|
|
167
|
+
});
|
|
168
|
+
const body = await response.json();
|
|
169
|
+
if (!response.ok) throw new Error(String(body.error_description ?? "client registration failed"));
|
|
170
|
+
clientId = String(body.client_id);
|
|
171
|
+
}
|
|
172
|
+
const authorize = new URL(metadata.authorization_endpoint);
|
|
173
|
+
for (const [key, value] of Object.entries({
|
|
174
|
+
response_type: "code",
|
|
175
|
+
client_id: clientId,
|
|
176
|
+
redirect_uri: redirectUri,
|
|
177
|
+
code_challenge: challenge,
|
|
178
|
+
code_challenge_method: "S256",
|
|
179
|
+
state,
|
|
180
|
+
resource: url
|
|
181
|
+
})) {
|
|
182
|
+
authorize.searchParams.set(key, value);
|
|
183
|
+
}
|
|
184
|
+
deps.log(`Opening your browser to sign in to Novalink. If it does not open, visit:
|
|
185
|
+
${authorize.toString()}`);
|
|
186
|
+
deps.open(authorize.toString());
|
|
187
|
+
const timeout = new Promise(
|
|
188
|
+
(_, reject) => setTimeout(() => reject(new Error("Timed out waiting for sign-in.")), deps.timeoutMs ?? 5 * 6e4).unref()
|
|
189
|
+
);
|
|
190
|
+
const code = await Promise.race([received, timeout]);
|
|
191
|
+
const tokens = toTokens(
|
|
192
|
+
clientId,
|
|
193
|
+
await postForm(deps.fetch, metadata.token_endpoint, {
|
|
194
|
+
grant_type: "authorization_code",
|
|
195
|
+
code,
|
|
196
|
+
code_verifier: verifier,
|
|
197
|
+
client_id: clientId,
|
|
198
|
+
redirect_uri: redirectUri,
|
|
199
|
+
resource: url
|
|
200
|
+
})
|
|
201
|
+
);
|
|
202
|
+
deps.store.set(url, tokens);
|
|
203
|
+
return tokens;
|
|
204
|
+
} finally {
|
|
205
|
+
server.close();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async function refresh(url, deps) {
|
|
209
|
+
const stored = deps.store.get(url);
|
|
210
|
+
if (!stored.refreshToken || !stored.clientId) return void 0;
|
|
211
|
+
try {
|
|
212
|
+
const metadata = await discover(url, deps.fetch);
|
|
213
|
+
const tokens = toTokens(
|
|
214
|
+
stored.clientId,
|
|
215
|
+
await postForm(deps.fetch, metadata.token_endpoint, {
|
|
216
|
+
grant_type: "refresh_token",
|
|
217
|
+
refresh_token: stored.refreshToken,
|
|
218
|
+
client_id: stored.clientId,
|
|
219
|
+
resource: url
|
|
220
|
+
})
|
|
221
|
+
);
|
|
222
|
+
deps.store.set(url, tokens);
|
|
223
|
+
return tokens;
|
|
224
|
+
} catch (error) {
|
|
225
|
+
deps.log(`Could not refresh the Novalink session: ${error.message}`);
|
|
226
|
+
deps.store.set(url, { clientId: stored.clientId });
|
|
227
|
+
return void 0;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async function accessToken(url, deps) {
|
|
231
|
+
const stored = deps.store.get(url);
|
|
232
|
+
if (stored.accessToken && (stored.expiresAt ?? 0) - REFRESH_MARGIN_MS > Date.now()) return stored.accessToken;
|
|
233
|
+
return (await refresh(url, deps))?.accessToken;
|
|
234
|
+
}
|
|
235
|
+
async function logout(url, deps) {
|
|
236
|
+
const stored = deps.store.get(url);
|
|
237
|
+
const token = stored.refreshToken ?? stored.accessToken;
|
|
238
|
+
if (token) {
|
|
239
|
+
try {
|
|
240
|
+
const metadata = await discover(url, deps.fetch);
|
|
241
|
+
if (metadata.revocation_endpoint) await postForm(deps.fetch, metadata.revocation_endpoint, { token });
|
|
242
|
+
} catch {
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
deps.store.delete(url);
|
|
246
|
+
return Boolean(token);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// src/bridge.ts
|
|
250
|
+
import { createInterface } from "readline";
|
|
251
|
+
var Bridge = class {
|
|
252
|
+
constructor(url, deps, write) {
|
|
253
|
+
this.url = url;
|
|
254
|
+
this.deps = deps;
|
|
255
|
+
this.write = write;
|
|
256
|
+
}
|
|
257
|
+
url;
|
|
258
|
+
deps;
|
|
259
|
+
write;
|
|
260
|
+
signingIn;
|
|
261
|
+
protocolVersion;
|
|
262
|
+
async token() {
|
|
263
|
+
const token = await accessToken(this.url, this.deps);
|
|
264
|
+
if (token) return token;
|
|
265
|
+
this.signingIn ??= login(this.url, this.deps).finally(() => this.signingIn = void 0);
|
|
266
|
+
await this.signingIn;
|
|
267
|
+
return await accessToken(this.url, this.deps) ?? "";
|
|
268
|
+
}
|
|
269
|
+
post(message, token) {
|
|
270
|
+
const headers = {
|
|
271
|
+
"Content-Type": "application/json",
|
|
272
|
+
Accept: "application/json, text/event-stream",
|
|
273
|
+
Authorization: `Bearer ${token}`
|
|
274
|
+
};
|
|
275
|
+
if (this.protocolVersion) headers["MCP-Protocol-Version"] = this.protocolVersion;
|
|
276
|
+
return this.deps.fetch(this.url, { method: "POST", headers, body: JSON.stringify(message) });
|
|
277
|
+
}
|
|
278
|
+
async relay(message) {
|
|
279
|
+
const expectsAnswer = message.method !== void 0 && message.id !== void 0 && message.id !== null;
|
|
280
|
+
try {
|
|
281
|
+
let response = await this.post(message, await this.token());
|
|
282
|
+
if (response.status === 401) {
|
|
283
|
+
const renewed = (await refresh(this.url, this.deps))?.accessToken ?? await this.token();
|
|
284
|
+
response = await this.post(message, renewed);
|
|
285
|
+
}
|
|
286
|
+
if (response.status === 202 || !expectsAnswer) return;
|
|
287
|
+
const type = response.headers.get("content-type") ?? "";
|
|
288
|
+
const text = await response.text();
|
|
289
|
+
const answers = type.includes("text/event-stream") ? fromEventStream(text) : [text];
|
|
290
|
+
for (const answer of answers) {
|
|
291
|
+
if (message.method === "initialize") this.rememberVersion(answer);
|
|
292
|
+
this.write(answer);
|
|
293
|
+
}
|
|
294
|
+
if (!answers.length) this.fail(message, `Novalink answered ${response.status} with no body.`);
|
|
295
|
+
} catch (error) {
|
|
296
|
+
if (expectsAnswer) this.fail(message, error.message);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
rememberVersion(answer) {
|
|
300
|
+
try {
|
|
301
|
+
this.protocolVersion = JSON.parse(answer).result?.protocolVersion;
|
|
302
|
+
} catch {
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
fail(message, detail) {
|
|
306
|
+
this.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, error: { code: -32603, message: `Novalink: ${detail}` } }));
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
function fromEventStream(text) {
|
|
310
|
+
return text.split(/\r?\n\r?\n/).map(
|
|
311
|
+
(event) => event.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n")
|
|
312
|
+
).filter(Boolean);
|
|
313
|
+
}
|
|
314
|
+
async function serve(url, deps) {
|
|
315
|
+
const bridge = new Bridge(url, deps, (line) => process.stdout.write(`${line}
|
|
316
|
+
`));
|
|
317
|
+
const pending = /* @__PURE__ */ new Set();
|
|
318
|
+
const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
319
|
+
for await (const line of lines) {
|
|
320
|
+
if (!line.trim()) continue;
|
|
321
|
+
let message;
|
|
322
|
+
try {
|
|
323
|
+
message = JSON.parse(line);
|
|
324
|
+
} catch {
|
|
325
|
+
deps.log("Ignored a line that is not JSON.");
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
const task = bridge.relay(message).finally(() => pending.delete(task));
|
|
329
|
+
pending.add(task);
|
|
330
|
+
}
|
|
331
|
+
await Promise.all(pending);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// src/clients.ts
|
|
335
|
+
import { join as join2 } from "path";
|
|
336
|
+
|
|
337
|
+
// src/skill.ts
|
|
338
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
339
|
+
function parseSkill(raw) {
|
|
340
|
+
const match = /^---\n([\s\S]*?)\n---\n+([\s\S]*)$/.exec(raw);
|
|
341
|
+
if (!match) throw new Error("SKILL.md has no frontmatter");
|
|
342
|
+
const field = (key) => new RegExp(`^${key}:\\s*(.+)$`, "m").exec(match[1] ?? "")?.[1]?.trim() ?? "";
|
|
343
|
+
return { name: field("name"), description: field("description"), body: match[2] ?? "", raw };
|
|
344
|
+
}
|
|
345
|
+
function loadSkill() {
|
|
346
|
+
return parseSkill(readFileSync2(new URL("../skill/SKILL.md", import.meta.url), "utf8"));
|
|
347
|
+
}
|
|
348
|
+
var frontmatter = (fields) => `---
|
|
349
|
+
${Object.entries(fields).map(([key, value]) => `${key}: ${typeof value === "string" ? JSON.stringify(value) : value}`).join("\n")}
|
|
350
|
+
---
|
|
351
|
+
|
|
352
|
+
`;
|
|
353
|
+
var variants = {
|
|
354
|
+
claudeSkill: (skill) => skill.raw,
|
|
355
|
+
cursorRule: (skill) => frontmatter({ description: skill.description, alwaysApply: false }) + skill.body,
|
|
356
|
+
plainCommand: (skill) => skill.body,
|
|
357
|
+
modelDecisionRule: (skill) => frontmatter({ trigger: "model_decision", description: skill.description }) + skill.body,
|
|
358
|
+
antigravityWorkflow: (skill) => frontmatter({ description: skill.description }) + skill.body,
|
|
359
|
+
vscodePrompt: (skill) => frontmatter({ description: skill.description, mode: "agent" }) + skill.body
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
// src/clients.ts
|
|
363
|
+
var bridgeEntry = (url) => ({
|
|
364
|
+
command: "npx",
|
|
365
|
+
args: ["-y", `${PACKAGE}@latest`, "serve", "--url", url]
|
|
366
|
+
});
|
|
367
|
+
function vscodeUserDir(place2) {
|
|
368
|
+
if (place2.platform === "win32") return join2(place2.appData ?? join2(place2.home, "AppData", "Roaming"), "Code", "User");
|
|
369
|
+
if (place2.platform === "darwin") return join2(place2.home, "Library", "Application Support", "Code", "User");
|
|
370
|
+
return join2(place2.home, ".config", "Code", "User");
|
|
371
|
+
}
|
|
372
|
+
var userOnly = (name) => ({
|
|
373
|
+
kind: "note",
|
|
374
|
+
text: `${name} reads the guide per project; run install with --scope project inside a repo to add it there.`
|
|
375
|
+
});
|
|
376
|
+
var CLIENTS = [
|
|
377
|
+
{
|
|
378
|
+
id: "claude-code",
|
|
379
|
+
name: "Claude Code",
|
|
380
|
+
detect: (place2, { onPath: onPath2, exists }) => onPath2("claude") || exists(join2(place2.home, ".claude")),
|
|
381
|
+
plan: (place2, options) => {
|
|
382
|
+
const entry = options.bridge ? bridgeEntry(options.url) : { type: "http", url: options.url };
|
|
383
|
+
const steps = options.scope === "project" ? [{ kind: "server", path: join2(place2.cwd, ".mcp.json"), key: "mcpServers", entry }] : [
|
|
384
|
+
{
|
|
385
|
+
kind: "command",
|
|
386
|
+
argv: options.bridge ? ["claude", "mcp", "add", "--scope", "user", SERVER_NAME, "--", "npx", "-y", `${PACKAGE}@latest`, "serve", "--url", options.url] : ["claude", "mcp", "add", "--transport", "http", "--scope", "user", SERVER_NAME, options.url],
|
|
387
|
+
undo: ["claude", "mcp", "remove", "--scope", "user", SERVER_NAME]
|
|
388
|
+
}
|
|
389
|
+
];
|
|
390
|
+
if (options.withSkill) {
|
|
391
|
+
const root = options.scope === "project" ? place2.cwd : place2.home;
|
|
392
|
+
steps.push({
|
|
393
|
+
kind: "file",
|
|
394
|
+
path: join2(root, ".claude", "skills", SKILL_NAME, "SKILL.md"),
|
|
395
|
+
content: variants.claudeSkill(options.skill),
|
|
396
|
+
removeDir: true
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
steps.push({ kind: "note", text: `In Claude Code, run /mcp and choose ${SERVER_NAME} to sign in. Then use /${SKILL_NAME}.` });
|
|
400
|
+
return steps;
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
id: "cursor",
|
|
405
|
+
name: "Cursor",
|
|
406
|
+
detect: (place2, { onPath: onPath2, exists }) => onPath2("cursor") || exists(join2(place2.home, ".cursor")),
|
|
407
|
+
plan: (place2, options) => {
|
|
408
|
+
const root = options.scope === "project" ? place2.cwd : place2.home;
|
|
409
|
+
const entry = options.bridge ? bridgeEntry(options.url) : { url: options.url };
|
|
410
|
+
const steps = [{ kind: "server", path: join2(root, ".cursor", "mcp.json"), key: "mcpServers", entry }];
|
|
411
|
+
if (options.withSkill) {
|
|
412
|
+
steps.push({ kind: "file", path: join2(root, ".cursor", "commands", `${SKILL_NAME}.md`), content: variants.plainCommand(options.skill) });
|
|
413
|
+
if (options.scope === "project") {
|
|
414
|
+
steps.push({ kind: "file", path: join2(root, ".cursor", "rules", `${SKILL_NAME}.mdc`), content: variants.cursorRule(options.skill) });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
steps.push({ kind: "note", text: `In Cursor, open Settings > MCP and select Connect on ${SERVER_NAME} to sign in. Then use /${SKILL_NAME}.` });
|
|
418
|
+
return steps;
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
id: "antigravity",
|
|
423
|
+
name: "Antigravity",
|
|
424
|
+
detect: (place2, { onPath: onPath2, exists }) => onPath2("antigravity") || exists(join2(place2.home, ".gemini", "antigravity")),
|
|
425
|
+
plan: (place2, options) => {
|
|
426
|
+
const entry = options.bridge ? bridgeEntry(options.url) : { serverUrl: options.url };
|
|
427
|
+
const steps = [
|
|
428
|
+
{ kind: "server", path: join2(place2.home, ".gemini", "antigravity", "mcp_config.json"), key: "mcpServers", entry }
|
|
429
|
+
];
|
|
430
|
+
if (options.withSkill) {
|
|
431
|
+
if (options.scope === "project") {
|
|
432
|
+
steps.push(
|
|
433
|
+
{ kind: "file", path: join2(place2.cwd, ".agent", "rules", `${SKILL_NAME}.md`), content: variants.modelDecisionRule(options.skill) },
|
|
434
|
+
{ kind: "file", path: join2(place2.cwd, ".agent", "workflows", `${SKILL_NAME}.md`), content: variants.antigravityWorkflow(options.skill) }
|
|
435
|
+
);
|
|
436
|
+
} else {
|
|
437
|
+
steps.push(userOnly("Antigravity"));
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
steps.push({ kind: "note", text: `In Antigravity, refresh the MCP servers panel and sign in when the browser opens. Then use /${SKILL_NAME}.` });
|
|
441
|
+
return steps;
|
|
442
|
+
}
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
id: "vscode",
|
|
446
|
+
name: "VS Code",
|
|
447
|
+
detect: (place2, { onPath: onPath2, exists }) => onPath2("code") || exists(vscodeUserDir(place2)),
|
|
448
|
+
plan: (place2, options) => {
|
|
449
|
+
const entry = options.bridge ? { type: "stdio", ...bridgeEntry(options.url) } : { type: "http", url: options.url };
|
|
450
|
+
const path = options.scope === "project" ? join2(place2.cwd, ".vscode", "mcp.json") : join2(vscodeUserDir(place2), "mcp.json");
|
|
451
|
+
const steps = [{ kind: "server", path, key: "servers", entry }];
|
|
452
|
+
if (options.withSkill) {
|
|
453
|
+
steps.push(
|
|
454
|
+
options.scope === "project" ? { kind: "file", path: join2(place2.cwd, ".github", "prompts", `${SKILL_NAME}.prompt.md`), content: variants.vscodePrompt(options.skill) } : { kind: "file", path: join2(vscodeUserDir(place2), "prompts", `${SKILL_NAME}.prompt.md`), content: variants.vscodePrompt(options.skill) }
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
steps.push({ kind: "note", text: `In VS Code, open mcp.json and select Start on ${SERVER_NAME} to sign in. Then use /${SKILL_NAME} in agent mode.` });
|
|
458
|
+
return steps;
|
|
459
|
+
}
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
id: "windsurf",
|
|
463
|
+
name: "Windsurf",
|
|
464
|
+
detect: (place2, { onPath: onPath2, exists }) => onPath2("windsurf") || exists(join2(place2.home, ".codeium", "windsurf")),
|
|
465
|
+
plan: (place2, options) => {
|
|
466
|
+
const entry = options.bridge ? bridgeEntry(options.url) : { serverUrl: options.url };
|
|
467
|
+
const steps = [
|
|
468
|
+
{ kind: "server", path: join2(place2.home, ".codeium", "windsurf", "mcp_config.json"), key: "mcpServers", entry }
|
|
469
|
+
];
|
|
470
|
+
if (options.withSkill) {
|
|
471
|
+
steps.push(
|
|
472
|
+
options.scope === "project" ? { kind: "file", path: join2(place2.cwd, ".windsurf", "rules", `${SKILL_NAME}.md`), content: variants.modelDecisionRule(options.skill) } : userOnly("Windsurf")
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
steps.push({ kind: "note", text: `In Windsurf, refresh MCP servers in Cascade and sign in when asked.` });
|
|
476
|
+
return steps;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
];
|
|
480
|
+
var CLIENT_IDS = CLIENTS.map((client) => client.id);
|
|
481
|
+
function findClient(id) {
|
|
482
|
+
const aliases = { claude: "claude-code", code: "vscode", "vs-code": "vscode", gemini: "antigravity" };
|
|
483
|
+
const wanted = aliases[id] ?? id;
|
|
484
|
+
return CLIENTS.find((client) => client.id === wanted);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// src/files.ts
|
|
488
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "fs";
|
|
489
|
+
import { dirname as dirname2 } from "path";
|
|
490
|
+
var ConfigError = class extends Error {
|
|
491
|
+
};
|
|
492
|
+
function readJson(path) {
|
|
493
|
+
if (!existsSync2(path)) return {};
|
|
494
|
+
const text = readFileSync3(path, "utf8");
|
|
495
|
+
if (!text.trim()) return {};
|
|
496
|
+
try {
|
|
497
|
+
const parsed = JSON.parse(text);
|
|
498
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
499
|
+
} catch {
|
|
500
|
+
}
|
|
501
|
+
throw new ConfigError(`${path} is not plain JSON, so it was left untouched`);
|
|
502
|
+
}
|
|
503
|
+
function withServer(config, key, name, entry) {
|
|
504
|
+
const servers = config[key] && typeof config[key] === "object" ? config[key] : {};
|
|
505
|
+
return { ...config, [key]: { ...servers, [name]: entry } };
|
|
506
|
+
}
|
|
507
|
+
function withoutServer(config, key, name) {
|
|
508
|
+
const servers = config[key] && typeof config[key] === "object" ? { ...config[key] } : {};
|
|
509
|
+
delete servers[name];
|
|
510
|
+
return { ...config, [key]: servers };
|
|
511
|
+
}
|
|
512
|
+
function writeText(path, content) {
|
|
513
|
+
mkdirSync2(dirname2(path), { recursive: true });
|
|
514
|
+
writeFileSync2(path, content);
|
|
515
|
+
}
|
|
516
|
+
function writeJson(path, value) {
|
|
517
|
+
writeText(path, `${JSON.stringify(value, null, 2)}
|
|
518
|
+
`);
|
|
519
|
+
}
|
|
520
|
+
function remove(path) {
|
|
521
|
+
if (!existsSync2(path)) return false;
|
|
522
|
+
rmSync(path, { recursive: true, force: true });
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// src/cli.ts
|
|
527
|
+
var HELP = `${PACKAGE} ${VERSION}: connect coding agents to Novalink
|
|
528
|
+
|
|
529
|
+
Usage
|
|
530
|
+
npx ${PACKAGE} install [options] Add Novalink and the /novalink-workflows guide to your editors
|
|
531
|
+
npx ${PACKAGE} uninstall [options] Remove them again
|
|
532
|
+
npx ${PACKAGE} login Sign in for the local bridge
|
|
533
|
+
npx ${PACKAGE} logout Sign out and revoke the bridge's tokens
|
|
534
|
+
npx ${PACKAGE} status Show the bridge's sign-in state
|
|
535
|
+
npx ${PACKAGE} serve Run the stdio bridge (editors start this for you)
|
|
536
|
+
npx ${PACKAGE} skill Print the workflow guide
|
|
537
|
+
|
|
538
|
+
Options
|
|
539
|
+
--client <ids> ${CLIENT_IDS.join(", ")} (comma separated; default: detected)
|
|
540
|
+
--scope <scope> project (this repo, default) or user (every project)
|
|
541
|
+
--url <url> MCP server URL (default: NOVALINK_MCP_URL, NOVALINK_API_URL/mcp, or production)
|
|
542
|
+
--bridge Connect through the local stdio bridge instead of over HTTP
|
|
543
|
+
--no-skill Skip the /novalink-workflows guide
|
|
544
|
+
--dry-run Show what would change without writing anything
|
|
545
|
+
--yes Do not ask; use detected editors
|
|
546
|
+
|
|
547
|
+
Docs: ${DOCS_URL}`;
|
|
548
|
+
function onPath(bin) {
|
|
549
|
+
const extensions = process.platform === "win32" ? ["", ".cmd", ".exe", ".bat"] : [""];
|
|
550
|
+
return (process.env.PATH ?? "").split(delimiter).some((dir) => dir && extensions.some((extension) => existsSync3(join3(dir, bin + extension))));
|
|
551
|
+
}
|
|
552
|
+
function openBrowser(url) {
|
|
553
|
+
const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url.replace(/&/g, "^&")]] : ["xdg-open", [url]];
|
|
554
|
+
try {
|
|
555
|
+
spawn(command, args, { stdio: "ignore", detached: true }).on("error", () => void 0).unref();
|
|
556
|
+
} catch {
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
function authDeps() {
|
|
560
|
+
return { fetch: globalThis.fetch, store: new TokenStore(defaultStorePath()), open: openBrowser, log };
|
|
561
|
+
}
|
|
562
|
+
var place = () => ({ cwd: process.cwd(), home: homedir2(), platform: process.platform, appData: process.env.APPDATA });
|
|
563
|
+
var shown = (path) => path.startsWith(process.cwd()) ? relative(process.cwd(), path) || "." : path.replace(homedir2(), "~");
|
|
564
|
+
async function chooseClients(ids, yes) {
|
|
565
|
+
if (ids) {
|
|
566
|
+
return ids.split(",").map((id) => {
|
|
567
|
+
const client = findClient(id.trim());
|
|
568
|
+
if (!client) throw new Error(`Unknown client '${id}'. Choose from: ${CLIENT_IDS.join(", ")}.`);
|
|
569
|
+
return client;
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
const probe = { onPath, exists: existsSync3 };
|
|
573
|
+
const detected = CLIENTS.filter((client) => client.detect(place(), probe));
|
|
574
|
+
if (yes || !process.stdin.isTTY) {
|
|
575
|
+
if (!detected.length) throw new Error(`No supported editor found. Pass --client with one of: ${CLIENT_IDS.join(", ")}.`);
|
|
576
|
+
return detected;
|
|
577
|
+
}
|
|
578
|
+
log("Which editors should use Novalink?");
|
|
579
|
+
CLIENTS.forEach((client, index) => log(` ${index + 1}. ${client.name}${detected.includes(client) ? " (detected)" : ""}`));
|
|
580
|
+
const prompt = createInterface2({ input: process.stdin, output: process.stderr });
|
|
581
|
+
const fallback = detected.map((client) => CLIENTS.indexOf(client) + 1).join(",");
|
|
582
|
+
const answer = (await prompt.question(`Numbers, comma separated [${fallback || "1"}]: `)).trim() || fallback || "1";
|
|
583
|
+
prompt.close();
|
|
584
|
+
const chosen = answer.split(",").map((part) => CLIENTS[Number(part.trim()) - 1]).filter((client) => Boolean(client));
|
|
585
|
+
if (!chosen.length) throw new Error("No editor chosen.");
|
|
586
|
+
return chosen;
|
|
587
|
+
}
|
|
588
|
+
function apply(step, dryRun, undo) {
|
|
589
|
+
const prefix = dryRun ? "would " : "";
|
|
590
|
+
switch (step.kind) {
|
|
591
|
+
case "server": {
|
|
592
|
+
try {
|
|
593
|
+
const current = readJson(step.path);
|
|
594
|
+
const next = undo ? withoutServer(current, step.key, SERVER_NAME) : withServer(current, step.key, SERVER_NAME, step.entry);
|
|
595
|
+
if (!dryRun) writeJson(step.path, next);
|
|
596
|
+
return `${prefix}${undo ? "remove" : "add"} ${SERVER_NAME} in ${shown(step.path)}`;
|
|
597
|
+
} catch (error) {
|
|
598
|
+
if (!(error instanceof ConfigError)) throw error;
|
|
599
|
+
return `${error.message}. Add this under "${step.key}" by hand:
|
|
600
|
+
"${SERVER_NAME}": ${JSON.stringify(step.entry)}`;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
case "file": {
|
|
604
|
+
if (undo) {
|
|
605
|
+
const path = step.removeDir ? join3(step.path, "..") : step.path;
|
|
606
|
+
if (!dryRun) remove(path);
|
|
607
|
+
return `${prefix}remove ${shown(path)}`;
|
|
608
|
+
}
|
|
609
|
+
if (!dryRun) writeText(step.path, step.content);
|
|
610
|
+
return `${prefix}write ${shown(step.path)}`;
|
|
611
|
+
}
|
|
612
|
+
case "command": {
|
|
613
|
+
const argv = undo ? step.undo : step.argv;
|
|
614
|
+
const [bin, ...args] = argv;
|
|
615
|
+
if (!bin || !onPath(bin)) return `run this yourself: ${argv.join(" ")}`;
|
|
616
|
+
if (dryRun) return `would run: ${argv.join(" ")}`;
|
|
617
|
+
const result = spawnSync(bin, args, { stdio: "inherit", shell: process.platform === "win32" });
|
|
618
|
+
return result.status === 0 ? `ran: ${argv.join(" ")}` : `this failed, run it yourself: ${argv.join(" ")}`;
|
|
619
|
+
}
|
|
620
|
+
case "note":
|
|
621
|
+
return void 0;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
async function install(values, undo) {
|
|
625
|
+
const scope = values.scope ?? "project";
|
|
626
|
+
if (scope !== "project" && scope !== "user") throw new Error("--scope is project or user.");
|
|
627
|
+
const url = serverUrl(values.url);
|
|
628
|
+
const clients = await chooseClients(values.client, Boolean(values.yes));
|
|
629
|
+
const options = { url, scope, skill: loadSkill(), withSkill: !values["no-skill"], bridge: Boolean(values.bridge) };
|
|
630
|
+
const notes = [];
|
|
631
|
+
for (const client of clients) {
|
|
632
|
+
log(`
|
|
633
|
+
${client.name}`);
|
|
634
|
+
for (const step of client.plan(place(), options)) {
|
|
635
|
+
if (step.kind === "note") {
|
|
636
|
+
if (!undo) notes.push(`${client.name}: ${step.text}`);
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
const line = apply(step, Boolean(values["dry-run"]), undo);
|
|
640
|
+
if (line) log(` ${line}`);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
if (undo) {
|
|
644
|
+
log(`
|
|
645
|
+
Removed. To revoke access too, run: npx ${PACKAGE} logout, and disconnect the agent under API keys in Novalink.`);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
log(`
|
|
649
|
+
Novalink MCP server: ${url}`);
|
|
650
|
+
for (const note of notes) log(`- ${note}`);
|
|
651
|
+
if (scope === "project") log("- Commit the new files to share the setup with your team; no secrets were written.");
|
|
652
|
+
}
|
|
653
|
+
async function main(argv) {
|
|
654
|
+
const { values, positionals } = parseArgs({
|
|
655
|
+
args: argv,
|
|
656
|
+
allowPositionals: true,
|
|
657
|
+
options: {
|
|
658
|
+
client: { type: "string" },
|
|
659
|
+
scope: { type: "string" },
|
|
660
|
+
url: { type: "string" },
|
|
661
|
+
bridge: { type: "boolean" },
|
|
662
|
+
"no-skill": { type: "boolean" },
|
|
663
|
+
"dry-run": { type: "boolean" },
|
|
664
|
+
yes: { type: "boolean", short: "y" },
|
|
665
|
+
help: { type: "boolean", short: "h" },
|
|
666
|
+
version: { type: "boolean", short: "v" }
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
if (values.version) {
|
|
670
|
+
log(VERSION);
|
|
671
|
+
return 0;
|
|
672
|
+
}
|
|
673
|
+
const command = positionals[0] ?? (values.help ? "help" : process.stdin.isTTY ? "install" : "serve");
|
|
674
|
+
const url = serverUrl(values.url);
|
|
675
|
+
switch (command) {
|
|
676
|
+
case "install":
|
|
677
|
+
case "uninstall":
|
|
678
|
+
await install(values, command === "uninstall");
|
|
679
|
+
return 0;
|
|
680
|
+
case "serve":
|
|
681
|
+
await serve(url, authDeps());
|
|
682
|
+
return 0;
|
|
683
|
+
case "login": {
|
|
684
|
+
const tokens = await login(url, authDeps());
|
|
685
|
+
log(`Signed in to ${url} with: ${tokens.scope ?? "all permissions"}`);
|
|
686
|
+
return 0;
|
|
687
|
+
}
|
|
688
|
+
case "logout":
|
|
689
|
+
log(await logout(url, authDeps()) ? `Signed out of ${url}.` : `Not signed in to ${url}.`);
|
|
690
|
+
return 0;
|
|
691
|
+
case "status": {
|
|
692
|
+
const tokens = new TokenStore(defaultStorePath()).get(url);
|
|
693
|
+
if (!tokens.refreshToken && !tokens.accessToken) log(`Not signed in to ${url}. Run: npx ${PACKAGE} login`);
|
|
694
|
+
else log(`Signed in to ${url}
|
|
695
|
+
Permissions: ${tokens.scope ?? "unknown"}
|
|
696
|
+
Token refreshes: ${tokens.refreshToken ? "yes" : "no"}`);
|
|
697
|
+
return 0;
|
|
698
|
+
}
|
|
699
|
+
case "skill":
|
|
700
|
+
process.stdout.write(loadSkill().raw);
|
|
701
|
+
return 0;
|
|
702
|
+
case "help":
|
|
703
|
+
log(HELP);
|
|
704
|
+
return 0;
|
|
705
|
+
default:
|
|
706
|
+
log(`Unknown command '${command}'.
|
|
707
|
+
|
|
708
|
+
${HELP}`);
|
|
709
|
+
return 1;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
main(process.argv.slice(2)).then(
|
|
713
|
+
(code) => {
|
|
714
|
+
process.exitCode = code;
|
|
715
|
+
},
|
|
716
|
+
(error) => {
|
|
717
|
+
log(`Error: ${error.message}`);
|
|
718
|
+
process.exitCode = 1;
|
|
719
|
+
}
|
|
720
|
+
);
|
|
721
|
+
export {
|
|
722
|
+
main
|
|
723
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@novalinkai/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Connect Claude Code, Cursor, Antigravity and other coding agents to Novalink, so they can plan, build and integrate workflows.",
|
|
5
|
+
"keywords": ["novalink", "mcp", "model-context-protocol", "claude-code", "cursor", "antigravity", "workflows", "ai-agents"],
|
|
6
|
+
"homepage": "https://novalink.live/developers/mcp",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/OshadhaVimuB/Nova-Link.git",
|
|
10
|
+
"directory": "packages/mcp"
|
|
11
|
+
},
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"bin": {
|
|
15
|
+
"novalink-mcp": "dist/cli.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"skill"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18.17"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsup",
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"test": "vitest run",
|
|
28
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^22.0.0",
|
|
32
|
+
"tsup": "^8.5.0",
|
|
33
|
+
"typescript": "^5.9.0",
|
|
34
|
+
"vitest": "^3.2.0"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public",
|
|
38
|
+
"provenance": true
|
|
39
|
+
}
|
|
40
|
+
}
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: novalink-workflows
|
|
3
|
+
description: Plan, build, test, publish and integrate Novalink AI workflows into this codebase through the Novalink MCP server. Use when the user wants to automate a task, build an AI agent workflow, or call a Novalink workflow from their code.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Building Novalink workflows
|
|
7
|
+
|
|
8
|
+
Novalink runs AI agent workflows: a trigger, then nodes (agents, app actions, HTTP calls, logic) joined by edges. You build them through the Novalink MCP tools, then call them from the user's code through the public API.
|
|
9
|
+
|
|
10
|
+
## Procedure
|
|
11
|
+
|
|
12
|
+
1. **Understand the goal.** Ask what should start the workflow, what it must produce, and which apps it touches. Read the codebase to see where it will be called from and what input it can send.
|
|
13
|
+
2. **Discover.** Call `list_integrations` to see connected apps, credentials, custom APIs and MCP servers. Call `list_node_types`, then `get_node_type` for each type you plan to use. Never invent node types, ports or config fields.
|
|
14
|
+
3. **Plan.** Call `plan_workflow` with a precise description. It drafts a graph from the account's real integrations. Show the user the `steps` in plain words and agree on changes before saving. For revisions, pass `previous_graph` and an `instruction`.
|
|
15
|
+
4. **Create.** Call `create_workflow` with the agreed graph. Fix every `error` in `issues` with `update_workflow` (it replaces the whole draft graph). Warnings do not block.
|
|
16
|
+
5. **Test.** Call `run_workflow` on the draft with a realistic `input`. If it fails, read `failed_nodes`, fix the graph, and run again. Runs can call real apps and spend tokens, so say what a test run will do first.
|
|
17
|
+
6. **Publish.** Ask the user, then call `publish_workflow`. It activates the new version by default, which is what the API runs.
|
|
18
|
+
7. **Integrate.**
|
|
19
|
+
- Call `create_api_key` scoped to this workflow only, and write the key to an untracked env file as `NOVALINK_API_KEY`. Check `.gitignore` covers it, and never print the key back or commit it.
|
|
20
|
+
- Call `get_integration_snippet` in the codebase's language, then adapt it to the project's HTTP client, types, config loading and error handling.
|
|
21
|
+
- Handle both answers: 200 means finished, and 202 means poll `status_url`.
|
|
22
|
+
8. **Hand over.** Tell the user the workflow name, the `editor_url`, where the call site lives, and which env var to set in each deployment.
|
|
23
|
+
|
|
24
|
+
## The graph
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"schema_version": 1,
|
|
29
|
+
"nodes": [
|
|
30
|
+
{"id": "trigger", "type": "manual_trigger", "name": "Trigger", "position": {"x": 0, "y": 0},
|
|
31
|
+
"config": {"test_payload": {"message": "Summarise this"}}},
|
|
32
|
+
{"id": "summarise", "type": "agent", "name": "Summarise", "position": {"x": 260, "y": 0},
|
|
33
|
+
"config": {"credential_id": "<an anthropic credential_id>", "model": "claude-haiku-4-5-20251001",
|
|
34
|
+
"user_prompt": "Summarise: {{ trigger.message }}"}},
|
|
35
|
+
{"id": "result", "type": "output", "name": "Result", "position": {"x": 520, "y": 0},
|
|
36
|
+
"config": {"value": "{{ nodes.summarise.output.text }}"}}
|
|
37
|
+
],
|
|
38
|
+
"edges": [
|
|
39
|
+
{"id": "e1", "source": "trigger", "target": "summarise"},
|
|
40
|
+
{"id": "e2", "source": "summarise", "target": "result"}
|
|
41
|
+
],
|
|
42
|
+
"settings": {}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The example shows the shape only. Take each node's exact config fields from `get_node_type`.
|
|
47
|
+
|
|
48
|
+
## Rules
|
|
49
|
+
|
|
50
|
+
- **Trigger.** Exactly one trigger node. Use `manual_trigger` for workflows called from code through the API, with a realistic `config.test_payload`. `webhook_trigger` and `schedule_trigger` exist for other starts.
|
|
51
|
+
- **Ids and names.** Node ids are unique lowercase slugs matching `^[a-z][a-z0-9_]*$`. Names are short Title Case labels.
|
|
52
|
+
- **Config.** Every config must satisfy its node type's `config_schema`, required fields included.
|
|
53
|
+
- **Edges.** An edge goes from an output port to an input port. Omit ports for the defaults (`out` to `in`).
|
|
54
|
+
- **Branching.**
|
|
55
|
+
- An `if` node fires `true` or `false`; connect each branch with `source_port` set to that name.
|
|
56
|
+
- A `switch` node's `config.cases` each name a port, plus `default`.
|
|
57
|
+
- When branches rejoin, route them through a `merge` node.
|
|
58
|
+
- **Flow.** Data flows forward only: no cycles, and every node is reachable from the trigger. End with an `output` node, whose value is what the API returns.
|
|
59
|
+
- **Templates.**
|
|
60
|
+
- `{{ trigger.<field> }}` reads the trigger payload. `{{ nodes.<id>.output.<field> }}` reads an upstream node, and only upstream nodes may be referenced.
|
|
61
|
+
- `if` and `switch` expressions are bare, without braces, e.g. `nodes.classify.output.structured.urgency == 'high'`.
|
|
62
|
+
- **Agents.**
|
|
63
|
+
- Set `credential_id` to an `anthropic` credential from `list_integrations`.
|
|
64
|
+
- An agent's output has `.text`, and `.structured` when `config.output_schema` is set. Set `output_schema` whenever later nodes branch on the answer.
|
|
65
|
+
- Use `claude-haiku-4-5-20251001` unless the task needs deeper reasoning, then `claude-sonnet-5`.
|
|
66
|
+
- **Apps.**
|
|
67
|
+
- GitHub, Slack, Notion, Linear and Tavily use a `connector_action` node with `connector_id`, `action` and `args`. Set `connection_id` from `list_integrations`, and prefer it over `http_request`.
|
|
68
|
+
- The user's own APIs use `api_request` with `api_id`, `endpoint` and `args`. Their MCP servers use `mcp_tool`.
|
|
69
|
+
- **Tools for agents.** An `http_request`, `connector_action` or `api_request` node can be attached to an agent with an edge of `kind: "tool"` and `target_port: "tools"`. A tool node has no other edges.
|
|
70
|
+
- **Secrets.** Never put keys or tokens in a graph. Reference stored credentials and connections by id. If one is missing, send the user to `connect_more` from `list_integrations`.
|
|
71
|
+
- **Size.** Prefer a few well-chosen nodes over many.
|
|
72
|
+
|
|
73
|
+
## Guardrails
|
|
74
|
+
|
|
75
|
+
- Confirm the plan before `create_workflow`, and ask before `publish_workflow`, `activate_version` and `create_api_key`.
|
|
76
|
+
- Edit drafts freely. Published versions change only when you publish again.
|
|
77
|
+
- Keep API keys out of source control, logs and chat.
|
|
78
|
+
- If a tool says a permission was not granted, ask the user to reconnect Novalink and allow it.
|