@evantahler/mcpx 0.16.4 → 0.17.1
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/.claude/skills/mcpx.md +31 -0
- package/.cursor/rules/mcpx.mdc +26 -1
- package/README.md +86 -0
- package/package.json +1 -1
- package/src/cli.ts +18 -0
- package/src/commands/allow.ts +163 -0
- package/src/commands/check-update.ts +70 -0
- package/src/commands/deny.ts +134 -0
- package/src/commands/upgrade.ts +226 -0
- package/src/config/loader.ts +1 -4
- package/src/constants.ts +9 -0
- package/src/lib/client-settings.ts +210 -0
- package/src/update/background.ts +76 -0
- package/src/update/cache.ts +37 -0
- package/src/update/checker.ts +122 -0
package/.claude/skills/mcpx.md
CHANGED
|
@@ -8,6 +8,26 @@ trigger: when the user wants to interact with external services, APIs, or MCP to
|
|
|
8
8
|
|
|
9
9
|
You have access to external tools via `mcpx`. Use this workflow:
|
|
10
10
|
|
|
11
|
+
## 0. Self-authorize (if needed)
|
|
12
|
+
|
|
13
|
+
By default, Claude Code prompts the user for every `mcpx exec` call. If the user has approved `Bash(mcpx allow:*)` (you cannot do this yourself — the user must approve it when prompted), you can grant yourself granular permissions before executing:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
mcpx allow <server> <tool>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
This writes a specific pattern to `.claude/settings.local.json`. After that, `mcpx exec <server> <tool>` runs without prompting.
|
|
20
|
+
|
|
21
|
+
For broader access:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
mcpx allow <server> # all tools on a server
|
|
25
|
+
mcpx allow --all-read # search, info, list, servers, ping, etc.
|
|
26
|
+
mcpx allow --all # all mcpx exec calls
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**Important:** If the user has not approved `Bash(mcpx allow:*)`, skip this step — Claude Code will prompt the user directly for each `mcpx exec` call instead.
|
|
30
|
+
|
|
11
31
|
## 1. Search for tools
|
|
12
32
|
|
|
13
33
|
```bash
|
|
@@ -152,6 +172,17 @@ mcpx deauth <server> # remove stored auth
|
|
|
152
172
|
| `mcpx task get <server> <taskId>` | Get task status |
|
|
153
173
|
| `mcpx task result <server> <taskId>` | Retrieve completed task result |
|
|
154
174
|
| `mcpx task cancel <server> <taskId>` | Cancel a running task |
|
|
175
|
+
| `mcpx allow <server>` | Allow agent to exec all tools on a server |
|
|
176
|
+
| `mcpx allow <server> <tools...>` | Allow specific tools only |
|
|
177
|
+
| `mcpx allow --all` | Allow all mcpx exec calls |
|
|
178
|
+
| `mcpx allow --all-read` | Allow read-only commands |
|
|
179
|
+
| `mcpx allow --list` | Show current mcpx permissions |
|
|
180
|
+
| `mcpx allow --cursor <server>` | Target Cursor instead of Claude |
|
|
181
|
+
| `mcpx deny <server>` | Remove server permissions |
|
|
182
|
+
| `mcpx deny --all` | Remove all mcpx permissions |
|
|
183
|
+
| `mcpx deny --cursor <server>` | Target Cursor instead of Claude |
|
|
184
|
+
| `mcpx check-update` | Check for a newer version of mcpx |
|
|
185
|
+
| `mcpx upgrade` | Upgrade mcpx to the latest version|
|
|
155
186
|
|
|
156
187
|
## Global flags
|
|
157
188
|
|
package/.cursor/rules/mcpx.mdc
CHANGED
|
@@ -101,6 +101,22 @@ mcpx task list my-server
|
|
|
101
101
|
|
|
102
102
|
Some servers request user input mid-operation. mcpx handles this automatically in interactive mode. Use `-N` / `--no-interactive` to decline all elicitation (for scripts/CI), or `--json` to handle elicitation programmatically via stdin/stdout.
|
|
103
103
|
|
|
104
|
+
## 6. Self-authorize (if needed)
|
|
105
|
+
|
|
106
|
+
Cursor prompts you for every `mcpx exec` call. You can grant yourself granular permissions:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
mcpx allow <server> --cursor # all tools on a server
|
|
110
|
+
mcpx allow <server> <tool> --cursor # specific tool
|
|
111
|
+
mcpx allow --all-read --cursor # search, info, list, etc.
|
|
112
|
+
mcpx allow --all --cursor # all mcpx exec calls
|
|
113
|
+
mcpx allow --list --cursor # show current permissions
|
|
114
|
+
mcpx deny <server> --cursor # revoke server permissions
|
|
115
|
+
mcpx deny --all --cursor # revoke all permissions
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
This writes `Shell(mcpx exec:server:*)` patterns to `.cursor/cli.json`.
|
|
119
|
+
|
|
104
120
|
## Authentication
|
|
105
121
|
|
|
106
122
|
```bash
|
|
@@ -132,7 +148,7 @@ mcpx deauth <server> # remove stored auth
|
|
|
132
148
|
| `mcpx index -i` | Show index status |
|
|
133
149
|
| `mcpx auth <server>` | Authenticate with OAuth |
|
|
134
150
|
| `mcpx auth <server> -s` | Check token status and TTL |
|
|
135
|
-
| `mcpx auth <server> -r`
|
|
151
|
+
| `mcpx auth <server> -r` | Force token refresh |
|
|
136
152
|
| `mcpx auth <server> --no-index` | Authenticate without rebuilding index |
|
|
137
153
|
| `mcpx deauth <server>` | Remove stored authentication |
|
|
138
154
|
| `mcpx ping` | Check connectivity to all servers |
|
|
@@ -152,6 +168,15 @@ mcpx deauth <server> # remove stored auth
|
|
|
152
168
|
| `mcpx task get <server> <taskId>` | Get task status |
|
|
153
169
|
| `mcpx task result <server> <taskId>` | Retrieve completed task result |
|
|
154
170
|
| `mcpx task cancel <server> <taskId>` | Cancel a running task |
|
|
171
|
+
| `mcpx allow <server> --cursor` | Allow exec all tools on a server |
|
|
172
|
+
| `mcpx allow <server> <tools...> --cursor` | Allow specific tools only |
|
|
173
|
+
| `mcpx allow --all --cursor` | Allow all mcpx exec calls |
|
|
174
|
+
| `mcpx allow --all-read --cursor` | Allow read-only commands |
|
|
175
|
+
| `mcpx allow --list --cursor` | Show current permissions |
|
|
176
|
+
| `mcpx deny <server> --cursor` | Remove server permissions |
|
|
177
|
+
| `mcpx deny --all --cursor` | Remove all mcpx permissions |
|
|
178
|
+
| `mcpx check-update` | Check for a newer version of mcpx |
|
|
179
|
+
| `mcpx upgrade` | Upgrade mcpx to the latest version|
|
|
155
180
|
|
|
156
181
|
## Global flags
|
|
157
182
|
|
package/README.md
CHANGED
|
@@ -104,6 +104,16 @@ mcpx search -n 5 "manage pull requests"
|
|
|
104
104
|
| `mcpx task get <server> <taskId>` | Get task status |
|
|
105
105
|
| `mcpx task result <server> <taskId>` | Retrieve completed task result |
|
|
106
106
|
| `mcpx task cancel <server> <taskId>` | Cancel a running task |
|
|
107
|
+
| `mcpx allow <server>` | Allow an agent to exec all tools on a server |
|
|
108
|
+
| `mcpx allow <server> <tools...>` | Allow specific tools only |
|
|
109
|
+
| `mcpx allow --all` | Allow all mcpx exec calls |
|
|
110
|
+
| `mcpx allow --all-read` | Allow read-only commands (search, info, list, etc.) |
|
|
111
|
+
| `mcpx allow --list` | Show current mcpx-related permissions |
|
|
112
|
+
| `mcpx allow --cursor <server>` | Allow for Cursor instead of Claude Code |
|
|
113
|
+
| `mcpx deny <server>` | Remove permissions for a server |
|
|
114
|
+
| `mcpx deny --all` | Remove all mcpx-related permissions |
|
|
115
|
+
| `mcpx check-update` | Check for a newer version of mcpx |
|
|
116
|
+
| `mcpx upgrade` | Upgrade mcpx to the latest version |
|
|
107
117
|
|
|
108
118
|
## Options
|
|
109
119
|
|
|
@@ -625,6 +635,82 @@ To execute tools:
|
|
|
625
635
|
Always search before executing — don't assume tool names.
|
|
626
636
|
```
|
|
627
637
|
|
|
638
|
+
## Permissions (Claude Code & Cursor)
|
|
639
|
+
|
|
640
|
+
AI agents like Claude Code and Cursor prompt users to approve each `mcpx exec` call. `mcpx allow` and `mcpx deny` manage fine-grained permission rules so agents can self-authorize specific tools without broad access.
|
|
641
|
+
|
|
642
|
+
**Key insight:** If the user allows the initial permission pattern once (safe — it only writes to local settings files), the agent can then grant itself access to specific tools as needed. This is an opt-in workflow — by default, agents cannot self-authorize and will prompt the user for each `mcpx exec` call.
|
|
643
|
+
|
|
644
|
+
```bash
|
|
645
|
+
# Allow all tools on a server (Claude Code, default)
|
|
646
|
+
mcpx allow github
|
|
647
|
+
|
|
648
|
+
# Allow for Cursor instead
|
|
649
|
+
mcpx allow github --cursor
|
|
650
|
+
|
|
651
|
+
# Allow specific tools only
|
|
652
|
+
mcpx allow github search_repositories get_file
|
|
653
|
+
|
|
654
|
+
# Allow read-only commands (search, info, list, servers, ping, etc.)
|
|
655
|
+
mcpx allow --all-read
|
|
656
|
+
|
|
657
|
+
# Allow all mcpx exec calls
|
|
658
|
+
mcpx allow --all
|
|
659
|
+
|
|
660
|
+
# Show current permissions across all scopes
|
|
661
|
+
mcpx allow --list
|
|
662
|
+
mcpx allow --list --cursor
|
|
663
|
+
|
|
664
|
+
# Preview what would be written
|
|
665
|
+
mcpx allow github --dry-run
|
|
666
|
+
|
|
667
|
+
# Revoke a server's permissions
|
|
668
|
+
mcpx deny github
|
|
669
|
+
|
|
670
|
+
# Revoke all mcpx permissions
|
|
671
|
+
mcpx deny --all
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
**Target flag** — by default, permissions target Claude Code. Use `--cursor` to target Cursor instead:
|
|
675
|
+
|
|
676
|
+
| Flag | Pattern prefix | Settings files |
|
|
677
|
+
| ----------- | -------------- | ----------------------------------------------- |
|
|
678
|
+
| _(default)_ | `Bash(…)` | `.claude/settings.local.json`, etc. |
|
|
679
|
+
| `--cursor` | `Shell(…)` | `.cursor/cli.json`, `~/.cursor/cli-config.json` |
|
|
680
|
+
|
|
681
|
+
**Scope flags** control where the permission is written:
|
|
682
|
+
|
|
683
|
+
| Flag | Claude Code file | Cursor file | Default |
|
|
684
|
+
| ----------- | ----------------------------- | --------------------------- | ------- |
|
|
685
|
+
| `--local` | `.claude/settings.local.json` | `.cursor/cli.json` | ✓ |
|
|
686
|
+
| `--project` | `.claude/settings.json` | `.cursor/cli.json` | |
|
|
687
|
+
| `--global` | `~/.claude/settings.json` | `~/.cursor/cli-config.json` | |
|
|
688
|
+
|
|
689
|
+
**`allow` options:**
|
|
690
|
+
|
|
691
|
+
| Flag | Purpose |
|
|
692
|
+
| ------------ | --------------------------------------------------- |
|
|
693
|
+
| `--all` | Allow all mcpx exec calls |
|
|
694
|
+
| `--all-read` | Allow read-only commands (search, info, list, etc.) |
|
|
695
|
+
| `--list` | Show current mcpx-related permissions |
|
|
696
|
+
| `--cursor` | Target Cursor settings instead of Claude Code |
|
|
697
|
+
| `--local` | Write to local settings (default) |
|
|
698
|
+
| `--project` | Write to project settings (shared) |
|
|
699
|
+
| `--global` | Write to global settings |
|
|
700
|
+
| `--dry-run` | Show patterns without writing |
|
|
701
|
+
|
|
702
|
+
**`deny` options:**
|
|
703
|
+
|
|
704
|
+
| Flag | Purpose |
|
|
705
|
+
| ------------ | --------------------------------------------- |
|
|
706
|
+
| `--all` | Remove all mcpx-related permissions |
|
|
707
|
+
| `--all-read` | Remove read-only command permissions |
|
|
708
|
+
| `--cursor` | Target Cursor settings instead of Claude Code |
|
|
709
|
+
| `--local` | Write to local settings (default) |
|
|
710
|
+
| `--project` | Write to project settings (shared) |
|
|
711
|
+
| `--global` | Write to global settings |
|
|
712
|
+
| `--dry-run` | Show what would be removed |
|
|
713
|
+
|
|
628
714
|
## Development
|
|
629
715
|
|
|
630
716
|
```bash
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -15,6 +15,11 @@ import { registerResourceCommand } from "./commands/resource.ts";
|
|
|
15
15
|
import { registerPromptCommand } from "./commands/prompt.ts";
|
|
16
16
|
import { registerServersCommand } from "./commands/servers.ts";
|
|
17
17
|
import { registerTaskCommand } from "./commands/task.ts";
|
|
18
|
+
import { registerAllowCommand } from "./commands/allow.ts";
|
|
19
|
+
import { registerDenyCommand } from "./commands/deny.ts";
|
|
20
|
+
import { registerCheckUpdateCommand } from "./commands/check-update.ts";
|
|
21
|
+
import { registerUpgradeCommand } from "./commands/upgrade.ts";
|
|
22
|
+
import { maybeCheckForUpdate } from "./update/background.ts";
|
|
18
23
|
|
|
19
24
|
import pkg from "../package.json";
|
|
20
25
|
|
|
@@ -50,6 +55,10 @@ registerResourceCommand(program);
|
|
|
50
55
|
registerPromptCommand(program);
|
|
51
56
|
registerServersCommand(program);
|
|
52
57
|
registerTaskCommand(program);
|
|
58
|
+
registerAllowCommand(program);
|
|
59
|
+
registerDenyCommand(program);
|
|
60
|
+
registerCheckUpdateCommand(program);
|
|
61
|
+
registerUpgradeCommand(program);
|
|
53
62
|
|
|
54
63
|
// Detect unknown subcommands before commander misreports them as "too many arguments"
|
|
55
64
|
const knownCommands = new Set(program.commands.map((c) => c.name()));
|
|
@@ -77,4 +86,13 @@ if (firstCommand && !knownCommands.has(firstCommand)) {
|
|
|
77
86
|
process.exit(1);
|
|
78
87
|
}
|
|
79
88
|
|
|
89
|
+
// Fire-and-forget background update check
|
|
90
|
+
const updateNotice = maybeCheckForUpdate();
|
|
91
|
+
|
|
80
92
|
program.parse();
|
|
93
|
+
|
|
94
|
+
// Print update notice after command output completes
|
|
95
|
+
process.on("beforeExit", async () => {
|
|
96
|
+
const notice = await updateNotice;
|
|
97
|
+
if (notice) process.stderr.write(notice);
|
|
98
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { bold, cyan, dim, green, yellow } from "ansis";
|
|
3
|
+
import {
|
|
4
|
+
type Client,
|
|
5
|
+
type Scope,
|
|
6
|
+
resolveSettingsPath,
|
|
7
|
+
readClientSettings,
|
|
8
|
+
writeClientSettings,
|
|
9
|
+
execPattern,
|
|
10
|
+
readOnlyPatterns,
|
|
11
|
+
allExecPattern,
|
|
12
|
+
allowCommandPattern,
|
|
13
|
+
denyCommandPattern,
|
|
14
|
+
addPatterns,
|
|
15
|
+
getMcpxPatterns,
|
|
16
|
+
} from "../lib/client-settings.ts";
|
|
17
|
+
import { formatOutput } from "../output/format-output.ts";
|
|
18
|
+
import type { FormatOptions } from "../output/formatter.ts";
|
|
19
|
+
|
|
20
|
+
export function registerAllowCommand(program: Command) {
|
|
21
|
+
program
|
|
22
|
+
.command("allow")
|
|
23
|
+
.description("add permission rules for mcpx commands (Claude Code or Cursor)")
|
|
24
|
+
.argument("[server]", "server name to allow")
|
|
25
|
+
.argument("[tools...]", "specific tool names to allow")
|
|
26
|
+
.option("--all", "allow all mcpx exec calls")
|
|
27
|
+
.option("--all-read", "allow read-only commands (search, info, list, servers, ping, etc.)")
|
|
28
|
+
.option("--list", "show current mcpx-related permissions")
|
|
29
|
+
.option("--cursor", "target Cursor settings instead of Claude Code")
|
|
30
|
+
.option("--local", "write to local settings (default)")
|
|
31
|
+
.option("--project", "write to project settings (shared)")
|
|
32
|
+
.option("--global", "write to global settings")
|
|
33
|
+
.option("--dry-run", "show patterns without writing")
|
|
34
|
+
.action(
|
|
35
|
+
async (
|
|
36
|
+
server: string | undefined,
|
|
37
|
+
tools: string[],
|
|
38
|
+
options: {
|
|
39
|
+
all?: boolean;
|
|
40
|
+
allRead?: boolean;
|
|
41
|
+
list?: boolean;
|
|
42
|
+
cursor?: boolean;
|
|
43
|
+
local?: boolean;
|
|
44
|
+
project?: boolean;
|
|
45
|
+
global?: boolean;
|
|
46
|
+
dryRun?: boolean;
|
|
47
|
+
},
|
|
48
|
+
) => {
|
|
49
|
+
const formatOptions: FormatOptions = { json: program.opts().json };
|
|
50
|
+
const client: Client = options.cursor ? "cursor" : "claude";
|
|
51
|
+
|
|
52
|
+
// --list mode: show current permissions across all scopes
|
|
53
|
+
if (options.list) {
|
|
54
|
+
// Cursor maps local and project to the same file, so only show unique scopes
|
|
55
|
+
const scopes: Scope[] =
|
|
56
|
+
client === "cursor" ? ["local", "global"] : ["local", "project", "global"];
|
|
57
|
+
const results: { scope: Scope; path: string; patterns: string[] }[] = [];
|
|
58
|
+
|
|
59
|
+
for (const scope of scopes) {
|
|
60
|
+
const path = resolveSettingsPath(scope, client);
|
|
61
|
+
const settings = await readClientSettings(path);
|
|
62
|
+
const patterns = getMcpxPatterns(settings, client);
|
|
63
|
+
results.push({ scope, path, patterns });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log(
|
|
67
|
+
formatOutput(
|
|
68
|
+
results.map((r) => ({ scope: r.scope, path: r.path, patterns: r.patterns })),
|
|
69
|
+
() => {
|
|
70
|
+
const lines: string[] = [];
|
|
71
|
+
for (const r of results) {
|
|
72
|
+
lines.push(bold(`${r.scope}`) + dim(` (${r.path})`));
|
|
73
|
+
if (r.patterns.length === 0) {
|
|
74
|
+
lines.push(` ${dim("(none)")}`);
|
|
75
|
+
} else {
|
|
76
|
+
for (const p of r.patterns) {
|
|
77
|
+
lines.push(` ${green("✓")} ${p}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
lines.push("");
|
|
81
|
+
}
|
|
82
|
+
return lines.join("\n").trimEnd();
|
|
83
|
+
},
|
|
84
|
+
formatOptions,
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Build the list of patterns to add
|
|
91
|
+
const patterns: string[] = [];
|
|
92
|
+
|
|
93
|
+
if (options.all) {
|
|
94
|
+
patterns.push(allExecPattern(client));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (options.allRead) {
|
|
98
|
+
patterns.push(...readOnlyPatterns(client));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (server && tools.length > 0) {
|
|
102
|
+
for (const tool of tools) {
|
|
103
|
+
patterns.push(execPattern(server, tool, client));
|
|
104
|
+
}
|
|
105
|
+
} else if (server) {
|
|
106
|
+
patterns.push(execPattern(server, undefined, client));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (patterns.length === 0) {
|
|
110
|
+
console.error("error: specify a server, --all, or --all-read. See 'mcpx allow --help'.");
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Always include allow/deny command patterns so the agent can self-manage
|
|
115
|
+
patterns.push(allowCommandPattern(client));
|
|
116
|
+
patterns.push(denyCommandPattern(client));
|
|
117
|
+
|
|
118
|
+
const scope: Scope = options.global ? "global" : options.project ? "project" : "local";
|
|
119
|
+
const path = resolveSettingsPath(scope, client);
|
|
120
|
+
|
|
121
|
+
if (options.dryRun) {
|
|
122
|
+
console.log(
|
|
123
|
+
formatOutput(
|
|
124
|
+
{ scope, path, patterns },
|
|
125
|
+
() => {
|
|
126
|
+
const lines: string[] = [];
|
|
127
|
+
lines.push(bold("Dry run") + dim(` — would write to ${path}:`));
|
|
128
|
+
for (const p of patterns) {
|
|
129
|
+
lines.push(` ${yellow("+")} ${p}`);
|
|
130
|
+
}
|
|
131
|
+
return lines.join("\n");
|
|
132
|
+
},
|
|
133
|
+
formatOptions,
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const settings = await readClientSettings(path);
|
|
140
|
+
const { settings: updated, added } = addPatterns(settings, patterns);
|
|
141
|
+
await writeClientSettings(path, updated);
|
|
142
|
+
|
|
143
|
+
console.log(
|
|
144
|
+
formatOutput(
|
|
145
|
+
{ scope, path, added, total: (updated.permissions?.allow ?? []).length },
|
|
146
|
+
() => {
|
|
147
|
+
const lines: string[] = [];
|
|
148
|
+
if (added.length === 0) {
|
|
149
|
+
lines.push(dim("All patterns already present — no changes."));
|
|
150
|
+
} else {
|
|
151
|
+
lines.push(bold(`Added ${added.length} permission(s)`) + dim(` → ${path}`));
|
|
152
|
+
for (const p of added) {
|
|
153
|
+
lines.push(` ${green("+")} ${p}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return lines.join("\n");
|
|
157
|
+
},
|
|
158
|
+
formatOptions,
|
|
159
|
+
),
|
|
160
|
+
);
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { green, yellow, cyan, dim } from "ansis";
|
|
2
|
+
import type { Command } from "commander";
|
|
3
|
+
import { createSpinner } from "nanospinner";
|
|
4
|
+
import pkg from "../../package.json";
|
|
5
|
+
import { checkForUpdate } from "../update/checker.ts";
|
|
6
|
+
import { saveUpdateCache } from "../update/cache.ts";
|
|
7
|
+
import type { UpdateCache } from "../update/checker.ts";
|
|
8
|
+
|
|
9
|
+
export function registerCheckUpdateCommand(program: Command) {
|
|
10
|
+
program
|
|
11
|
+
.command("check-update")
|
|
12
|
+
.description("Check for a newer version of mcpx")
|
|
13
|
+
.action(async () => {
|
|
14
|
+
const opts = program.opts();
|
|
15
|
+
const json = !!(opts.json as boolean | undefined);
|
|
16
|
+
const isTTY = process.stderr.isTTY ?? false;
|
|
17
|
+
|
|
18
|
+
const spinner =
|
|
19
|
+
!json && isTTY
|
|
20
|
+
? createSpinner("Checking for updates...", { stream: process.stderr }).start()
|
|
21
|
+
: null;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const info = await checkForUpdate(pkg.version);
|
|
25
|
+
|
|
26
|
+
// Save to cache
|
|
27
|
+
const cache: UpdateCache = {
|
|
28
|
+
lastCheckAt: new Date().toISOString(),
|
|
29
|
+
latestVersion: info.latestVersion,
|
|
30
|
+
hasUpdate: info.hasUpdate,
|
|
31
|
+
changelog: info.changelog,
|
|
32
|
+
};
|
|
33
|
+
await saveUpdateCache(cache);
|
|
34
|
+
|
|
35
|
+
spinner?.stop();
|
|
36
|
+
|
|
37
|
+
if (json) {
|
|
38
|
+
console.log(JSON.stringify(info, null, 2));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!info.hasUpdate) {
|
|
43
|
+
if (info.aheadOfLatest) {
|
|
44
|
+
console.log(
|
|
45
|
+
yellow(
|
|
46
|
+
`mcpx v${info.currentVersion} is ahead of latest published release (v${info.latestVersion})`,
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
} else {
|
|
50
|
+
console.log(green(`mcpx is up to date (v${info.currentVersion})`));
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log(yellow(`Update available: ${info.currentVersion} → ${info.latestVersion}`));
|
|
56
|
+
|
|
57
|
+
if (info.changelog) {
|
|
58
|
+
console.log("");
|
|
59
|
+
console.log(dim(info.changelog));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.log("");
|
|
63
|
+
console.log(cyan(`Run \`mcpx upgrade\` to update`));
|
|
64
|
+
} catch (err) {
|
|
65
|
+
spinner?.error({ text: "Failed to check for updates" });
|
|
66
|
+
console.error(String(err));
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { bold, dim, green, red, yellow } from "ansis";
|
|
3
|
+
import {
|
|
4
|
+
type Client,
|
|
5
|
+
type Scope,
|
|
6
|
+
resolveSettingsPath,
|
|
7
|
+
readClientSettings,
|
|
8
|
+
writeClientSettings,
|
|
9
|
+
execPattern,
|
|
10
|
+
readOnlyPatterns,
|
|
11
|
+
allExecPattern,
|
|
12
|
+
removePatterns,
|
|
13
|
+
removeAllMcpxPatterns,
|
|
14
|
+
getServerPatterns,
|
|
15
|
+
} from "../lib/client-settings.ts";
|
|
16
|
+
import { formatOutput } from "../output/format-output.ts";
|
|
17
|
+
import type { FormatOptions } from "../output/formatter.ts";
|
|
18
|
+
|
|
19
|
+
export function registerDenyCommand(program: Command) {
|
|
20
|
+
program
|
|
21
|
+
.command("deny")
|
|
22
|
+
.description("remove permission rules for mcpx commands (Claude Code or Cursor)")
|
|
23
|
+
.argument("[server]", "server name to deny")
|
|
24
|
+
.argument("[tools...]", "specific tool names to deny")
|
|
25
|
+
.option("--all", "remove all mcpx-related permissions")
|
|
26
|
+
.option("--all-read", "remove read-only command permissions")
|
|
27
|
+
.option("--cursor", "target Cursor settings instead of Claude Code")
|
|
28
|
+
.option("--local", "write to local settings (default)")
|
|
29
|
+
.option("--project", "write to project settings (shared)")
|
|
30
|
+
.option("--global", "write to global settings")
|
|
31
|
+
.option("--dry-run", "show what would be removed")
|
|
32
|
+
.action(
|
|
33
|
+
async (
|
|
34
|
+
server: string | undefined,
|
|
35
|
+
tools: string[],
|
|
36
|
+
options: {
|
|
37
|
+
all?: boolean;
|
|
38
|
+
allRead?: boolean;
|
|
39
|
+
cursor?: boolean;
|
|
40
|
+
local?: boolean;
|
|
41
|
+
project?: boolean;
|
|
42
|
+
global?: boolean;
|
|
43
|
+
dryRun?: boolean;
|
|
44
|
+
},
|
|
45
|
+
) => {
|
|
46
|
+
const formatOptions: FormatOptions = { json: program.opts().json };
|
|
47
|
+
const client: Client = options.cursor ? "cursor" : "claude";
|
|
48
|
+
const scope: Scope = options.global ? "global" : options.project ? "project" : "local";
|
|
49
|
+
const path = resolveSettingsPath(scope, client);
|
|
50
|
+
const settings = await readClientSettings(path);
|
|
51
|
+
|
|
52
|
+
let result: { settings: typeof settings; removed: string[] };
|
|
53
|
+
|
|
54
|
+
if (options.all) {
|
|
55
|
+
// Remove all mcpx-related patterns
|
|
56
|
+
result = removeAllMcpxPatterns(settings, client);
|
|
57
|
+
} else {
|
|
58
|
+
// Build the list of patterns to remove
|
|
59
|
+
const patterns: string[] = [];
|
|
60
|
+
|
|
61
|
+
if (options.allRead) {
|
|
62
|
+
patterns.push(...readOnlyPatterns(client));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (server && tools.length > 0) {
|
|
66
|
+
for (const tool of tools) {
|
|
67
|
+
patterns.push(execPattern(server, tool, client));
|
|
68
|
+
}
|
|
69
|
+
} else if (server) {
|
|
70
|
+
// Remove the server-level pattern AND all tool-specific patterns for this server
|
|
71
|
+
patterns.push(execPattern(server, undefined, client));
|
|
72
|
+
patterns.push(...getServerPatterns(settings, server, client));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (patterns.length === 0) {
|
|
76
|
+
console.error("error: specify a server, --all, or --all-read. See 'mcpx deny --help'.");
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
result = removePatterns(settings, patterns);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (options.dryRun) {
|
|
84
|
+
console.log(
|
|
85
|
+
formatOutput(
|
|
86
|
+
{ scope, path, wouldRemove: result.removed },
|
|
87
|
+
() => {
|
|
88
|
+
const lines: string[] = [];
|
|
89
|
+
lines.push(bold("Dry run") + dim(` — would remove from ${path}:`));
|
|
90
|
+
if (result.removed.length === 0) {
|
|
91
|
+
lines.push(` ${dim("(no matching patterns found)")}`);
|
|
92
|
+
} else {
|
|
93
|
+
for (const p of result.removed) {
|
|
94
|
+
lines.push(` ${yellow("-")} ${p}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return lines.join("\n");
|
|
98
|
+
},
|
|
99
|
+
formatOptions,
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
await writeClientSettings(path, result.settings);
|
|
106
|
+
|
|
107
|
+
console.log(
|
|
108
|
+
formatOutput(
|
|
109
|
+
{
|
|
110
|
+
scope,
|
|
111
|
+
path,
|
|
112
|
+
removed: result.removed,
|
|
113
|
+
total: (result.settings.permissions?.allow ?? []).length,
|
|
114
|
+
},
|
|
115
|
+
() => {
|
|
116
|
+
const lines: string[] = [];
|
|
117
|
+
if (result.removed.length === 0) {
|
|
118
|
+
lines.push(dim("No matching patterns found — no changes."));
|
|
119
|
+
} else {
|
|
120
|
+
lines.push(
|
|
121
|
+
bold(`Removed ${result.removed.length} permission(s)`) + dim(` → ${path}`),
|
|
122
|
+
);
|
|
123
|
+
for (const p of result.removed) {
|
|
124
|
+
lines.push(` ${red("-")} ${p}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return lines.join("\n");
|
|
128
|
+
},
|
|
129
|
+
formatOptions,
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { $ } from "bun";
|
|
2
|
+
import { green, yellow, red, cyan, dim } from "ansis";
|
|
3
|
+
import type { Command } from "commander";
|
|
4
|
+
import { createSpinner } from "nanospinner";
|
|
5
|
+
import { tmpdir } from "os";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
import pkg from "../../package.json";
|
|
8
|
+
import {
|
|
9
|
+
checkForUpdate,
|
|
10
|
+
detectInstallMethod,
|
|
11
|
+
needsCheck,
|
|
12
|
+
type InstallMethod,
|
|
13
|
+
} from "../update/checker.ts";
|
|
14
|
+
import { loadUpdateCache, saveUpdateCache, clearUpdateCache } from "../update/cache.ts";
|
|
15
|
+
import type { UpdateCache } from "../update/checker.ts";
|
|
16
|
+
import pkgMeta from "../../package.json";
|
|
17
|
+
|
|
18
|
+
const GITHUB_REPO = pkgMeta.repository.url
|
|
19
|
+
.replace(/^https:\/\/github\.com\//, "")
|
|
20
|
+
.replace(/\.git$/, "");
|
|
21
|
+
|
|
22
|
+
function platformArtifactName(): string {
|
|
23
|
+
let os: string;
|
|
24
|
+
let ext = "";
|
|
25
|
+
switch (process.platform) {
|
|
26
|
+
case "darwin":
|
|
27
|
+
os = "darwin";
|
|
28
|
+
break;
|
|
29
|
+
case "win32":
|
|
30
|
+
os = "windows";
|
|
31
|
+
ext = ".exe";
|
|
32
|
+
break;
|
|
33
|
+
default:
|
|
34
|
+
os = "linux";
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
const arch = process.arch === "arm64" ? "arm64" : "x64";
|
|
38
|
+
return `mcpx-${os}-${arch}${ext}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function upgradeWithPackageManager(command: string, args: string[]): Promise<boolean> {
|
|
42
|
+
const result = await $`${command} ${args}`.nothrow();
|
|
43
|
+
return result.exitCode === 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function upgradeFromBinary(latestVersion: string): Promise<boolean> {
|
|
47
|
+
const artifact = platformArtifactName();
|
|
48
|
+
const tag = `v${latestVersion}`;
|
|
49
|
+
const url = `https://github.com/${GITHUB_REPO}/releases/download/${tag}/${artifact}`;
|
|
50
|
+
|
|
51
|
+
const tmpPath = join(tmpdir(), `mcpx-upgrade-${Date.now()}`);
|
|
52
|
+
const targetPath = process.execPath;
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch(url);
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
console.error(red(`Failed to download binary: HTTP ${res.status}`));
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const bytes = await res.arrayBuffer();
|
|
62
|
+
await Bun.write(tmpPath, bytes);
|
|
63
|
+
|
|
64
|
+
await $`chmod +x ${tmpPath}`.quiet();
|
|
65
|
+
|
|
66
|
+
// Try to move into place
|
|
67
|
+
const mv = await $`mv ${tmpPath} ${targetPath}`.quiet().nothrow();
|
|
68
|
+
|
|
69
|
+
if (mv.exitCode !== 0) {
|
|
70
|
+
// Try with sudo
|
|
71
|
+
console.log(dim("Requires elevated permissions..."));
|
|
72
|
+
const sudo = await $`sudo mv ${tmpPath} ${targetPath}`.nothrow();
|
|
73
|
+
if (sudo.exitCode !== 0) {
|
|
74
|
+
console.error(red("Failed to install binary. Try running with sudo."));
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return true;
|
|
80
|
+
} catch (err) {
|
|
81
|
+
console.error(red(`Failed to upgrade binary: ${err}`));
|
|
82
|
+
// Clean up temp file
|
|
83
|
+
await $`rm -f ${tmpPath}`.quiet().nothrow();
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function registerUpgradeCommand(program: Command) {
|
|
89
|
+
program
|
|
90
|
+
.command("upgrade")
|
|
91
|
+
.description("Upgrade mcpx to the latest version")
|
|
92
|
+
.action(async () => {
|
|
93
|
+
const opts = program.opts();
|
|
94
|
+
const json = !!(opts.json as boolean | undefined);
|
|
95
|
+
const isTTY = process.stderr.isTTY ?? false;
|
|
96
|
+
|
|
97
|
+
const spinner =
|
|
98
|
+
!json && isTTY
|
|
99
|
+
? createSpinner("Checking for updates...", { stream: process.stderr }).start()
|
|
100
|
+
: null;
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
// Check for update (use cache if fresh)
|
|
104
|
+
const cache = await loadUpdateCache();
|
|
105
|
+
let latestVersion: string;
|
|
106
|
+
let hasUpdate: boolean;
|
|
107
|
+
|
|
108
|
+
if (!needsCheck(cache) && cache) {
|
|
109
|
+
latestVersion = cache.latestVersion;
|
|
110
|
+
hasUpdate = cache.hasUpdate;
|
|
111
|
+
} else {
|
|
112
|
+
const info = await checkForUpdate(pkg.version);
|
|
113
|
+
latestVersion = info.latestVersion;
|
|
114
|
+
hasUpdate = info.hasUpdate;
|
|
115
|
+
|
|
116
|
+
const newCache: UpdateCache = {
|
|
117
|
+
lastCheckAt: new Date().toISOString(),
|
|
118
|
+
latestVersion,
|
|
119
|
+
hasUpdate,
|
|
120
|
+
changelog: info.changelog,
|
|
121
|
+
};
|
|
122
|
+
await saveUpdateCache(newCache);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!hasUpdate) {
|
|
126
|
+
spinner?.stop();
|
|
127
|
+
if (json) {
|
|
128
|
+
console.log(
|
|
129
|
+
JSON.stringify({
|
|
130
|
+
upgraded: false,
|
|
131
|
+
currentVersion: pkg.version,
|
|
132
|
+
message: "Already up to date",
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
} else {
|
|
136
|
+
console.log(green(`mcpx is already up to date (v${pkg.version})`));
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const method: InstallMethod = detectInstallMethod();
|
|
142
|
+
spinner?.update({
|
|
143
|
+
text: `Upgrading from v${pkg.version} to v${latestVersion} (${method})...`,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
let success = false;
|
|
147
|
+
|
|
148
|
+
switch (method) {
|
|
149
|
+
case "bun":
|
|
150
|
+
spinner?.stop();
|
|
151
|
+
success = await upgradeWithPackageManager("bun", [
|
|
152
|
+
"install",
|
|
153
|
+
"-g",
|
|
154
|
+
`@evantahler/mcpx@${latestVersion}`,
|
|
155
|
+
]);
|
|
156
|
+
break;
|
|
157
|
+
|
|
158
|
+
case "npm":
|
|
159
|
+
spinner?.stop();
|
|
160
|
+
success = await upgradeWithPackageManager("npm", [
|
|
161
|
+
"install",
|
|
162
|
+
"-g",
|
|
163
|
+
`@evantahler/mcpx@${latestVersion}`,
|
|
164
|
+
]);
|
|
165
|
+
break;
|
|
166
|
+
|
|
167
|
+
case "binary":
|
|
168
|
+
spinner?.stop();
|
|
169
|
+
success = await upgradeFromBinary(latestVersion);
|
|
170
|
+
break;
|
|
171
|
+
|
|
172
|
+
case "local-dev":
|
|
173
|
+
spinner?.stop();
|
|
174
|
+
if (json) {
|
|
175
|
+
console.log(
|
|
176
|
+
JSON.stringify({
|
|
177
|
+
upgraded: false,
|
|
178
|
+
currentVersion: pkg.version,
|
|
179
|
+
latestVersion,
|
|
180
|
+
installMethod: "local-dev",
|
|
181
|
+
message: "Running from source. Use `git pull && bun install` to update.",
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
} else {
|
|
185
|
+
console.log(yellow("Running from source. Use `git pull && bun install` to update."));
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (success) {
|
|
191
|
+
await clearUpdateCache();
|
|
192
|
+
if (json) {
|
|
193
|
+
console.log(
|
|
194
|
+
JSON.stringify({
|
|
195
|
+
upgraded: true,
|
|
196
|
+
previousVersion: pkg.version,
|
|
197
|
+
newVersion: latestVersion,
|
|
198
|
+
installMethod: method,
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
} else {
|
|
202
|
+
console.log(green(`Successfully upgraded mcpx: v${pkg.version} → v${latestVersion}`));
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
if (json) {
|
|
206
|
+
console.log(
|
|
207
|
+
JSON.stringify({
|
|
208
|
+
upgraded: false,
|
|
209
|
+
currentVersion: pkg.version,
|
|
210
|
+
latestVersion,
|
|
211
|
+
installMethod: method,
|
|
212
|
+
message: "Upgrade failed",
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
} else {
|
|
216
|
+
console.error(red("Upgrade failed. See errors above."));
|
|
217
|
+
}
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
} catch (err) {
|
|
221
|
+
spinner?.error({ text: "Upgrade failed" });
|
|
222
|
+
console.error(String(err));
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}
|
package/src/config/loader.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { join, resolve } from "path";
|
|
2
|
-
import { homedir } from "os";
|
|
3
2
|
import { interpolateEnv } from "./env.ts";
|
|
4
|
-
import { ENV } from "../constants.ts";
|
|
3
|
+
import { DEFAULT_CONFIG_DIR, ENV } from "../constants.ts";
|
|
5
4
|
import {
|
|
6
5
|
type Config,
|
|
7
6
|
type ServersFile,
|
|
@@ -12,8 +11,6 @@ import {
|
|
|
12
11
|
validateSearchIndex,
|
|
13
12
|
} from "./schemas.ts";
|
|
14
13
|
|
|
15
|
-
const DEFAULT_CONFIG_DIR = join(homedir(), ".mcpx");
|
|
16
|
-
|
|
17
14
|
const EMPTY_SERVERS: ServersFile = { mcpServers: {} };
|
|
18
15
|
const EMPTY_AUTH: AuthFile = {};
|
|
19
16
|
const EMPTY_SEARCH_INDEX: SearchIndex = {
|
package/src/constants.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
|
|
4
|
+
/** Default config directory (~/.mcpx) */
|
|
5
|
+
export const DEFAULT_CONFIG_DIR = join(homedir(), ".mcpx");
|
|
6
|
+
|
|
1
7
|
/** Environment variable names used by mcpx */
|
|
2
8
|
export const ENV = {
|
|
3
9
|
DEBUG: "MCP_DEBUG",
|
|
@@ -6,6 +12,7 @@ export const ENV = {
|
|
|
6
12
|
MAX_RETRIES: "MCP_MAX_RETRIES",
|
|
7
13
|
STRICT_ENV: "MCP_STRICT_ENV",
|
|
8
14
|
CONFIG_PATH: "MCP_CONFIG_PATH",
|
|
15
|
+
NO_UPDATE_CHECK: "MCPX_NO_UPDATE_CHECK",
|
|
9
16
|
} as const;
|
|
10
17
|
|
|
11
18
|
/** Default values for configurable options */
|
|
@@ -16,4 +23,6 @@ export const DEFAULTS = {
|
|
|
16
23
|
TASK_TTL_MS: 60_000,
|
|
17
24
|
SEARCH_TOP_K: 10,
|
|
18
25
|
LOG_LEVEL: "warning",
|
|
26
|
+
UPDATE_CHECK_INTERVAL_MS: 24 * 60 * 60 * 1000,
|
|
27
|
+
UPDATE_CHECK_TIMEOUT_MS: 5_000,
|
|
19
28
|
} as const;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { readFile, mkdir, writeFile } from "fs/promises";
|
|
4
|
+
|
|
5
|
+
export type Client = "claude" | "cursor";
|
|
6
|
+
export type Scope = "local" | "project" | "global";
|
|
7
|
+
|
|
8
|
+
export interface ClientSettings {
|
|
9
|
+
permissions?: {
|
|
10
|
+
allow?: string[];
|
|
11
|
+
deny?: string[];
|
|
12
|
+
};
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function prefix(client: Client): string {
|
|
17
|
+
return client === "claude" ? "Bash" : "Shell";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Resolve the settings file path for a given scope and client */
|
|
21
|
+
export function resolveSettingsPath(scope: Scope, client: Client = "claude"): string {
|
|
22
|
+
if (client === "cursor") {
|
|
23
|
+
switch (scope) {
|
|
24
|
+
case "local":
|
|
25
|
+
case "project":
|
|
26
|
+
return join(process.cwd(), ".cursor", "cli.json");
|
|
27
|
+
case "global":
|
|
28
|
+
return join(homedir(), ".cursor", "cli-config.json");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
switch (scope) {
|
|
33
|
+
case "local":
|
|
34
|
+
return join(process.cwd(), ".claude", "settings.local.json");
|
|
35
|
+
case "project":
|
|
36
|
+
return join(process.cwd(), ".claude", "settings.json");
|
|
37
|
+
case "global":
|
|
38
|
+
return join(homedir(), ".claude", "settings.json");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Read client settings from a file, returning empty settings if the file doesn't exist */
|
|
43
|
+
export async function readClientSettings(path: string): Promise<ClientSettings> {
|
|
44
|
+
try {
|
|
45
|
+
const content = await readFile(path, "utf-8");
|
|
46
|
+
return JSON.parse(content) as ClientSettings;
|
|
47
|
+
} catch {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Write client settings to a file, creating parent directories as needed */
|
|
53
|
+
export async function writeClientSettings(path: string, settings: ClientSettings): Promise<void> {
|
|
54
|
+
const dir = join(path, "..");
|
|
55
|
+
await mkdir(dir, { recursive: true });
|
|
56
|
+
await writeFile(path, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Generate a permission pattern for mcpx exec with a specific server and optional tool */
|
|
60
|
+
export function execPattern(server: string, tool?: string, client: Client = "claude"): string {
|
|
61
|
+
const p = prefix(client);
|
|
62
|
+
if (tool) {
|
|
63
|
+
return `${p}(mcpx exec:${server}:${tool}:*)`;
|
|
64
|
+
}
|
|
65
|
+
return `${p}(mcpx exec:${server}:*)`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Read-only mcpx commands that are safe to allow broadly */
|
|
69
|
+
const READ_ONLY_COMMANDS = [
|
|
70
|
+
"search",
|
|
71
|
+
"info",
|
|
72
|
+
"servers",
|
|
73
|
+
"ping",
|
|
74
|
+
"resource",
|
|
75
|
+
"prompt",
|
|
76
|
+
"task",
|
|
77
|
+
"index",
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/** Generate patterns for all read-only mcpx commands */
|
|
81
|
+
export function readOnlyPatterns(client: Client = "claude"): string[] {
|
|
82
|
+
const p = prefix(client);
|
|
83
|
+
return READ_ONLY_COMMANDS.map((cmd) => `${p}(mcpx ${cmd}:*)`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Generate the broad allow-all pattern for mcpx exec */
|
|
87
|
+
export function allExecPattern(client: Client = "claude"): string {
|
|
88
|
+
return `${prefix(client)}(mcpx exec:*)`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Generate the allow pattern for mcpx allow itself */
|
|
92
|
+
export function allowCommandPattern(client: Client = "claude"): string {
|
|
93
|
+
return `${prefix(client)}(mcpx allow:*)`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Generate the allow pattern for mcpx deny itself */
|
|
97
|
+
export function denyCommandPattern(client: Client = "claude"): string {
|
|
98
|
+
return `${prefix(client)}(mcpx deny:*)`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Check if a permission pattern is mcpx-related */
|
|
102
|
+
export function isMcpxPattern(pattern: string, client: Client = "claude"): boolean {
|
|
103
|
+
return pattern.startsWith(`${prefix(client)}(mcpx `);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Add patterns to settings, deduplicating. Returns the updated settings and list of newly added patterns. */
|
|
107
|
+
export function addPatterns(
|
|
108
|
+
settings: ClientSettings,
|
|
109
|
+
patterns: string[],
|
|
110
|
+
): { settings: ClientSettings; added: string[] } {
|
|
111
|
+
const existing = new Set(settings.permissions?.allow ?? []);
|
|
112
|
+
const added: string[] = [];
|
|
113
|
+
|
|
114
|
+
for (const p of patterns) {
|
|
115
|
+
if (!existing.has(p)) {
|
|
116
|
+
existing.add(p);
|
|
117
|
+
added.push(p);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
settings: {
|
|
123
|
+
...settings,
|
|
124
|
+
permissions: {
|
|
125
|
+
...settings.permissions,
|
|
126
|
+
allow: [...existing],
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
added,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Remove specific patterns from settings. Returns the updated settings and list of removed patterns. */
|
|
134
|
+
export function removePatterns(
|
|
135
|
+
settings: ClientSettings,
|
|
136
|
+
patterns: string[],
|
|
137
|
+
): { settings: ClientSettings; removed: string[] } {
|
|
138
|
+
const existing = settings.permissions?.allow ?? [];
|
|
139
|
+
const toRemove = new Set(patterns);
|
|
140
|
+
const removed: string[] = [];
|
|
141
|
+
const remaining: string[] = [];
|
|
142
|
+
|
|
143
|
+
for (const p of existing) {
|
|
144
|
+
if (toRemove.has(p)) {
|
|
145
|
+
removed.push(p);
|
|
146
|
+
} else {
|
|
147
|
+
remaining.push(p);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
settings: {
|
|
153
|
+
...settings,
|
|
154
|
+
permissions: {
|
|
155
|
+
...settings.permissions,
|
|
156
|
+
allow: remaining,
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
removed,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Remove all mcpx-related patterns from settings. Returns the updated settings and list of removed patterns. */
|
|
164
|
+
export function removeAllMcpxPatterns(
|
|
165
|
+
settings: ClientSettings,
|
|
166
|
+
client: Client = "claude",
|
|
167
|
+
): {
|
|
168
|
+
settings: ClientSettings;
|
|
169
|
+
removed: string[];
|
|
170
|
+
} {
|
|
171
|
+
const existing = settings.permissions?.allow ?? [];
|
|
172
|
+
const removed: string[] = [];
|
|
173
|
+
const remaining: string[] = [];
|
|
174
|
+
|
|
175
|
+
for (const p of existing) {
|
|
176
|
+
if (isMcpxPattern(p, client)) {
|
|
177
|
+
removed.push(p);
|
|
178
|
+
} else {
|
|
179
|
+
remaining.push(p);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
settings: {
|
|
185
|
+
...settings,
|
|
186
|
+
permissions: {
|
|
187
|
+
...settings.permissions,
|
|
188
|
+
allow: remaining,
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
removed,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Extract all mcpx-related patterns from settings */
|
|
196
|
+
export function getMcpxPatterns(settings: ClientSettings, client: Client = "claude"): string[] {
|
|
197
|
+
return (settings.permissions?.allow ?? []).filter((p) => isMcpxPattern(p, client));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Get all mcpx-related patterns for a specific server */
|
|
201
|
+
export function getServerPatterns(
|
|
202
|
+
settings: ClientSettings,
|
|
203
|
+
server: string,
|
|
204
|
+
client: Client = "claude",
|
|
205
|
+
): string[] {
|
|
206
|
+
const p = prefix(client);
|
|
207
|
+
return getMcpxPatterns(settings, client).filter(
|
|
208
|
+
(pat) => pat.startsWith(`${p}(mcpx exec:${server}:`) || pat === `${p}(mcpx exec:${server}:*)`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { yellow, cyan, dim } from "ansis";
|
|
2
|
+
import pkg from "../../package.json";
|
|
3
|
+
import { ENV, DEFAULTS } from "../constants.ts";
|
|
4
|
+
import { checkForUpdate, needsCheck, type UpdateCache } from "./checker.ts";
|
|
5
|
+
import { loadUpdateCache, saveUpdateCache } from "./cache.ts";
|
|
6
|
+
|
|
7
|
+
/** Format an update notice for stderr output. */
|
|
8
|
+
function formatNotice(currentVersion: string, latestVersion: string, changelog?: string): string {
|
|
9
|
+
const lines: string[] = ["", yellow(`Update available: ${currentVersion} → ${latestVersion}`)];
|
|
10
|
+
|
|
11
|
+
if (changelog) {
|
|
12
|
+
lines.push("");
|
|
13
|
+
lines.push(dim(changelog));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
lines.push("");
|
|
17
|
+
lines.push(cyan(`Run \`mcpx upgrade\` to update`));
|
|
18
|
+
lines.push("");
|
|
19
|
+
|
|
20
|
+
return lines.join("\n");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Non-blocking background update check. Returns a formatted notice string
|
|
25
|
+
* if an update is available, or null otherwise. Never throws.
|
|
26
|
+
*/
|
|
27
|
+
export async function maybeCheckForUpdate(): Promise<string | null> {
|
|
28
|
+
try {
|
|
29
|
+
// Opt-out via env var
|
|
30
|
+
if (process.env[ENV.NO_UPDATE_CHECK] === "1") return null;
|
|
31
|
+
|
|
32
|
+
// Skip if this is the check-update or upgrade command
|
|
33
|
+
const args = process.argv.slice(2);
|
|
34
|
+
const command = args.find((a) => !a.startsWith("-"));
|
|
35
|
+
if (command === "check-update" || command === "upgrade") return null;
|
|
36
|
+
|
|
37
|
+
// Only show in TTY
|
|
38
|
+
if (!(process.stderr.isTTY ?? false)) return null;
|
|
39
|
+
|
|
40
|
+
const cache = await loadUpdateCache();
|
|
41
|
+
|
|
42
|
+
if (!needsCheck(cache)) {
|
|
43
|
+
// Cache is fresh — use cached result
|
|
44
|
+
if (cache?.hasUpdate) {
|
|
45
|
+
return formatNotice(pkg.version, cache.latestVersion, cache.changelog);
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Cache is stale or missing — check with timeout
|
|
51
|
+
const controller = new AbortController();
|
|
52
|
+
const timeout = setTimeout(() => controller.abort(), DEFAULTS.UPDATE_CHECK_TIMEOUT_MS);
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const info = await checkForUpdate(pkg.version, controller.signal);
|
|
56
|
+
|
|
57
|
+
const newCache: UpdateCache = {
|
|
58
|
+
lastCheckAt: new Date().toISOString(),
|
|
59
|
+
latestVersion: info.latestVersion,
|
|
60
|
+
hasUpdate: info.hasUpdate,
|
|
61
|
+
changelog: info.changelog,
|
|
62
|
+
};
|
|
63
|
+
await saveUpdateCache(newCache);
|
|
64
|
+
|
|
65
|
+
if (info.hasUpdate) {
|
|
66
|
+
return formatNotice(pkg.version, info.latestVersion, info.changelog);
|
|
67
|
+
}
|
|
68
|
+
} finally {
|
|
69
|
+
clearTimeout(timeout);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return null;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { DEFAULT_CONFIG_DIR } from "../constants.ts";
|
|
3
|
+
import type { UpdateCache } from "./checker.ts";
|
|
4
|
+
|
|
5
|
+
const UPDATE_CACHE_PATH = join(DEFAULT_CONFIG_DIR, "update.json");
|
|
6
|
+
|
|
7
|
+
/** Load the cached update check result, if it exists. */
|
|
8
|
+
export async function loadUpdateCache(): Promise<UpdateCache | undefined> {
|
|
9
|
+
try {
|
|
10
|
+
const file = Bun.file(UPDATE_CACHE_PATH);
|
|
11
|
+
if (!(await file.exists())) return undefined;
|
|
12
|
+
return JSON.parse(await file.text()) as UpdateCache;
|
|
13
|
+
} catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Save update check result to the cache file. */
|
|
19
|
+
export async function saveUpdateCache(cache: UpdateCache): Promise<void> {
|
|
20
|
+
try {
|
|
21
|
+
await Bun.write(UPDATE_CACHE_PATH, JSON.stringify(cache, null, 2) + "\n");
|
|
22
|
+
} catch {
|
|
23
|
+
// Ignore write failures (e.g. permissions)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Remove the cached update check result. */
|
|
28
|
+
export async function clearUpdateCache(): Promise<void> {
|
|
29
|
+
try {
|
|
30
|
+
const file = Bun.file(UPDATE_CACHE_PATH);
|
|
31
|
+
if (await file.exists()) {
|
|
32
|
+
await Bun.write(UPDATE_CACHE_PATH, "");
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// Ignore
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import pkg from "../../package.json";
|
|
2
|
+
import { DEFAULTS } from "../constants.ts";
|
|
3
|
+
|
|
4
|
+
const NPM_REGISTRY_URL = `https://registry.npmjs.org/${pkg.name}/latest`;
|
|
5
|
+
const GITHUB_REPO = pkg.repository.url
|
|
6
|
+
.replace(/^https:\/\/github\.com\//, "")
|
|
7
|
+
.replace(/\.git$/, "");
|
|
8
|
+
|
|
9
|
+
export interface UpdateInfo {
|
|
10
|
+
currentVersion: string;
|
|
11
|
+
latestVersion: string;
|
|
12
|
+
hasUpdate: boolean;
|
|
13
|
+
aheadOfLatest: boolean;
|
|
14
|
+
changelog?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UpdateCache {
|
|
18
|
+
lastCheckAt: string;
|
|
19
|
+
latestVersion: string;
|
|
20
|
+
hasUpdate: boolean;
|
|
21
|
+
changelog?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type InstallMethod = "npm" | "bun" | "binary" | "local-dev";
|
|
25
|
+
|
|
26
|
+
/** Compare two semver strings. Returns true if latest > current. */
|
|
27
|
+
export function isNewerVersion(current: string, latest: string): boolean {
|
|
28
|
+
return Bun.semver.order(current, latest) === -1;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Fetch the latest version from the npm registry. */
|
|
32
|
+
export async function fetchLatestVersion(signal?: AbortSignal): Promise<string> {
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetch(NPM_REGISTRY_URL, { signal });
|
|
35
|
+
if (!res.ok) return pkg.version;
|
|
36
|
+
const data = (await res.json()) as { version: string };
|
|
37
|
+
return data.version;
|
|
38
|
+
} catch {
|
|
39
|
+
return pkg.version;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Fetch changelog from GitHub releases between two versions. */
|
|
44
|
+
export async function fetchChangelog(
|
|
45
|
+
fromVersion: string,
|
|
46
|
+
toVersion: string,
|
|
47
|
+
signal?: AbortSignal,
|
|
48
|
+
): Promise<string | undefined> {
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases?per_page=20`, {
|
|
51
|
+
signal,
|
|
52
|
+
headers: { Accept: "application/vnd.github.v3+json" },
|
|
53
|
+
});
|
|
54
|
+
if (!res.ok) return undefined;
|
|
55
|
+
|
|
56
|
+
const releases = (await res.json()) as Array<{
|
|
57
|
+
tag_name: string;
|
|
58
|
+
body: string | null;
|
|
59
|
+
}>;
|
|
60
|
+
|
|
61
|
+
const relevant = releases.filter((r) => {
|
|
62
|
+
const v = r.tag_name.replace(/^v/, "");
|
|
63
|
+
return isNewerVersion(fromVersion, v) && !isNewerVersion(toVersion, v);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (relevant.length === 0) return undefined;
|
|
67
|
+
|
|
68
|
+
return relevant
|
|
69
|
+
.map((r) => `## ${r.tag_name}\n${r.body ?? ""}`)
|
|
70
|
+
.join("\n\n")
|
|
71
|
+
.trim();
|
|
72
|
+
} catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Check npm for a newer version and fetch changelog if available. */
|
|
78
|
+
export async function checkForUpdate(
|
|
79
|
+
currentVersion: string,
|
|
80
|
+
signal?: AbortSignal,
|
|
81
|
+
): Promise<UpdateInfo> {
|
|
82
|
+
const latestVersion = await fetchLatestVersion(signal);
|
|
83
|
+
const hasUpdate = isNewerVersion(currentVersion, latestVersion);
|
|
84
|
+
const aheadOfLatest = isNewerVersion(latestVersion, currentVersion);
|
|
85
|
+
|
|
86
|
+
let changelog: string | undefined;
|
|
87
|
+
if (hasUpdate) {
|
|
88
|
+
changelog = await fetchChangelog(currentVersion, latestVersion, signal);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { currentVersion, latestVersion, hasUpdate, aheadOfLatest, changelog };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Returns true if the cache is missing or older than 24 hours. */
|
|
95
|
+
export function needsCheck(cache?: UpdateCache): boolean {
|
|
96
|
+
if (!cache?.lastCheckAt) return true;
|
|
97
|
+
return Date.now() - new Date(cache.lastCheckAt).getTime() > DEFAULTS.UPDATE_CHECK_INTERVAL_MS;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Detect how mcpx was installed. */
|
|
101
|
+
export function detectInstallMethod(): InstallMethod {
|
|
102
|
+
const script = process.argv[1] ?? "";
|
|
103
|
+
const execPath = process.execPath;
|
|
104
|
+
|
|
105
|
+
// Local dev: running src/cli.ts directly outside node_modules
|
|
106
|
+
if (script.includes("src/cli.ts") && !script.includes("node_modules")) {
|
|
107
|
+
return "local-dev";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Compiled binary: execPath is the binary itself (not bun/node)
|
|
111
|
+
if (!execPath.includes("bun") && !execPath.includes("node")) {
|
|
112
|
+
return "binary";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Bun global install: path contains .bun/install
|
|
116
|
+
if (script.includes(".bun/install") || script.includes(".bun/bin")) {
|
|
117
|
+
return "bun";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// npm global install: fallback for node_modules paths
|
|
121
|
+
return "npm";
|
|
122
|
+
}
|