noslopui 0.1.0 → 0.2.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 +9 -13
- 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 +191 -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 +106 -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
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Connect your coding agent to [noslopUI](https://noslopui.com) — hand-crafted U
|
|
|
6
6
|
npx noslopui@latest init
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
That one command finds your
|
|
9
|
+
That one command finds the agents on your machine, signs you in once through the browser, adds the MCP server to each agent's **user-level** config, and installs the workflow skill. Restart your agents and ask one to build something.
|
|
10
10
|
|
|
11
11
|
## Why
|
|
12
12
|
|
|
@@ -14,46 +14,86 @@ Ask any agent for a landing page and you get the same page: gradient headline, p
|
|
|
14
14
|
|
|
15
15
|
The MCP server is what your agent calls. The skill is what makes it think to call it at all when you say "build me a website" — so `init` installs both, and you can skip the skill with `--no-skill`.
|
|
16
16
|
|
|
17
|
+
## Supported agents
|
|
18
|
+
|
|
19
|
+
| Agent | `--agent` | Config it writes | Skill it installs |
|
|
20
|
+
|---|---|---|---|
|
|
21
|
+
| Claude Code | `claude-code` | `~/.claude.json` | `~/.claude/skills/noslopui/` |
|
|
22
|
+
| Codex | `codex` | `~/.codex/config.toml` | `~/.agents/skills/noslopui/` |
|
|
23
|
+
| Cursor | `cursor` | `~/.cursor/mcp.json` | `~/.agents/skills/noslopui/` |
|
|
24
|
+
| VS Code (Copilot) | `vscode` | your user `mcp.json` ¹ | `~/.agents/skills/noslopui/` |
|
|
25
|
+
| Devin Desktop | `devin` | `~/.config/devin/mcp_config.json` ² | `~/.codeium/windsurf/skills/noslopui/` |
|
|
26
|
+
| Windsurf (Cascade) | `windsurf` | `~/.codeium/windsurf/mcp_config.json` | `~/.codeium/windsurf/skills/noslopui/` |
|
|
27
|
+
| OpenCode | `opencode` | `~/.config/opencode/opencode.json` | `~/.agents/skills/noslopui/` |
|
|
28
|
+
| Antigravity | `antigravity` | `~/.gemini/config/mcp_config.json` | `~/.gemini/config/skills/noslopui/` |
|
|
29
|
+
|
|
30
|
+
¹ `%APPDATA%\Code\User\mcp.json` on Windows, `~/Library/Application Support/Code/User/mcp.json` on macOS, `~/.config/Code/User/mcp.json` on Linux.
|
|
31
|
+
² `%APPDATA%\devin\mcp_config.json` on Windows. Windsurf became Devin Desktop in June 2026: its default agent (Devin Local) reads this file, the older Cascade agent reads the Windsurf one, so they're set up separately.
|
|
32
|
+
|
|
33
|
+
Every path and field comes from that agent's own documentation. Several agents read the same skills folder, so one copy there serves all of them.
|
|
34
|
+
|
|
35
|
+
Anything else that speaks MCP over HTTP can be connected by hand — add `https://noslopui.com/api/mcp` and sign in when it asks. Steps for each client: [noslopui.com/mcp](https://noslopui.com/mcp).
|
|
36
|
+
|
|
17
37
|
## Commands
|
|
18
38
|
|
|
39
|
+
**Set up**
|
|
40
|
+
|
|
19
41
|
| | |
|
|
20
42
|
|---|---|
|
|
21
|
-
| `npx noslopui init` | Set up
|
|
43
|
+
| `npx noslopui init` | Set up your agents: one sign-in, then the server and the skill for each |
|
|
22
44
|
| `npx noslopui doctor` | Check what's set up, and say what to do about anything that isn't |
|
|
23
|
-
| `npx noslopui login` | Get a fresh key for an agent that's already set up |
|
|
24
45
|
| `npx noslopui update` | Re-download the skill |
|
|
46
|
+
| `npx noslopui skill` | Install only the skill, for an agent you connected by hand (`--agent <id>`) |
|
|
25
47
|
| `npx noslopui remove` | Take the noslopUI server back out |
|
|
26
48
|
|
|
27
|
-
|
|
49
|
+
**Account**
|
|
28
50
|
|
|
29
|
-
|
|
51
|
+
| | |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `npx noslopui login` | Sign this CLI in, for the catalog commands below |
|
|
54
|
+
| `npx noslopui login --agent <id>` | A fresh key for an agent that's already set up |
|
|
55
|
+
| `npx noslopui whoami` | The account and plan this CLI is signed in to |
|
|
56
|
+
| `npx noslopui logout` | Forget this CLI's sign-in |
|
|
57
|
+
|
|
58
|
+
**Catalog**
|
|
59
|
+
|
|
60
|
+
| | |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `npx noslopui search <query>` | Search components and blocks (`--tag style:editorial`, `--framework`, `--limit`) |
|
|
63
|
+
| `npx noslopui get <id>` | Print a component's code, or `--write src/Hero.tsx` (`--format tsx\|html\|vue`) |
|
|
64
|
+
| `npx noslopui prompt <id>` | Print a component's AI-prompt version |
|
|
65
|
+
| `npx noslopui design-systems [query]` | Search design systems (`--tag theme:dark`) |
|
|
66
|
+
| `npx noslopui design-system <id>` | Print a DESIGN.md, or `--write DESIGN.md` |
|
|
67
|
+
| `npx noslopui collections` | Your saved collections |
|
|
68
|
+
| `npx noslopui collection <name>` | What's in one |
|
|
30
69
|
|
|
31
|
-
|
|
70
|
+
The catalog commands call the same MCP tools your agent does, with the same access: search and collections are free on every account; code, prompts and DESIGN.md files need a paid plan or the account's free trial. Output is only the thing you asked for, so `get <id> > Hero.tsx` works; `--json` gives the raw result.
|
|
32
71
|
|
|
33
|
-
|
|
72
|
+
Options: `--agent <id>` (repeatable), `--no-skill`, `--no-browser`, `--key <key>`, `--json`, `--force`, `-y/--yes`.
|
|
34
73
|
|
|
35
74
|
## What it does to your machine
|
|
36
75
|
|
|
37
|
-
It edits your
|
|
76
|
+
It edits your agents' user-level config, and nothing else:
|
|
38
77
|
|
|
39
|
-
- Adds one entry, `noslopui`,
|
|
40
|
-
- **Copies the file first.** The backup
|
|
41
|
-
- **Never touches another server's entry**, or
|
|
78
|
+
- Adds one entry, `noslopui`, to each agent you choose, in the file listed above.
|
|
79
|
+
- **Copies the file first.** The backup sits next to the original as `<file>.noslopui-backup-<timestamp>`, and its path is printed.
|
|
80
|
+
- **Never touches another server's entry**, or anything else in the file. Codex's `config.toml` is edited table by table: every line outside `[mcp_servers.noslopui]` is left byte for byte as it was.
|
|
42
81
|
- **Never writes to a project config.** A key in a project file is a key that gets committed.
|
|
43
|
-
- **Stops if the config doesn't parse
|
|
44
|
-
- Writes the skill to
|
|
82
|
+
- **Stops if the config doesn't parse**, or if the `noslopui` entry is written in a form it doesn't recognise. It will not overwrite a file it couldn't read — that file is the only copy of your setup.
|
|
83
|
+
- Writes the skill to the folders listed above.
|
|
84
|
+
- Saves the CLI's own sign-in in `~/.config/noslopui/credentials.json` (`%APPDATA%\noslopui\` on Windows), readable only by you.
|
|
45
85
|
|
|
46
|
-
`remove` takes the entry back out and leaves everything else, including the skill unless you pass `--skill
|
|
86
|
+
`remove` takes the entry back out and leaves everything else, including the skill unless you pass `--skill` — and even then it keeps a skill folder another configured agent still reads. It does **not** revoke your key: the key belongs to your account, not to this machine. Revoke it at [noslopui.com/account](https://noslopui.com/account?tab=mcp).
|
|
47
87
|
|
|
48
88
|
### If Claude Code is running
|
|
49
89
|
|
|
50
90
|
`~/.claude.json` is also Claude Code's own state file, and a running Claude Code rewrites it from memory. Quit it before running `init`, or the entry can be overwritten seconds later. `doctor` re-reads the file rather than trusting the write, so it will tell you if that happened.
|
|
51
91
|
|
|
52
|
-
## Your
|
|
92
|
+
## Your keys
|
|
53
93
|
|
|
54
|
-
`init` issues one key per agent, named after the agent and this machine, so you can tell them apart and revoke one without breaking the others.
|
|
94
|
+
`init` issues one key per agent, named after the agent and this machine, so you can tell them apart on the account page and revoke one without breaking the others — plus one for the CLI itself if it isn't signed in yet. Keys are written to each agent's config and nowhere else; this tool never prints one in full, and never sends it anywhere except noslopui.com.
|
|
55
95
|
|
|
56
|
-
`--key <key>` skips the browser and uses a key you already made on the account page, for CI or a machine with no browser.
|
|
96
|
+
`--key <key>` skips the browser and uses a key you already made on the account page, for CI or a machine with no browser. `NOSLOPUI_API_KEY` does the same for the catalog commands.
|
|
57
97
|
|
|
58
98
|
## The skill
|
|
59
99
|
|
|
@@ -72,7 +112,7 @@ npm run typecheck
|
|
|
72
112
|
|
|
73
113
|
`NOSLOPUI_ORIGIN` points the CLI at a different server, which is how the flow is exercised against a local one.
|
|
74
114
|
|
|
75
|
-
Adding an agent is
|
|
115
|
+
Adding an agent is its facts in `src/adapters/json-agents.ts` (or its own module, if its config isn't JSON), one line in the registry, the same id in the server's `CLI_AGENTS`, and a row in `test/agents.test.ts`.
|
|
76
116
|
|
|
77
117
|
## Licence
|
|
78
118
|
|
package/SKILL.md
CHANGED
|
@@ -14,15 +14,15 @@ AI-generated) UI components, full page examples, and portable Design Systems.
|
|
|
14
14
|
2. **Search the whole catalog on fit.** A design system is a set of rules, not an inventory of parts: any component can be built in any system, so nothing is off-limits once one is chosen.
|
|
15
15
|
3. **Never substitute a catalog component for one the user explicitly saved** in a collection. Their pick was a decision, not an oversight.
|
|
16
16
|
4. **Ask about their brand before building** — logo, brand colours, typeface, real photography. Put their values into the system's tokens (their colour replaces `accent`) and keep the system's rules.
|
|
17
|
-
5. **Check for a collection first:** call `list_collections`. If one fits the request, build from it. If none fits,
|
|
17
|
+
5. **Check for a collection first:** call `list_collections`. If one fits the request, build from it. If none fits, don't guess a collection — shortlist two or three design systems with `search_design_systems` and let the user pick.
|
|
18
18
|
6. **If a gated tool says the trial has ended or the fair-use limit is reached, tell the user and stop** — don't retry, and don't swap in something generated from scratch.
|
|
19
19
|
|
|
20
|
-
Setup, if
|
|
21
|
-
`noslopui
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
starts.
|
|
20
|
+
Setup, if the noslopUI tools aren't available: the user runs
|
|
21
|
+
`npx noslopui@latest init`, or adds `https://noslopui.com/api/mcp` in their
|
|
22
|
+
agent and signs in when it asks (steps for each agent: `noslopui.com/mcp`).
|
|
23
|
+
Every call runs as the user's account. Search, metadata and collections are
|
|
24
|
+
free on every account. Code, prompts and DESIGN.md files need a paid plan or
|
|
25
|
+
the account's free 3-day trial, which the first such call starts.
|
|
26
26
|
|
|
27
27
|
---
|
|
28
28
|
|
|
@@ -32,10 +32,6 @@ starts.
|
|
|
32
32
|
|
|
33
33
|
- A relevant collection exists → **Path A**. The user already did the choosing.
|
|
34
34
|
- No collection, or nothing relevant → **Path B**.
|
|
35
|
-
- It says no key is configured → **Path B**, without calling `get_collection`
|
|
36
|
-
(it needs the same key, and a guessed collection name finds nothing). Tell the
|
|
37
|
-
user once that code and DESIGN.md files will need a key — see Setup above —
|
|
38
|
-
and carry on with everything that works without one.
|
|
39
35
|
|
|
40
36
|
Several collections and no obvious match → ask. Don't guess between two curated
|
|
41
37
|
sets.
|
|
@@ -232,8 +228,8 @@ get_component_code({ id }) -> { id, name
|
|
|
232
228
|
get_component_prompt({ id }) -> { id, name, promptText } [gated: paid or trial]
|
|
233
229
|
```
|
|
234
230
|
|
|
235
|
-
The
|
|
236
|
-
|
|
231
|
+
The account rides on the connection (a key or a sign-in, sent as the
|
|
232
|
+
`Authorization` header), so no tool takes a credential argument. Every search, metadata and collection tool is free on
|
|
237
233
|
every account; `get_design_system_file`, `get_component_code` and
|
|
238
234
|
`get_component_prompt` need the account on a paid plan or inside its free
|
|
239
235
|
3-day trial.
|
|
@@ -17,130 +17,30 @@
|
|
|
17
17
|
// is open while this runs, it can overwrite our entry when it next saves.
|
|
18
18
|
// That's why install tells the user to quit Claude Code first, and why
|
|
19
19
|
// `doctor` re-checks rather than trusting that the write stuck.
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
export
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
return false;
|
|
42
|
-
}
|
|
43
|
-
export class ClaudeCodeAdapter {
|
|
44
|
-
id = 'claude-code';
|
|
45
|
-
label = 'Claude Code';
|
|
46
|
-
configPath() {
|
|
47
|
-
return join(homedir(), '.claude.json');
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { JsonConfigAdapter, SERVER_NAME } from './json-adapter.js';
|
|
22
|
+
import { home } from '../lib/paths.js';
|
|
23
|
+
import { installSkillAt } from '../lib/skill-file.js';
|
|
24
|
+
export { SERVER_NAME };
|
|
25
|
+
export class ClaudeCodeAdapter extends JsonConfigAdapter {
|
|
26
|
+
constructor() {
|
|
27
|
+
super({
|
|
28
|
+
id: 'claude-code',
|
|
29
|
+
label: 'Claude Code',
|
|
30
|
+
configPath: () => join(home(), '.claude.json'),
|
|
31
|
+
container: 'mcpServers',
|
|
32
|
+
urlField: 'url',
|
|
33
|
+
fixed: { type: 'http' },
|
|
34
|
+
detectPaths: () => [join(home(), '.claude.json'), join(home(), '.claude')],
|
|
35
|
+
detectBinaries: ['claude'],
|
|
36
|
+
skillPath: () => join(home(), '.claude', 'skills', 'noslopui', 'SKILL.md'),
|
|
37
|
+
readBackHint: 'If Claude Code is running, quit it and run this again.',
|
|
38
|
+
});
|
|
48
39
|
}
|
|
49
40
|
skillPath() {
|
|
50
|
-
return join(
|
|
51
|
-
}
|
|
52
|
-
async detect() {
|
|
53
|
-
try {
|
|
54
|
-
return onPath('claude') || existsSync(this.configPath()) || existsSync(join(homedir(), '.claude'));
|
|
55
|
-
}
|
|
56
|
-
catch {
|
|
57
|
-
return false;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
async install(entry) {
|
|
61
|
-
const path = this.configPath();
|
|
62
|
-
const config = readJsonFile(path) ?? {};
|
|
63
|
-
const backupPath = backupFile(path);
|
|
64
|
-
const servers = (typeof config.mcpServers === 'object' && config.mcpServers !== null && !Array.isArray(config.mcpServers)
|
|
65
|
-
? config.mcpServers
|
|
66
|
-
: {});
|
|
67
|
-
const replacedExisting = SERVER_NAME in servers;
|
|
68
|
-
// Spread the existing entry so anything Claude Code (or the user) added to
|
|
69
|
-
// it that we don't know about survives the update.
|
|
70
|
-
const existing = (typeof servers[SERVER_NAME] === 'object' && servers[SERVER_NAME] !== null ? servers[SERVER_NAME] : {});
|
|
71
|
-
const existingHeaders = (typeof existing.headers === 'object' && existing.headers !== null ? existing.headers : {});
|
|
72
|
-
servers[SERVER_NAME] = {
|
|
73
|
-
...existing,
|
|
74
|
-
type: 'http',
|
|
75
|
-
url: entry.url,
|
|
76
|
-
headers: { ...existingHeaders, Authorization: `Bearer ${entry.key}` },
|
|
77
|
-
};
|
|
78
|
-
config.mcpServers = servers;
|
|
79
|
-
writeJsonFile(path, config, { indent: existsSync(path) ? detectIndent(path) : 2 });
|
|
80
|
-
// Read back: this file has another writer, so a successful write is not
|
|
81
|
-
// the same as a config that says what we think it says.
|
|
82
|
-
const after = await this.status();
|
|
83
|
-
if (!after.installed || after.url !== entry.url || !after.hasKey) {
|
|
84
|
-
throw new Error(`The entry was written to ${path} but reading it back didn't show it. If Claude Code is running, quit it and run this again.`);
|
|
85
|
-
}
|
|
86
|
-
return { configPath: path, backupPath, method: 'config-file', replacedExisting };
|
|
87
|
-
}
|
|
88
|
-
async status() {
|
|
89
|
-
const path = this.configPath();
|
|
90
|
-
const config = readJsonFile(path);
|
|
91
|
-
if (!config)
|
|
92
|
-
return { installed: false, configPath: path, url: null, hasKey: false };
|
|
93
|
-
const servers = config.mcpServers;
|
|
94
|
-
const entry = servers?.[SERVER_NAME];
|
|
95
|
-
if (!entry)
|
|
96
|
-
return { installed: false, configPath: path, url: null, hasKey: false };
|
|
97
|
-
const url = typeof entry.url === 'string' ? entry.url : null;
|
|
98
|
-
const headers = (entry.headers ?? {});
|
|
99
|
-
const auth = typeof headers.Authorization === 'string' ? headers.Authorization : '';
|
|
100
|
-
const hasKey = /^Bearer\s+\S+/i.test(auth);
|
|
101
|
-
let problem;
|
|
102
|
-
if (!url)
|
|
103
|
-
problem = 'The entry has no url.';
|
|
104
|
-
else if (!hasKey)
|
|
105
|
-
problem = 'The entry has no Authorization header, so gated tools will be refused.';
|
|
106
|
-
else if (entry.type !== 'http')
|
|
107
|
-
problem = `The entry's type is "${String(entry.type)}", expected "http".`;
|
|
108
|
-
return { installed: true, configPath: path, url, hasKey, ...(problem ? { problem } : {}) };
|
|
109
|
-
}
|
|
110
|
-
async uninstall() {
|
|
111
|
-
const path = this.configPath();
|
|
112
|
-
const config = readJsonFile(path);
|
|
113
|
-
if (!config)
|
|
114
|
-
return false;
|
|
115
|
-
const servers = config.mcpServers;
|
|
116
|
-
if (!servers || !(SERVER_NAME in servers))
|
|
117
|
-
return false;
|
|
118
|
-
backupFile(path);
|
|
119
|
-
delete servers[SERVER_NAME];
|
|
120
|
-
writeJsonFile(path, config, { indent: detectIndent(path) });
|
|
121
|
-
return true;
|
|
41
|
+
return join(home(), '.claude', 'skills', 'noslopui', 'SKILL.md');
|
|
122
42
|
}
|
|
123
43
|
async installSkill(markdown) {
|
|
124
|
-
|
|
125
|
-
if (existsSync(path)) {
|
|
126
|
-
try {
|
|
127
|
-
if (readFileSync(path, 'utf8') === markdown)
|
|
128
|
-
return { path, changed: false };
|
|
129
|
-
}
|
|
130
|
-
catch {
|
|
131
|
-
// Unreadable but present: fall through and replace it.
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
135
|
-
writeFileSync(path, markdown, 'utf8');
|
|
136
|
-
return { path, changed: true };
|
|
137
|
-
}
|
|
138
|
-
async removeSkill() {
|
|
139
|
-
const path = this.skillPath();
|
|
140
|
-
if (!existsSync(path))
|
|
141
|
-
return false;
|
|
142
|
-
// Only our own folder, and only because we created it.
|
|
143
|
-
rmSync(dirname(path), { recursive: true, force: true });
|
|
144
|
-
return true;
|
|
44
|
+
return installSkillAt(this.skillPath(), markdown);
|
|
145
45
|
}
|
|
146
46
|
}
|
|
@@ -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
|
}
|