noslopui 0.1.0 → 0.3.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 +58 -18
- package/SKILL.md +104 -39
- package/dist/adapters/claude-code.js +21 -121
- package/dist/adapters/codex.js +196 -0
- package/dist/adapters/index.js +16 -5
- package/dist/adapters/json-adapter.js +135 -0
- package/dist/adapters/json-agents.js +88 -0
- package/dist/adapters/types.js +4 -4
- package/dist/commands/account.js +49 -0
- package/dist/commands/catalog.js +214 -0
- package/dist/commands/doctor.js +39 -28
- package/dist/commands/init.js +69 -35
- package/dist/commands/login.js +31 -21
- package/dist/commands/remove.js +40 -5
- package/dist/commands/skill.js +42 -0
- package/dist/commands/update.js +17 -11
- package/dist/index.js +109 -29
- package/dist/lib/api.js +92 -15
- package/dist/lib/json-file.js +7 -2
- package/dist/lib/paths.js +41 -0
- package/dist/lib/session.js +49 -0
- package/dist/lib/skill-file.js +26 -0
- package/dist/lib/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Codex (the CLI, the IDE extension and the app share one config).
|
|
2
|
+
//
|
|
3
|
+
// User-level MCP servers live in `~/.codex/config.toml`, one table each:
|
|
4
|
+
//
|
|
5
|
+
// [mcp_servers.noslopui]
|
|
6
|
+
// url = "https://noslopui.com/api/mcp"
|
|
7
|
+
// http_headers = { "Authorization" = "Bearer nsui_…" }
|
|
8
|
+
//
|
|
9
|
+
// Checked against learn.chatgpt.com/docs/extend/mcp (2026-09-21): `url`,
|
|
10
|
+
// `http_headers` (a static map) and `bearer_token_env_var` are all supported.
|
|
11
|
+
// We write the static header, like every other agent, so the key lives in the
|
|
12
|
+
// config and nowhere else — an env var would have to be set in every shell
|
|
13
|
+
// Codex is ever started from, which is exactly the fragility this tool exists
|
|
14
|
+
// to remove.
|
|
15
|
+
//
|
|
16
|
+
// NO TOML PARSER. This package has zero runtime dependencies, so instead of
|
|
17
|
+
// parsing the file we edit one table and leave every other byte as it was:
|
|
18
|
+
// find `[mcp_servers.noslopui]` (and any `[mcp_servers.noslopui.*]`
|
|
19
|
+
// sub-tables), replace our keys in it, keep any other key the user put there.
|
|
20
|
+
// If our server appears in a form we don't recognise — an inline table under
|
|
21
|
+
// `[mcp_servers]`, or a dotted key — we stop and say so, rather than guess.
|
|
22
|
+
// Skills: Codex reads user-level skills from ~/.agents/skills
|
|
23
|
+
// (learn.chatgpt.com/docs/build-skills).
|
|
24
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { backupFile, ConfigUnreadableError, writeTextFile } from '../lib/json-file.js';
|
|
27
|
+
import { home, onPath, sharedSkillPath } from '../lib/paths.js';
|
|
28
|
+
import { installSkillAt, removeSkillAt } from '../lib/skill-file.js';
|
|
29
|
+
import { SERVER_NAME } from './json-adapter.js';
|
|
30
|
+
/** `[mcp_servers.noslopui]` or one of its sub-tables, in any quoting TOML allows. */
|
|
31
|
+
const OUR_HEADER = new RegExp(`^\\s*\\[\\s*mcp_servers\\s*\\.\\s*(?:${SERVER_NAME}|"${SERVER_NAME}"|'${SERVER_NAME}')\\s*(\\]|\\.)`);
|
|
32
|
+
/** `[mcp_servers.noslopui.http_headers]` — replaced by our inline map, or TOML would see the key twice. */
|
|
33
|
+
const OUR_HEADERS_SUBTABLE = new RegExp(`^\\s*\\[\\s*mcp_servers\\s*\\.\\s*["']?${SERVER_NAME}["']?\\s*\\.\\s*["']?http_headers["']?\\s*\\]`);
|
|
34
|
+
const ANY_HEADER = /^\s*\[/;
|
|
35
|
+
/** Our server declared some other way: `noslopui = {…}` or `mcp_servers.noslopui.url = …`. */
|
|
36
|
+
const FOREIGN_FORM = new RegExp(`^\\s*(?:mcp_servers\\s*\\.\\s*)?["']?${SERVER_NAME}["']?\\s*(?:=|\\.)`);
|
|
37
|
+
/** Keys we own inside our table; every other key the user wrote there is kept. */
|
|
38
|
+
const OUR_KEYS = /^\s*(url|http_headers|bearer_token_env_var)\s*=/;
|
|
39
|
+
const tomlString = (s) => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
40
|
+
function parse(path) {
|
|
41
|
+
if (!existsSync(path))
|
|
42
|
+
return null;
|
|
43
|
+
let raw;
|
|
44
|
+
try {
|
|
45
|
+
raw = readFileSync(path, 'utf8');
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
throw new ConfigUnreadableError(path, err);
|
|
49
|
+
}
|
|
50
|
+
const eol = raw.includes('\r\n') ? '\r\n' : '\n';
|
|
51
|
+
const lines = raw.split(/\r?\n/);
|
|
52
|
+
const blocks = [];
|
|
53
|
+
let open = null;
|
|
54
|
+
lines.forEach((line, i) => {
|
|
55
|
+
if (ANY_HEADER.test(line)) {
|
|
56
|
+
if (open !== null) {
|
|
57
|
+
blocks.push([open, i]);
|
|
58
|
+
open = null;
|
|
59
|
+
}
|
|
60
|
+
if (OUR_HEADER.test(line))
|
|
61
|
+
open = i;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (open === null && !line.trim().startsWith('#') && FOREIGN_FORM.test(line)) {
|
|
65
|
+
throw new ConfigUnreadableError(path, new Error(`"${SERVER_NAME}" is declared in a form this tool doesn't edit`));
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
if (open !== null)
|
|
69
|
+
blocks.push([open, lines.length]);
|
|
70
|
+
return { lines, eol, blocks };
|
|
71
|
+
}
|
|
72
|
+
function ourLines(parsed) {
|
|
73
|
+
return parsed.blocks.flatMap(([a, b]) => parsed.lines.slice(a, b));
|
|
74
|
+
}
|
|
75
|
+
export class CodexAdapter {
|
|
76
|
+
id = 'codex';
|
|
77
|
+
label = 'Codex';
|
|
78
|
+
configPath() {
|
|
79
|
+
return join(home(), '.codex', 'config.toml');
|
|
80
|
+
}
|
|
81
|
+
skillPath() {
|
|
82
|
+
return sharedSkillPath();
|
|
83
|
+
}
|
|
84
|
+
async detect() {
|
|
85
|
+
try {
|
|
86
|
+
return onPath('codex') || existsSync(join(home(), '.codex'));
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async install(entry) {
|
|
93
|
+
const path = this.configPath();
|
|
94
|
+
const parsed = parse(path);
|
|
95
|
+
const backupPath = backupFile(path);
|
|
96
|
+
const lines = parsed?.lines ?? [];
|
|
97
|
+
const eol = parsed?.eol ?? '\n';
|
|
98
|
+
const blocks = parsed?.blocks ?? [];
|
|
99
|
+
const replacedExisting = blocks.length > 0;
|
|
100
|
+
// Keys the user added to our main table (enabled, tool_timeout_sec, …)
|
|
101
|
+
// and any sub-table other than http_headers survive the rewrite.
|
|
102
|
+
const kept = [];
|
|
103
|
+
const keptSubtables = [];
|
|
104
|
+
for (const [a, b] of blocks) {
|
|
105
|
+
const header = lines[a] ?? '';
|
|
106
|
+
const body = lines.slice(a + 1, b);
|
|
107
|
+
if (OUR_HEADERS_SUBTABLE.test(header))
|
|
108
|
+
continue;
|
|
109
|
+
if (/\]\s*(#.*)?$/.test(header) && new RegExp(`${SERVER_NAME}["']?\\s*\\]`).test(header)) {
|
|
110
|
+
kept.push(...body.filter((l) => !OUR_KEYS.test(l)));
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
keptSubtables.push(header, ...body);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
while (kept.length && !kept[kept.length - 1].trim())
|
|
117
|
+
kept.pop();
|
|
118
|
+
const block = [
|
|
119
|
+
`[mcp_servers.${SERVER_NAME}]`,
|
|
120
|
+
`url = ${tomlString(entry.url)}`,
|
|
121
|
+
`http_headers = { "Authorization" = ${tomlString(`Bearer ${entry.key}`)} }`,
|
|
122
|
+
...kept,
|
|
123
|
+
'',
|
|
124
|
+
...keptSubtables,
|
|
125
|
+
];
|
|
126
|
+
while (block.length && !block[block.length - 1].trim())
|
|
127
|
+
block.pop();
|
|
128
|
+
let out;
|
|
129
|
+
if (blocks.length) {
|
|
130
|
+
const first = blocks[0][0];
|
|
131
|
+
const inBlocks = (i) => blocks.some(([a, b]) => i >= a && i < b);
|
|
132
|
+
out = [...lines.slice(0, first).filter((_, i) => !inBlocks(i)), ...block, '', ...lines.slice(first).filter((_, j) => !inBlocks(first + j))];
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
const body = [...lines];
|
|
136
|
+
while (body.length && !body[body.length - 1].trim())
|
|
137
|
+
body.pop();
|
|
138
|
+
out = body.length ? [...body, '', ...block] : [...block];
|
|
139
|
+
}
|
|
140
|
+
while (out.length && !out[out.length - 1].trim())
|
|
141
|
+
out.pop();
|
|
142
|
+
writeTextFile(path, out.join(eol) + eol);
|
|
143
|
+
const after = await this.status();
|
|
144
|
+
if (!after.installed || after.url !== entry.url || !after.hasKey) {
|
|
145
|
+
throw new Error(`The entry was written to ${path} but reading it back didn't show it.`);
|
|
146
|
+
}
|
|
147
|
+
return { configPath: path, backupPath, method: 'config-file', replacedExisting };
|
|
148
|
+
}
|
|
149
|
+
async status() {
|
|
150
|
+
const path = this.configPath();
|
|
151
|
+
const parsed = parse(path);
|
|
152
|
+
if (!parsed || !parsed.blocks.length)
|
|
153
|
+
return { installed: false, configPath: path, url: null, hasKey: false };
|
|
154
|
+
const text = ourLines(parsed).join('\n');
|
|
155
|
+
const url = text.match(/^\s*url\s*=\s*"([^"]*)"/m)?.[1] ?? null;
|
|
156
|
+
const hasKey = /"?Authorization"?\s*=\s*"Bearer\s+\S+"/i.test(text) || /^\s*bearer_token_env_var\s*=/m.test(text);
|
|
157
|
+
let problem;
|
|
158
|
+
if (!url)
|
|
159
|
+
problem = 'The entry has no url.';
|
|
160
|
+
else if (!hasKey)
|
|
161
|
+
problem = 'The entry has no Authorization header, so Codex will be asked to sign in ("codex mcp login noslopui").';
|
|
162
|
+
return { installed: true, configPath: path, url, hasKey, ...(problem ? { problem } : {}) };
|
|
163
|
+
}
|
|
164
|
+
async configuredKey() {
|
|
165
|
+
try {
|
|
166
|
+
const parsed = parse(this.configPath());
|
|
167
|
+
if (!parsed)
|
|
168
|
+
return null;
|
|
169
|
+
return ourLines(parsed).join('\n').match(/"?Authorization"?\s*=\s*"Bearer\s+([^"\s]+)"/i)?.[1] ?? null;
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async uninstall() {
|
|
176
|
+
const path = this.configPath();
|
|
177
|
+
const parsed = parse(path);
|
|
178
|
+
if (!parsed || !parsed.blocks.length)
|
|
179
|
+
return false;
|
|
180
|
+
backupFile(path);
|
|
181
|
+
const inBlocks = (i) => parsed.blocks.some(([a, b]) => i >= a && i < b);
|
|
182
|
+
const out = parsed.lines.filter((_, i) => !inBlocks(i));
|
|
183
|
+
// Collapse the blank lines our table leaves behind.
|
|
184
|
+
const tidy = out.filter((l, i) => l.trim() || (out[i - 1]?.trim() ?? '') !== '');
|
|
185
|
+
while (tidy.length && !tidy[tidy.length - 1].trim())
|
|
186
|
+
tidy.pop();
|
|
187
|
+
writeTextFile(path, tidy.length ? tidy.join(parsed.eol) + parsed.eol : '');
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
async installSkill(markdown) {
|
|
191
|
+
return installSkillAt(this.skillPath(), markdown);
|
|
192
|
+
}
|
|
193
|
+
async removeSkill() {
|
|
194
|
+
return removeSkillAt(this.skillPath());
|
|
195
|
+
}
|
|
196
|
+
}
|
package/dist/adapters/index.js
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
|
-
// The adapter registry.
|
|
1
|
+
// The adapter registry — every agent `npx noslopui init` can set up.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// The server keeps the same list (CLI_AGENTS in the website's lib/cli-auth.ts)
|
|
4
|
+
// and refuses a key for any agent not on it, so the two change together.
|
|
5
|
+
// Order is the order they're offered in.
|
|
6
6
|
import { ClaudeCodeAdapter } from './claude-code.js';
|
|
7
|
-
|
|
7
|
+
import { CodexAdapter } from './codex.js';
|
|
8
|
+
import { antigravity, cursor, devin, opencode, vscode, windsurf } from './json-agents.js';
|
|
9
|
+
export const ADAPTERS = [
|
|
10
|
+
new ClaudeCodeAdapter(),
|
|
11
|
+
new CodexAdapter(),
|
|
12
|
+
cursor(),
|
|
13
|
+
vscode(),
|
|
14
|
+
devin(),
|
|
15
|
+
windsurf(),
|
|
16
|
+
opencode(),
|
|
17
|
+
antigravity(),
|
|
18
|
+
];
|
|
8
19
|
export function adapterById(id) {
|
|
9
20
|
return ADAPTERS.find((a) => a.id === id);
|
|
10
21
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// The shared shape of every agent whose MCP config is a JSON file.
|
|
2
|
+
//
|
|
3
|
+
// Seven of the eight agents keep their servers in a JSON object keyed by
|
|
4
|
+
// server name; they differ only in where the file is, what the object is
|
|
5
|
+
// called (`mcpServers`, `servers`, `mcp`), and which fields a remote server
|
|
6
|
+
// takes (`url` or `serverUrl`, plus `type: "http"`, `transport: "http"` or
|
|
7
|
+
// `type: "remote"`). Each adapter states those facts and nothing else; the
|
|
8
|
+
// rules for editing a file we don't own live here once, in `lib/json-file.ts`
|
|
9
|
+
// underneath:
|
|
10
|
+
//
|
|
11
|
+
// · back up before the first write, merge, never touch another entry
|
|
12
|
+
// · keep fields on our own entry that we didn't put there
|
|
13
|
+
// · write atomically, and stop cold on a file that doesn't parse
|
|
14
|
+
// · read the entry back — several of these files have another writer
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
import { backupFile, detectIndent, readJsonFile, writeJsonFile } from '../lib/json-file.js';
|
|
17
|
+
import { installSkillAt, removeSkillAt } from '../lib/skill-file.js';
|
|
18
|
+
import { onPath } from '../lib/paths.js';
|
|
19
|
+
/** The name the server appears under in every agent. */
|
|
20
|
+
export const SERVER_NAME = 'noslopui';
|
|
21
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
22
|
+
export class JsonConfigAdapter {
|
|
23
|
+
spec;
|
|
24
|
+
id;
|
|
25
|
+
label;
|
|
26
|
+
constructor(spec) {
|
|
27
|
+
this.spec = spec;
|
|
28
|
+
this.id = spec.id;
|
|
29
|
+
this.label = spec.label;
|
|
30
|
+
}
|
|
31
|
+
configPath() {
|
|
32
|
+
return this.spec.configPath();
|
|
33
|
+
}
|
|
34
|
+
skillPath() {
|
|
35
|
+
return this.spec.skillPath();
|
|
36
|
+
}
|
|
37
|
+
async detect() {
|
|
38
|
+
try {
|
|
39
|
+
if ((this.spec.detectBinaries ?? []).some(onPath))
|
|
40
|
+
return true;
|
|
41
|
+
return this.spec.detectPaths().some((p) => existsSync(p));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async install(entry) {
|
|
48
|
+
const path = this.configPath();
|
|
49
|
+
const config = readJsonFile(path) ?? {};
|
|
50
|
+
const backupPath = backupFile(path);
|
|
51
|
+
const servers = isObject(config[this.spec.container]) ? config[this.spec.container] : {};
|
|
52
|
+
const replacedExisting = SERVER_NAME in servers;
|
|
53
|
+
// Spread the existing entry so anything the agent (or the user) added to
|
|
54
|
+
// it that we don't know about survives the update.
|
|
55
|
+
const existing = isObject(servers[SERVER_NAME]) ? servers[SERVER_NAME] : {};
|
|
56
|
+
const existingHeaders = isObject(existing.headers) ? existing.headers : {};
|
|
57
|
+
// The other spelling of the url field would be read instead of ours by an
|
|
58
|
+
// agent that accepts both, so it goes.
|
|
59
|
+
const { url: _url, serverUrl: _serverUrl, ...rest } = existing;
|
|
60
|
+
servers[SERVER_NAME] = {
|
|
61
|
+
...rest,
|
|
62
|
+
...(this.spec.fixed ?? {}),
|
|
63
|
+
[this.spec.urlField]: entry.url,
|
|
64
|
+
headers: { ...existingHeaders, Authorization: `Bearer ${entry.key}` },
|
|
65
|
+
};
|
|
66
|
+
config[this.spec.container] = servers;
|
|
67
|
+
writeJsonFile(path, config, { indent: existsSync(path) ? detectIndent(path) : 2 });
|
|
68
|
+
// Read back: a successful write is not the same as a config that says
|
|
69
|
+
// what we think it says, when something else may rewrite the file.
|
|
70
|
+
const after = await this.status();
|
|
71
|
+
if (!after.installed || after.url !== entry.url || !after.hasKey) {
|
|
72
|
+
throw new Error(`The entry was written to ${path} but reading it back didn't show it.${this.spec.readBackHint ? ` ${this.spec.readBackHint}` : ''}`);
|
|
73
|
+
}
|
|
74
|
+
return { configPath: path, backupPath, method: 'config-file', replacedExisting };
|
|
75
|
+
}
|
|
76
|
+
async status() {
|
|
77
|
+
const path = this.configPath();
|
|
78
|
+
const config = readJsonFile(path);
|
|
79
|
+
const servers = config && isObject(config[this.spec.container]) ? config[this.spec.container] : null;
|
|
80
|
+
const entry = servers && isObject(servers[SERVER_NAME]) ? servers[SERVER_NAME] : null;
|
|
81
|
+
if (!entry)
|
|
82
|
+
return { installed: false, configPath: path, url: null, hasKey: false };
|
|
83
|
+
const raw = entry[this.spec.urlField];
|
|
84
|
+
const url = typeof raw === 'string' ? raw : null;
|
|
85
|
+
const headers = isObject(entry.headers) ? entry.headers : {};
|
|
86
|
+
const auth = typeof headers.Authorization === 'string' ? headers.Authorization : '';
|
|
87
|
+
const hasKey = /^Bearer\s+\S+/i.test(auth);
|
|
88
|
+
let problem;
|
|
89
|
+
if (!url)
|
|
90
|
+
problem = `The entry has no "${this.spec.urlField}".`;
|
|
91
|
+
else if (!hasKey)
|
|
92
|
+
problem = 'The entry has no Authorization header, so the agent will be asked to sign in.';
|
|
93
|
+
else {
|
|
94
|
+
for (const [field, value] of Object.entries(this.spec.fixed ?? {})) {
|
|
95
|
+
if (entry[field] !== value) {
|
|
96
|
+
problem = `The entry's ${field} is "${String(entry[field])}", expected "${String(value)}".`;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { installed: true, configPath: path, url, hasKey, ...(problem ? { problem } : {}) };
|
|
102
|
+
}
|
|
103
|
+
async configuredKey() {
|
|
104
|
+
try {
|
|
105
|
+
const config = readJsonFile(this.configPath());
|
|
106
|
+
const servers = config && isObject(config[this.spec.container]) ? config[this.spec.container] : null;
|
|
107
|
+
const entry = servers && isObject(servers[SERVER_NAME]) ? servers[SERVER_NAME] : null;
|
|
108
|
+
const headers = entry && isObject(entry.headers) ? entry.headers : {};
|
|
109
|
+
const auth = typeof headers.Authorization === 'string' ? headers.Authorization : '';
|
|
110
|
+
return auth.match(/^Bearer\s+(\S+)$/i)?.[1] ?? null;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async uninstall() {
|
|
117
|
+
const path = this.configPath();
|
|
118
|
+
const config = readJsonFile(path);
|
|
119
|
+
const servers = config && isObject(config[this.spec.container]) ? config[this.spec.container] : null;
|
|
120
|
+
if (!config || !servers || !(SERVER_NAME in servers))
|
|
121
|
+
return false;
|
|
122
|
+
backupFile(path);
|
|
123
|
+
delete servers[SERVER_NAME];
|
|
124
|
+
writeJsonFile(path, config, { indent: detectIndent(path) });
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
async installSkill(markdown) {
|
|
128
|
+
const path = this.skillPath();
|
|
129
|
+
return path ? installSkillAt(path, markdown) : null;
|
|
130
|
+
}
|
|
131
|
+
async removeSkill() {
|
|
132
|
+
const path = this.skillPath();
|
|
133
|
+
return path ? removeSkillAt(path) : false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Every agent whose MCP config is a JSON file, apart from Claude Code (which
|
|
2
|
+
// has its own module for its history). Each entry is only facts, and each fact
|
|
3
|
+
// is from that vendor's own documentation, checked 2026-09-21:
|
|
4
|
+
//
|
|
5
|
+
// Cursor cursor.com/docs/context/mcp, …/context/skills
|
|
6
|
+
// VS Code code.visualstudio.com/docs/copilot/customization/mcp-servers, …/agent-skills
|
|
7
|
+
// Devin docs.devin.ai/cli/extensibility/mcp/configuration, …/skills
|
|
8
|
+
// Windsurf docs.devin.ai/desktop/cascade/mcp (the legacy Cascade agent)
|
|
9
|
+
// OpenCode opencode.ai/docs/config, …/mcp-servers, …/skills
|
|
10
|
+
// Antigravity antigravity.google/docs/mcp
|
|
11
|
+
//
|
|
12
|
+
// Windsurf shipped as Devin Desktop in June 2026 and now has two agents with
|
|
13
|
+
// two config files: Devin Local (the default for new tabs, and the Devin CLI)
|
|
14
|
+
// reads the Devin CLI's config; the legacy Cascade agent still reads
|
|
15
|
+
// ~/.codeium/windsurf/mcp_config.json. They're two adapters, so each is
|
|
16
|
+
// detected, written and checked on its own. Both read skills from
|
|
17
|
+
// ~/.codeium/windsurf/skills, so they share one copy.
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { JsonConfigAdapter } from './json-adapter.js';
|
|
20
|
+
import { appData, home, sharedSkillPath, vscodeUserDir, xdgConfig } from '../lib/paths.js';
|
|
21
|
+
const devinConfigDir = () => (process.platform === 'win32' ? join(appData(), 'devin') : join(xdgConfig(), 'devin'));
|
|
22
|
+
const windsurfSkillPath = () => join(home(), '.codeium', 'windsurf', 'skills', 'noslopui', 'SKILL.md');
|
|
23
|
+
export const cursor = () => new JsonConfigAdapter({
|
|
24
|
+
id: 'cursor',
|
|
25
|
+
label: 'Cursor',
|
|
26
|
+
configPath: () => join(home(), '.cursor', 'mcp.json'),
|
|
27
|
+
container: 'mcpServers',
|
|
28
|
+
urlField: 'url',
|
|
29
|
+
detectPaths: () => [join(home(), '.cursor')],
|
|
30
|
+
detectBinaries: ['cursor'],
|
|
31
|
+
skillPath: sharedSkillPath,
|
|
32
|
+
});
|
|
33
|
+
export const vscode = () => new JsonConfigAdapter({
|
|
34
|
+
id: 'vscode',
|
|
35
|
+
label: 'VS Code',
|
|
36
|
+
configPath: () => join(vscodeUserDir(), 'mcp.json'),
|
|
37
|
+
container: 'servers',
|
|
38
|
+
urlField: 'url',
|
|
39
|
+
fixed: { type: 'http' },
|
|
40
|
+
detectPaths: () => [vscodeUserDir()],
|
|
41
|
+
detectBinaries: ['code'],
|
|
42
|
+
skillPath: sharedSkillPath,
|
|
43
|
+
});
|
|
44
|
+
export const devin = () => new JsonConfigAdapter({
|
|
45
|
+
id: 'devin',
|
|
46
|
+
label: 'Devin Desktop',
|
|
47
|
+
configPath: () => join(devinConfigDir(), 'mcp_config.json'),
|
|
48
|
+
container: 'mcpServers',
|
|
49
|
+
urlField: 'url',
|
|
50
|
+
fixed: { transport: 'http' },
|
|
51
|
+
detectPaths: () => [devinConfigDir()],
|
|
52
|
+
detectBinaries: ['devin'],
|
|
53
|
+
skillPath: windsurfSkillPath,
|
|
54
|
+
});
|
|
55
|
+
export const windsurf = () => new JsonConfigAdapter({
|
|
56
|
+
id: 'windsurf',
|
|
57
|
+
label: 'Windsurf (Cascade)',
|
|
58
|
+
configPath: () => join(home(), '.codeium', 'windsurf', 'mcp_config.json'),
|
|
59
|
+
container: 'mcpServers',
|
|
60
|
+
urlField: 'serverUrl',
|
|
61
|
+
detectPaths: () => [join(home(), '.codeium', 'windsurf')],
|
|
62
|
+
detectBinaries: ['windsurf'],
|
|
63
|
+
skillPath: windsurfSkillPath,
|
|
64
|
+
});
|
|
65
|
+
export const opencode = () => new JsonConfigAdapter({
|
|
66
|
+
id: 'opencode',
|
|
67
|
+
label: 'OpenCode',
|
|
68
|
+
// OpenCode also reads opencode.jsonc. We only ever write the .json file:
|
|
69
|
+
// a .jsonc may carry comments that a JSON round trip would delete, and
|
|
70
|
+
// the reader refuses a file it can't parse rather than rewrite it.
|
|
71
|
+
configPath: () => join(xdgConfig(), 'opencode', 'opencode.json'),
|
|
72
|
+
container: 'mcp',
|
|
73
|
+
urlField: 'url',
|
|
74
|
+
fixed: { type: 'remote', enabled: true },
|
|
75
|
+
detectPaths: () => [join(xdgConfig(), 'opencode')],
|
|
76
|
+
detectBinaries: ['opencode'],
|
|
77
|
+
skillPath: sharedSkillPath,
|
|
78
|
+
});
|
|
79
|
+
export const antigravity = () => new JsonConfigAdapter({
|
|
80
|
+
id: 'antigravity',
|
|
81
|
+
label: 'Antigravity',
|
|
82
|
+
configPath: () => join(home(), '.gemini', 'config', 'mcp_config.json'),
|
|
83
|
+
container: 'mcpServers',
|
|
84
|
+
urlField: 'serverUrl',
|
|
85
|
+
detectPaths: () => [join(home(), '.gemini', 'config'), join(home(), '.gemini', 'antigravity')],
|
|
86
|
+
detectBinaries: ['antigravity'],
|
|
87
|
+
skillPath: () => join(home(), '.gemini', 'config', 'skills', 'noslopui', 'SKILL.md'),
|
|
88
|
+
});
|
package/dist/adapters/types.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// The shape every agent adapter fills in.
|
|
2
2
|
//
|
|
3
3
|
// Adding an agent is meant to be this file's worth of work and nothing else:
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// the facts about its config (most agents are one entry in json-agents.ts),
|
|
5
|
+
// one entry in the registry, the same id in the server's CLI_AGENTS, and a
|
|
6
|
+
// fixture test. The interesting part of supporting a new agent is finding out
|
|
7
|
+
// from its own docs where it keeps things, not writing code.
|
|
8
8
|
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// `noslopui whoami` and `noslopui logout` — the CLI's own session.
|
|
2
|
+
import { origin, whoami as fetchAccount } from '../lib/api.js';
|
|
3
|
+
import { clearSession, readSession, sessionPath } from '../lib/session.js';
|
|
4
|
+
import { color, hint, ok, say, warn } from '../lib/ui.js';
|
|
5
|
+
export async function whoami(options) {
|
|
6
|
+
const session = readSession();
|
|
7
|
+
if (!session) {
|
|
8
|
+
if (options.json)
|
|
9
|
+
say(JSON.stringify({ signedIn: false }));
|
|
10
|
+
else {
|
|
11
|
+
say();
|
|
12
|
+
warn('Not signed in.');
|
|
13
|
+
hint('Run "npx noslopui login".');
|
|
14
|
+
say();
|
|
15
|
+
}
|
|
16
|
+
return 1;
|
|
17
|
+
}
|
|
18
|
+
const account = await fetchAccount(session.key);
|
|
19
|
+
if (options.json) {
|
|
20
|
+
say(JSON.stringify({ signedIn: true, ...account }, null, 2));
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
const plan = account.plan === 'paid'
|
|
24
|
+
? 'paid plan'
|
|
25
|
+
: account.plan === 'trial'
|
|
26
|
+
? account.trialEndsAt
|
|
27
|
+
? `free trial, ends ${new Date(account.trialEndsAt).toLocaleDateString()}`
|
|
28
|
+
: 'free trial, not started yet — the first unlock starts it'
|
|
29
|
+
: 'free — search and collections; code and DESIGN.md files need a plan';
|
|
30
|
+
say();
|
|
31
|
+
ok(`${color.bold(account.email ?? 'Signed in')} · ${plan}`);
|
|
32
|
+
hint(session.savedAt === 'env' ? 'key from NOSLOPUI_API_KEY' : `key saved in ${sessionPath()}`);
|
|
33
|
+
say();
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
export async function logout() {
|
|
37
|
+
say();
|
|
38
|
+
if (clearSession()) {
|
|
39
|
+
ok('Signed out on this machine.');
|
|
40
|
+
hint(`The key still exists on your account — revoke it at ${origin()}/account?tab=mcp if this machine shouldn't have it.`);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
warn('Not signed in, so nothing to do.');
|
|
44
|
+
}
|
|
45
|
+
if (process.env.NOSLOPUI_API_KEY)
|
|
46
|
+
hint('NOSLOPUI_API_KEY is still set in this shell.');
|
|
47
|
+
say();
|
|
48
|
+
return 0;
|
|
49
|
+
}
|