@mintlify/cli 4.0.1379 → 4.0.1380
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/__test__/indexSetup/clients.test.ts +109 -0
- package/__test__/indexSetup/configFile.test.ts +171 -0
- package/__test__/indexSetup/engine.test.ts +108 -0
- package/__test__/indexSetup/rule.test.ts +87 -0
- package/__test__/indexSetup/setup.test.ts +21 -0
- package/__test__/indexSetup/toml.test.ts +75 -0
- package/bin/cli.js +24 -0
- package/bin/indexSetup/clients.js +173 -0
- package/bin/indexSetup/configFile.js +126 -0
- package/bin/indexSetup/engine.js +92 -0
- package/bin/indexSetup/rule.js +56 -0
- package/bin/indexSetup/setup.js +94 -0
- package/bin/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +3 -2
- package/src/cli.tsx +30 -0
- package/src/indexSetup/clients.ts +206 -0
- package/src/indexSetup/configFile.ts +140 -0
- package/src/indexSetup/engine.ts +113 -0
- package/src/indexSetup/rule.ts +56 -0
- package/src/indexSetup/setup.tsx +108 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
ALL_CLIENT_NAMES,
|
|
8
|
+
CLIENTS,
|
|
9
|
+
detectClients,
|
|
10
|
+
INDEX_MCP_URL,
|
|
11
|
+
type SetupContext,
|
|
12
|
+
} from '../../src/indexSetup/clients.js';
|
|
13
|
+
|
|
14
|
+
describe('clients', () => {
|
|
15
|
+
let home: string;
|
|
16
|
+
let cwd: string;
|
|
17
|
+
let ctx: SetupContext;
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
home = await fs.mkdtemp(path.join(os.tmpdir(), 'mint-index-home-'));
|
|
21
|
+
cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'mint-index-cwd-'));
|
|
22
|
+
ctx = { home, cwd, platform: 'darwin', env: {} };
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(async () => {
|
|
26
|
+
await fs.remove(home);
|
|
27
|
+
await fs.remove(cwd);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('registers all seven clients', () => {
|
|
31
|
+
expect(ALL_CLIENT_NAMES).toEqual([
|
|
32
|
+
'claude',
|
|
33
|
+
'cursor',
|
|
34
|
+
'vscode',
|
|
35
|
+
'codex',
|
|
36
|
+
'opencode',
|
|
37
|
+
'windsurf',
|
|
38
|
+
'zed',
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('every buildEntry points at the index MCP url', () => {
|
|
43
|
+
for (const name of ALL_CLIENT_NAMES) {
|
|
44
|
+
const entry = CLIENTS[name].buildEntry();
|
|
45
|
+
expect(Object.values(entry)).toContain(INDEX_MCP_URL);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('claude global config respects CLAUDE_CONFIG_DIR', () => {
|
|
50
|
+
expect(CLIENTS.claude.globalConfigPaths(ctx)).toEqual([path.join(home, '.claude.json')]);
|
|
51
|
+
const custom = { ...ctx, env: { CLAUDE_CONFIG_DIR: '/tmp/claude-alt' } };
|
|
52
|
+
expect(CLIENTS.claude.globalConfigPaths(custom)).toEqual([
|
|
53
|
+
path.join('/tmp/claude-alt', '.claude.json'),
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('vscode paths are platform specific', () => {
|
|
58
|
+
expect(CLIENTS.vscode.globalConfigPaths(ctx)[0]).toBe(
|
|
59
|
+
path.join(home, 'Library', 'Application Support', 'Code', 'User', 'mcp.json')
|
|
60
|
+
);
|
|
61
|
+
const linux = { ...ctx, platform: 'linux' as const };
|
|
62
|
+
expect(CLIENTS.vscode.globalConfigPaths(linux)[0]).toBe(
|
|
63
|
+
path.join(home, '.config', 'Code', 'User', 'mcp.json')
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('zed paths are platform specific', () => {
|
|
68
|
+
expect(CLIENTS.zed.globalConfigPaths(ctx)[0]).toBe(
|
|
69
|
+
path.join(home, '.config', 'zed', 'settings.json')
|
|
70
|
+
);
|
|
71
|
+
const win32 = {
|
|
72
|
+
...ctx,
|
|
73
|
+
platform: 'win32' as const,
|
|
74
|
+
env: { APPDATA: 'C:\\Users\\me\\AppData\\Roaming' },
|
|
75
|
+
};
|
|
76
|
+
expect(CLIENTS.zed.globalConfigPaths(win32)[0]).toBe(
|
|
77
|
+
path.join('C:\\Users\\me\\AppData\\Roaming', 'Zed', 'settings.json')
|
|
78
|
+
);
|
|
79
|
+
const win32NoAppData = { ...ctx, platform: 'win32' as const, env: {} };
|
|
80
|
+
expect(CLIENTS.zed.globalConfigPaths(win32NoAppData)[0]).toBe(
|
|
81
|
+
path.join(home, 'AppData', 'Roaming', 'Zed', 'settings.json')
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('entry shapes match each client protocol', () => {
|
|
86
|
+
expect(CLIENTS.claude.buildEntry()).toEqual({ type: 'http', url: INDEX_MCP_URL });
|
|
87
|
+
expect(CLIENTS.cursor.buildEntry()).toEqual({ url: INDEX_MCP_URL });
|
|
88
|
+
expect(CLIENTS.vscode.buildEntry()).toEqual({ type: 'http', url: INDEX_MCP_URL });
|
|
89
|
+
expect(CLIENTS.codex.buildEntry()).toEqual({ type: 'http', url: INDEX_MCP_URL });
|
|
90
|
+
expect(CLIENTS.opencode.buildEntry()).toEqual({
|
|
91
|
+
type: 'remote',
|
|
92
|
+
url: INDEX_MCP_URL,
|
|
93
|
+
enabled: true,
|
|
94
|
+
});
|
|
95
|
+
expect(CLIENTS.windsurf.buildEntry()).toEqual({ serverUrl: INDEX_MCP_URL });
|
|
96
|
+
expect(CLIENTS.zed.buildEntry()).toEqual({ url: INDEX_MCP_URL });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('windsurf has no project config and zed has no rule', () => {
|
|
100
|
+
expect(CLIENTS.windsurf.projectConfigPaths).toBeUndefined();
|
|
101
|
+
expect(CLIENTS.zed.rule).toBeUndefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('detectClients finds only clients whose dirs exist', async () => {
|
|
105
|
+
await fs.mkdirp(path.join(home, '.cursor'));
|
|
106
|
+
await fs.mkdirp(path.join(home, '.codex'));
|
|
107
|
+
expect(await detectClients(ctx)).toEqual(['cursor', 'codex']);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
mergeServerEntry,
|
|
8
|
+
readJsonConfig,
|
|
9
|
+
resolveConfigPath,
|
|
10
|
+
writeJsonConfig,
|
|
11
|
+
writeJsonServerEntry,
|
|
12
|
+
} from '../../src/indexSetup/configFile.js';
|
|
13
|
+
|
|
14
|
+
describe('configFile', () => {
|
|
15
|
+
let dir: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'mint-index-'));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
await fs.remove(dir);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('readJsonConfig returns empty object for missing file', async () => {
|
|
26
|
+
expect(await readJsonConfig(path.join(dir, 'missing.json'))).toEqual({});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('readJsonConfig returns empty object for empty file', async () => {
|
|
30
|
+
const file = path.join(dir, 'empty.json');
|
|
31
|
+
await fs.writeFile(file, ' \n');
|
|
32
|
+
expect(await readJsonConfig(file)).toEqual({});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('readJsonConfig parses existing config', async () => {
|
|
36
|
+
const file = path.join(dir, 'config.json');
|
|
37
|
+
await fs.writeFile(file, JSON.stringify({ mcpServers: { other: { url: 'x' } } }));
|
|
38
|
+
expect(await readJsonConfig(file)).toEqual({ mcpServers: { other: { url: 'x' } } });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('readJsonConfig throws on invalid JSON', async () => {
|
|
42
|
+
const file = path.join(dir, 'bad.json');
|
|
43
|
+
await fs.writeFile(file, '{ not json');
|
|
44
|
+
await expect(readJsonConfig(file)).rejects.toThrow();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('readJsonConfig throws on non-object root', async () => {
|
|
48
|
+
const file = path.join(dir, 'array.json');
|
|
49
|
+
await fs.writeFile(file, '[1, 2]');
|
|
50
|
+
await expect(readJsonConfig(file)).rejects.toThrow();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('mergeServerEntry adds entry to empty config', () => {
|
|
54
|
+
const { config, alreadyExists } = mergeServerEntry({}, 'mcpServers', 'mintlify-index', {
|
|
55
|
+
url: 'https://index.mintlify.com/mcp',
|
|
56
|
+
});
|
|
57
|
+
expect(alreadyExists).toBe(false);
|
|
58
|
+
expect(config).toEqual({
|
|
59
|
+
mcpServers: { 'mintlify-index': { url: 'https://index.mintlify.com/mcp' } },
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('mergeServerEntry preserves sibling servers and unrelated keys', () => {
|
|
64
|
+
const existing = {
|
|
65
|
+
theme: 'dark',
|
|
66
|
+
mcpServers: { other: { url: 'x' } },
|
|
67
|
+
};
|
|
68
|
+
const { config, alreadyExists } = mergeServerEntry(existing, 'mcpServers', 'mintlify-index', {
|
|
69
|
+
url: 'y',
|
|
70
|
+
});
|
|
71
|
+
expect(alreadyExists).toBe(false);
|
|
72
|
+
expect(config).toEqual({
|
|
73
|
+
theme: 'dark',
|
|
74
|
+
mcpServers: { other: { url: 'x' }, 'mintlify-index': { url: 'y' } },
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('mergeServerEntry replaces existing entry and reports alreadyExists', () => {
|
|
79
|
+
const existing = { mcpServers: { 'mintlify-index': { url: 'old' } } };
|
|
80
|
+
const { config, alreadyExists } = mergeServerEntry(existing, 'mcpServers', 'mintlify-index', {
|
|
81
|
+
url: 'new',
|
|
82
|
+
});
|
|
83
|
+
expect(alreadyExists).toBe(true);
|
|
84
|
+
expect(config).toEqual({ mcpServers: { 'mintlify-index': { url: 'new' } } });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('mergeServerEntry recovers when config key holds a non-object', () => {
|
|
88
|
+
const { config } = mergeServerEntry({ mcpServers: 'oops' }, 'mcpServers', 'mintlify-index', {
|
|
89
|
+
url: 'y',
|
|
90
|
+
});
|
|
91
|
+
expect(config).toEqual({ mcpServers: { 'mintlify-index': { url: 'y' } } });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('writeJsonConfig creates parent dirs and ends with newline', async () => {
|
|
95
|
+
const file = path.join(dir, 'nested', 'deep', 'config.json');
|
|
96
|
+
await writeJsonConfig(file, { a: 1 });
|
|
97
|
+
const raw = await fs.readFile(file, 'utf-8');
|
|
98
|
+
expect(raw).toBe('{\n "a": 1\n}\n');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('resolveConfigPath returns first existing candidate', async () => {
|
|
102
|
+
const a = path.join(dir, 'a.json');
|
|
103
|
+
const b = path.join(dir, 'b.json');
|
|
104
|
+
await fs.writeFile(b, '{}');
|
|
105
|
+
expect(await resolveConfigPath([a, b])).toBe(b);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('resolveConfigPath falls back to first candidate when none exist', async () => {
|
|
109
|
+
const a = path.join(dir, 'a.json');
|
|
110
|
+
expect(await resolveConfigPath([a, path.join(dir, 'b.json')])).toBe(a);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('writeJsonServerEntry creates a fresh file when missing', async () => {
|
|
114
|
+
const file = path.join(dir, 'missing.json');
|
|
115
|
+
const { status } = await writeJsonServerEntry(file, 'mcpServers', 'mintlify-index', {
|
|
116
|
+
url: 'x',
|
|
117
|
+
});
|
|
118
|
+
expect(status).toBe('configured');
|
|
119
|
+
const raw = await fs.readFile(file, 'utf-8');
|
|
120
|
+
expect(raw).toBe(
|
|
121
|
+
'{\n "mcpServers": {\n "mintlify-index": {\n "url": "x"\n }\n }\n}\n'
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('writeJsonServerEntry preserves comments and trailing commas in JSONC', async () => {
|
|
126
|
+
const file = path.join(dir, 'settings.json');
|
|
127
|
+
await fs.writeFile(file, '// top comment\n{\n "theme": "dark", // inline comment\n}\n');
|
|
128
|
+
const { status } = await writeJsonServerEntry(file, 'context_servers', 'mintlify-index', {
|
|
129
|
+
url: 'x',
|
|
130
|
+
});
|
|
131
|
+
expect(status).toBe('configured');
|
|
132
|
+
const raw = await fs.readFile(file, 'utf-8');
|
|
133
|
+
expect(raw).toContain('// top comment');
|
|
134
|
+
expect(raw).toContain('// inline comment');
|
|
135
|
+
expect(raw).toContain('"theme": "dark"');
|
|
136
|
+
const parsed = await readJsonConfig(file);
|
|
137
|
+
expect(parsed).toMatchObject({
|
|
138
|
+
theme: 'dark',
|
|
139
|
+
context_servers: { 'mintlify-index': { url: 'x' } },
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('writeJsonServerEntry reports reconfigured on rerun', async () => {
|
|
144
|
+
const file = path.join(dir, 'settings.json');
|
|
145
|
+
await writeJsonServerEntry(file, 'mcpServers', 'mintlify-index', { url: 'x' });
|
|
146
|
+
const second = await writeJsonServerEntry(file, 'mcpServers', 'mintlify-index', { url: 'y' });
|
|
147
|
+
expect(second.status).toBe('reconfigured');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('writeJsonServerEntry recovers when the config key holds a non-object value', async () => {
|
|
151
|
+
const file = path.join(dir, 'settings.json');
|
|
152
|
+
await fs.writeFile(file, '// comment\n{\n "mcpServers": "oops",\n}\n');
|
|
153
|
+
const { status } = await writeJsonServerEntry(file, 'mcpServers', 'mintlify-index', {
|
|
154
|
+
url: 'x',
|
|
155
|
+
});
|
|
156
|
+
expect(status).toBe('configured');
|
|
157
|
+
const raw = await fs.readFile(file, 'utf-8');
|
|
158
|
+
expect(raw).toContain('// comment');
|
|
159
|
+
const parsed = await readJsonConfig(file);
|
|
160
|
+
expect(parsed).toEqual({ mcpServers: { 'mintlify-index': { url: 'x' } } });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('writeJsonServerEntry fails without touching the file on invalid JSON', async () => {
|
|
164
|
+
const file = path.join(dir, 'bad.json');
|
|
165
|
+
await fs.writeFile(file, '{ not json');
|
|
166
|
+
await expect(
|
|
167
|
+
writeJsonServerEntry(file, 'mcpServers', 'mintlify-index', { url: 'x' })
|
|
168
|
+
).rejects.toThrow();
|
|
169
|
+
expect(await fs.readFile(file, 'utf-8')).toBe('{ not json');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { CLIENTS, INDEX_MCP_URL, type SetupContext } from '../../src/indexSetup/clients.js';
|
|
7
|
+
import { readJsonConfig } from '../../src/indexSetup/configFile.js';
|
|
8
|
+
import { setupClient } from '../../src/indexSetup/engine.js';
|
|
9
|
+
import { RULE_MARKER } from '../../src/indexSetup/rule.js';
|
|
10
|
+
|
|
11
|
+
describe('setupClient', () => {
|
|
12
|
+
let home: string;
|
|
13
|
+
let cwd: string;
|
|
14
|
+
let ctx: SetupContext;
|
|
15
|
+
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
home = await fs.mkdtemp(path.join(os.tmpdir(), 'mint-index-home-'));
|
|
18
|
+
cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'mint-index-cwd-'));
|
|
19
|
+
ctx = { home, cwd, platform: 'darwin', env: {} };
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
await fs.remove(home);
|
|
24
|
+
await fs.remove(cwd);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('configures claude globally with entry and rule file', async () => {
|
|
28
|
+
const result = await setupClient(CLIENTS.claude, 'global', ctx);
|
|
29
|
+
expect(result.mcpStatus).toBe('configured');
|
|
30
|
+
const config = await readJsonConfig(path.join(home, '.claude.json'));
|
|
31
|
+
expect(config).toEqual({
|
|
32
|
+
mcpServers: { 'mintlify-index': { type: 'http', url: INDEX_MCP_URL } },
|
|
33
|
+
});
|
|
34
|
+
expect(result.ruleStatus).toBe('installed');
|
|
35
|
+
expect(await fs.pathExists(path.join(home, '.claude', 'rules', 'mintlify-index.md'))).toBe(
|
|
36
|
+
true
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('preserves unrelated keys in an existing claude config', async () => {
|
|
41
|
+
await fs.writeFile(
|
|
42
|
+
path.join(home, '.claude.json'),
|
|
43
|
+
JSON.stringify({ theme: 'dark', mcpServers: { other: { url: 'x' } } })
|
|
44
|
+
);
|
|
45
|
+
await setupClient(CLIENTS.claude, 'global', ctx);
|
|
46
|
+
const config = await readJsonConfig(path.join(home, '.claude.json'));
|
|
47
|
+
expect(config.theme).toBe('dark');
|
|
48
|
+
const servers = config.mcpServers;
|
|
49
|
+
expect(servers).toMatchObject({ other: { url: 'x' } });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('rerun reports reconfigured and updated', async () => {
|
|
53
|
+
await setupClient(CLIENTS.claude, 'global', ctx);
|
|
54
|
+
const second = await setupClient(CLIENTS.claude, 'global', ctx);
|
|
55
|
+
expect(second.mcpStatus).toBe('reconfigured');
|
|
56
|
+
expect(second.ruleStatus).toBe('updated');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('writes codex toml and appends AGENTS.md with markers', async () => {
|
|
60
|
+
const result = await setupClient(CLIENTS.codex, 'global', ctx);
|
|
61
|
+
expect(result.mcpStatus).toBe('configured');
|
|
62
|
+
const toml = await fs.readFile(path.join(home, '.codex', 'config.toml'), 'utf-8');
|
|
63
|
+
expect(toml).toContain('[mcp_servers.mintlify-index]');
|
|
64
|
+
expect(toml).toContain(`url = "${INDEX_MCP_URL}"`);
|
|
65
|
+
const agentsMd = await fs.readFile(path.join(home, '.codex', 'AGENTS.md'), 'utf-8');
|
|
66
|
+
expect(agentsMd).toContain(RULE_MARKER);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('project scope writes into cwd for clients that support it', async () => {
|
|
70
|
+
const result = await setupClient(CLIENTS.cursor, 'project', ctx);
|
|
71
|
+
expect(result.mcpStatus).toBe('configured');
|
|
72
|
+
expect(await fs.pathExists(path.join(cwd, '.cursor', 'mcp.json'))).toBe(true);
|
|
73
|
+
expect(await fs.pathExists(path.join(cwd, '.cursor', 'rules', 'mintlify-index.mdc'))).toBe(
|
|
74
|
+
true
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('project scope falls back to global for windsurf mcp config', async () => {
|
|
79
|
+
const result = await setupClient(CLIENTS.windsurf, 'project', ctx);
|
|
80
|
+
expect(result.mcpStatus).toBe('configured');
|
|
81
|
+
expect(result.mcpDetail).toBe(path.join(home, '.codeium', 'windsurf', 'mcp_config.json'));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('zed gets an entry under context_servers and no rule', async () => {
|
|
85
|
+
const result = await setupClient(CLIENTS.zed, 'global', ctx);
|
|
86
|
+
const config = await readJsonConfig(path.join(home, '.config', 'zed', 'settings.json'));
|
|
87
|
+
expect(config).toEqual({ context_servers: { 'mintlify-index': { url: INDEX_MCP_URL } } });
|
|
88
|
+
expect(result.ruleStatus).toBe('none');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('unparseable config fails that client and leaves the file untouched', async () => {
|
|
92
|
+
await fs.mkdirp(path.join(home, '.cursor'));
|
|
93
|
+
await fs.writeFile(path.join(home, '.cursor', 'mcp.json'), '{ broken');
|
|
94
|
+
const result = await setupClient(CLIENTS.cursor, 'global', ctx);
|
|
95
|
+
expect(result.mcpStatus).toBe('failed');
|
|
96
|
+
expect(await fs.readFile(path.join(home, '.cursor', 'mcp.json'), 'utf-8')).toBe('{ broken');
|
|
97
|
+
expect(result.ruleStatus).toBe('installed');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('a non-ENOENT read error fails the client instead of overwriting the config', async () => {
|
|
101
|
+
await fs.mkdirp(path.join(home, '.cursor', 'mcp.json'));
|
|
102
|
+
const result = await setupClient(CLIENTS.cursor, 'global', ctx);
|
|
103
|
+
expect(result.mcpStatus).toBe('failed');
|
|
104
|
+
expect(await fs.pathExists(path.join(home, '.cursor', 'mcp.json'))).toBe(true);
|
|
105
|
+
expect((await fs.stat(path.join(home, '.cursor', 'mcp.json'))).isDirectory()).toBe(true);
|
|
106
|
+
expect(result.ruleStatus).toBe('installed');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
appendRuleSection,
|
|
8
|
+
CURSOR_RULE,
|
|
9
|
+
RULE_BODY,
|
|
10
|
+
RULE_MARKER,
|
|
11
|
+
VSCODE_RULE,
|
|
12
|
+
writeRuleFile,
|
|
13
|
+
} from '../../src/indexSetup/rule.js';
|
|
14
|
+
|
|
15
|
+
describe('rule', () => {
|
|
16
|
+
let dir: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'mint-index-rule-'));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
await fs.remove(dir);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('frontmatter variants wrap the shared body', () => {
|
|
27
|
+
expect(CURSOR_RULE).toContain('alwaysApply: true');
|
|
28
|
+
expect(CURSOR_RULE).toContain(RULE_BODY);
|
|
29
|
+
expect(VSCODE_RULE).toContain("applyTo: '**'");
|
|
30
|
+
expect(VSCODE_RULE).toContain(RULE_BODY);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('writeRuleFile creates dirs and reports installed then updated', async () => {
|
|
34
|
+
const file = path.join(dir, 'rules', 'mintlify-index.md');
|
|
35
|
+
expect(await writeRuleFile(file, RULE_BODY)).toBe('installed');
|
|
36
|
+
expect(await writeRuleFile(file, RULE_BODY)).toBe('updated');
|
|
37
|
+
expect(await fs.readFile(file, 'utf-8')).toBe(RULE_BODY);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('appendRuleSection appends to a fresh file with markers', async () => {
|
|
41
|
+
const file = path.join(dir, 'AGENTS.md');
|
|
42
|
+
expect(await appendRuleSection(file, RULE_BODY)).toBe('installed');
|
|
43
|
+
const raw = await fs.readFile(file, 'utf-8');
|
|
44
|
+
expect(raw.startsWith(RULE_MARKER)).toBe(true);
|
|
45
|
+
expect(raw).toContain(RULE_BODY);
|
|
46
|
+
expect(raw.trimEnd().endsWith(RULE_MARKER)).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('appendRuleSection preserves existing user content', async () => {
|
|
50
|
+
const file = path.join(dir, 'AGENTS.md');
|
|
51
|
+
await fs.writeFile(file, '# My rules\n\nBe nice.\n');
|
|
52
|
+
await appendRuleSection(file, RULE_BODY);
|
|
53
|
+
const raw = await fs.readFile(file, 'utf-8');
|
|
54
|
+
expect(raw.startsWith('# My rules\n\nBe nice.\n')).toBe(true);
|
|
55
|
+
expect(raw).toContain(RULE_MARKER);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('appendRuleSection replaces its own section on rerun', async () => {
|
|
59
|
+
const file = path.join(dir, 'AGENTS.md');
|
|
60
|
+
await fs.writeFile(file, `before\n${RULE_MARKER}\nstale content\n${RULE_MARKER}\nafter\n`);
|
|
61
|
+
expect(await appendRuleSection(file, RULE_BODY)).toBe('updated');
|
|
62
|
+
const raw = await fs.readFile(file, 'utf-8');
|
|
63
|
+
expect(raw).not.toContain('stale content');
|
|
64
|
+
expect(raw).toContain(RULE_BODY);
|
|
65
|
+
expect(raw.startsWith('before\n')).toBe(true);
|
|
66
|
+
expect(raw.trimEnd().endsWith('after')).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('appendRuleSection appends a full section when only a stray marker exists', async () => {
|
|
70
|
+
const file = path.join(dir, 'AGENTS.md');
|
|
71
|
+
await fs.writeFile(file, `before\n${RULE_MARKER}\nafter\n`);
|
|
72
|
+
expect(await appendRuleSection(file, RULE_BODY)).toBe('installed');
|
|
73
|
+
const raw = await fs.readFile(file, 'utf-8');
|
|
74
|
+
expect(raw).toContain(`before\n${RULE_MARKER}\nafter\n`);
|
|
75
|
+
expect(raw).toContain(RULE_BODY);
|
|
76
|
+
const markerCount = raw.split(RULE_MARKER).length - 1;
|
|
77
|
+
expect(markerCount).toBe(3);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('appendRuleSection is idempotent', async () => {
|
|
81
|
+
const file = path.join(dir, 'AGENTS.md');
|
|
82
|
+
await appendRuleSection(file, RULE_BODY);
|
|
83
|
+
const first = await fs.readFile(file, 'utf-8');
|
|
84
|
+
await appendRuleSection(file, RULE_BODY);
|
|
85
|
+
expect(await fs.readFile(file, 'utf-8')).toBe(first);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { resolveExplicitClients } from '../../src/indexSetup/setup.js';
|
|
4
|
+
|
|
5
|
+
describe('resolveExplicitClients', () => {
|
|
6
|
+
it('returns empty for no flags', () => {
|
|
7
|
+
expect(resolveExplicitClients({})).toEqual([]);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('returns flagged clients in registry order', () => {
|
|
11
|
+
expect(resolveExplicitClients({ zed: true, claude: true, vscode: true })).toEqual([
|
|
12
|
+
'claude',
|
|
13
|
+
'vscode',
|
|
14
|
+
'zed',
|
|
15
|
+
]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('ignores scope and confirmation flags', () => {
|
|
19
|
+
expect(resolveExplicitClients({ project: true, yes: true })).toEqual([]);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { upsertTomlServer } from '../../src/indexSetup/configFile.js';
|
|
4
|
+
|
|
5
|
+
describe('upsertTomlServer', () => {
|
|
6
|
+
const entry = { type: 'http', url: 'https://index.mintlify.com/mcp' };
|
|
7
|
+
const block =
|
|
8
|
+
'[mcp_servers.mintlify-index]\ntype = "http"\nurl = "https://index.mintlify.com/mcp"\n';
|
|
9
|
+
|
|
10
|
+
it('appends to empty content', () => {
|
|
11
|
+
const result = upsertTomlServer('', 'mintlify-index', entry);
|
|
12
|
+
expect(result.alreadyExists).toBe(false);
|
|
13
|
+
expect(result.content).toBe(block);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('appends after existing content with a blank line', () => {
|
|
17
|
+
const existing = 'model = "o3"\n';
|
|
18
|
+
const result = upsertTomlServer(existing, 'mintlify-index', entry);
|
|
19
|
+
expect(result.alreadyExists).toBe(false);
|
|
20
|
+
expect(result.content).toBe(`model = "o3"\n\n${block}`);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('replaces an existing table without touching neighbors', () => {
|
|
24
|
+
const existing = `model = "o3"\n\n[mcp_servers.mintlify-index]\nurl = "old"\n\n[mcp_servers.other]\ncommand = "npx"\n`;
|
|
25
|
+
const result = upsertTomlServer(existing, 'mintlify-index', entry);
|
|
26
|
+
expect(result.alreadyExists).toBe(true);
|
|
27
|
+
expect(result.content).toContain(block);
|
|
28
|
+
expect(result.content).toContain('[mcp_servers.other]\ncommand = "npx"');
|
|
29
|
+
expect(result.content).not.toContain('url = "old"');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('replaces a table that sits at the end of the file', () => {
|
|
33
|
+
const existing = `model = "o3"\n\n[mcp_servers.mintlify-index]\nurl = "old"\n`;
|
|
34
|
+
const result = upsertTomlServer(existing, 'mintlify-index', entry);
|
|
35
|
+
expect(result.alreadyExists).toBe(true);
|
|
36
|
+
expect(result.content).toBe(`model = "o3"\n\n${block}`);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('ignores a header that appears inside a comment', () => {
|
|
40
|
+
const existing = `# [mcp_servers.mintlify-index]\nmodel = "o3"\n\n[mcp_servers.mintlify-index]\nurl = "old"\n\n[mcp_servers.other]\ncommand = "npx"\n`;
|
|
41
|
+
const result = upsertTomlServer(existing, 'mintlify-index', entry);
|
|
42
|
+
expect(result.alreadyExists).toBe(true);
|
|
43
|
+
expect(result.content).toContain('# [mcp_servers.mintlify-index]');
|
|
44
|
+
expect(result.content).toContain(block);
|
|
45
|
+
expect(result.content).toContain('[mcp_servers.other]\ncommand = "npx"');
|
|
46
|
+
expect(result.content).not.toContain('url = "old"');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('replaces an indented header without corrupting the split', () => {
|
|
50
|
+
const existing = `model = "o3"\n\n [mcp_servers.mintlify-index]\n url = "old"\n\n[mcp_servers.other]\ncommand = "npx"\n`;
|
|
51
|
+
const result = upsertTomlServer(existing, 'mintlify-index', entry);
|
|
52
|
+
expect(result.alreadyExists).toBe(true);
|
|
53
|
+
expect(result.content).toContain(block);
|
|
54
|
+
expect(result.content).toContain('[mcp_servers.other]\ncommand = "npx"');
|
|
55
|
+
expect(result.content).not.toContain('url = "old"');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('replaces a table whose header line carries a trailing comment', () => {
|
|
59
|
+
const existing = `model = "o3"\n\n[mcp_servers.mintlify-index] # managed by mint\nurl = "old"\n\n[mcp_servers.other]\ncommand = "npx"\n`;
|
|
60
|
+
const result = upsertTomlServer(existing, 'mintlify-index', entry);
|
|
61
|
+
expect(result.alreadyExists).toBe(true);
|
|
62
|
+
expect(result.content).toContain(block);
|
|
63
|
+
expect(result.content).toContain('[mcp_servers.other]\ncommand = "npx"');
|
|
64
|
+
expect(result.content).not.toContain('url = "old"');
|
|
65
|
+
const occurrences = result.content.split('[mcp_servers.mintlify-index]').length - 1;
|
|
66
|
+
expect(occurrences).toBe(1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('is idempotent across reruns', () => {
|
|
70
|
+
const once = upsertTomlServer('', 'mintlify-index', entry);
|
|
71
|
+
const twice = upsertTomlServer(once.content, 'mintlify-index', entry);
|
|
72
|
+
expect(twice.content).toBe(once.content);
|
|
73
|
+
expect(twice.alreadyExists).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
});
|
package/bin/cli.js
CHANGED
|
@@ -25,6 +25,7 @@ import { deslopHandler } from './deslop/index.js';
|
|
|
25
25
|
import { resolveExplicitFiles } from './deslop/resolveFiles.js';
|
|
26
26
|
import { formatHandler } from './format.js';
|
|
27
27
|
import { CMD_EXEC_PATH, checkPort, checkNodeVersion, autoUpgradeIfNeeded, findDocsRoot, getVersions, isAI, suppressConsoleWarnings, terminate, } from './helpers.js';
|
|
28
|
+
import { runIndexSetup } from './indexSetup/setup.js';
|
|
28
29
|
import { init } from './init.js';
|
|
29
30
|
import { getAccessToken } from './keyring.js';
|
|
30
31
|
import { login } from './login.js';
|
|
@@ -438,6 +439,29 @@ export const cli = ({ packageName = 'mint' }) => {
|
|
|
438
439
|
yield terminate(0);
|
|
439
440
|
}))
|
|
440
441
|
.demandCommand(1, 'specify a subcommand: set, get, or clear'))
|
|
442
|
+
.command('index', 'Install the Mintlify index MCP server into your coding agents', (yargs) => yargs
|
|
443
|
+
.option('claude', { type: 'boolean', description: 'Set up Claude Code' })
|
|
444
|
+
.option('cursor', { type: 'boolean', description: 'Set up Cursor' })
|
|
445
|
+
.option('vscode', { type: 'boolean', description: 'Set up VS Code' })
|
|
446
|
+
.option('codex', { type: 'boolean', description: 'Set up Codex' })
|
|
447
|
+
.option('opencode', { type: 'boolean', description: 'Set up OpenCode' })
|
|
448
|
+
.option('windsurf', { type: 'boolean', description: 'Set up Windsurf' })
|
|
449
|
+
.option('zed', { type: 'boolean', description: 'Set up Zed' })
|
|
450
|
+
.option('project', {
|
|
451
|
+
type: 'boolean',
|
|
452
|
+
description: 'Write project-level config instead of global',
|
|
453
|
+
})
|
|
454
|
+
.option('yes', {
|
|
455
|
+
alias: 'y',
|
|
456
|
+
type: 'boolean',
|
|
457
|
+
description: 'Skip prompts and set up all detected agents',
|
|
458
|
+
})
|
|
459
|
+
.usage('usage: mintlify index [options]')
|
|
460
|
+
.example('mintlify index', 'interactive picker')
|
|
461
|
+
.example('mintlify index --claude --cursor', 'set up specific agents'), (argv) => __awaiter(void 0, void 0, void 0, function* () {
|
|
462
|
+
const code = yield runIndexSetup(argv);
|
|
463
|
+
yield terminate(code);
|
|
464
|
+
}))
|
|
441
465
|
.command('update', 'Update the CLI to the latest version', () => undefined, () => __awaiter(void 0, void 0, void 0, function* () {
|
|
442
466
|
yield update({ packageName });
|
|
443
467
|
yield terminate(0);
|