@m2canvas/connect 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/dist/cli.js +95 -38
- package/dist/clients.js +57 -48
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,9 @@ npx @m2canvas/connect
|
|
|
8
8
|
|
|
9
9
|
It finds the supported clients you have installed, shows what it will change, and after you confirm, adds the M2 MCP server (`https://mcp.m2ui.ai/mcp`) to each one and opens its browser sign-in. No API key is needed.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
**In a terminal**, it asks before changing anything and opens each client's browser sign-in, so one command finishes the whole setup.
|
|
12
|
+
|
|
13
|
+
**Run by an agent** (no interactive terminal, e.g. pasted from M2's "Copy to agent"), the agent passes `--client <id>` (`claude-code`, `codex`, `cursor`, `opencode`, `qoder`) so only its own client is touched. It adds the server without asking, skips the sign-ins it can't run there, and prints the remaining steps: a command to run (Codex, Cursor, OpenCode) or a place to click in the app (Claude Code, Qoder). The agent can run the commands or pass the steps on to you. Running it again lists the sign-in steps for clients that were already added.
|
|
12
14
|
|
|
13
15
|
| Client | How it is set up | Sign-in |
|
|
14
16
|
|---|---|---|
|
|
@@ -20,6 +22,6 @@ Run it in a regular terminal. Some clients' sign-in (Claude Code's in particular
|
|
|
20
22
|
|
|
21
23
|
Clients that already have an entry pointing at `mcp.m2ui.ai`, under any name, are skipped. Before a config file is edited, the original is copied to `<file>.bak-m2canvas`.
|
|
22
24
|
|
|
23
|
-
Options: `--dry-run` shows the plan without changing anything; `--yes` skips the confirmation.
|
|
25
|
+
Options: `--client <id>` sets up only that client; `--dry-run` shows the plan without changing anything; `--yes` skips the confirmation.
|
|
24
26
|
|
|
25
27
|
After setup, restart each client so it loads the new server.
|
package/dist/cli.js
CHANGED
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// npx @m2canvas/connect —— 探测本机 agent 客户端,把 M2 MCP
|
|
2
|
+
// npx @m2canvas/connect —— 探测本机 agent 客户端,把 M2 MCP 写入各自配置并完成浏览器授权。
|
|
3
|
+
//
|
|
4
|
+
// 两种运行方式:
|
|
5
|
+
// · 真终端(TTY):预览 → 确认 → 写入 → 逐个跑 login,一条命令走完。
|
|
6
|
+
// · agent 代跑(非 TTY):Copy to agent 起手式让 agent 执行本命令。agent 的 shell 没有 TTY,
|
|
7
|
+
// 无法确认也跑不了 Claude Code 的 login,且 login 要阻塞等浏览器回调(OpenCode 还会超时),
|
|
8
|
+
// 所以只写入配置,把剩余授权步骤打印成 agent 可以照做 / 转述给用户的清单。
|
|
3
9
|
import { createInterface } from 'node:readline/promises';
|
|
4
|
-
import { M2_MCP_URL } from './config-files.js';
|
|
5
|
-
import { detectClients } from './clients.js';
|
|
6
|
-
|
|
10
|
+
import { M2_MCP_URL, SERVER_NAME } from './config-files.js';
|
|
11
|
+
import { CLIENTS, detectClients, isClientId } from './clients.js';
|
|
12
|
+
import { run } from './system.js';
|
|
13
|
+
const CLIENT_IDS = Object.keys(CLIENTS).join(', ');
|
|
14
|
+
const HELP = `Usage: npx @m2canvas/connect [--client <id>] [--yes] [--dry-run]
|
|
7
15
|
|
|
8
16
|
Adds M2 Canvas (${M2_MCP_URL}) to every agent client found on this machine
|
|
9
|
-
(Claude Code, Codex, Cursor, OpenCode, Qoder), then
|
|
17
|
+
(Claude Code, Codex, Cursor, OpenCode, Qoder), then signs each one in.
|
|
10
18
|
|
|
19
|
+
In a terminal it asks before changing anything and opens each sign-in in your browser.
|
|
20
|
+
When run by an agent (no terminal), it only adds the server and prints the remaining
|
|
21
|
+
sign-in steps.
|
|
22
|
+
|
|
23
|
+
--client <id> only set up this client (${CLIENT_IDS})
|
|
11
24
|
-y, --yes skip the confirmation prompt
|
|
12
25
|
--dry-run only show what would change
|
|
13
26
|
-h, --help show this help`;
|
|
@@ -21,14 +34,17 @@ async function confirm(question) {
|
|
|
21
34
|
rl.close();
|
|
22
35
|
}
|
|
23
36
|
}
|
|
37
|
+
function shellQuote(value) {
|
|
38
|
+
return /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
|
|
39
|
+
}
|
|
24
40
|
function printPlan(plans) {
|
|
25
41
|
console.log(`\nM2 Canvas MCP: ${M2_MCP_URL}\n`);
|
|
26
42
|
for (const plan of plans) {
|
|
27
43
|
if (plan.existing) {
|
|
28
|
-
console.log(` • ${plan.label}: already
|
|
44
|
+
console.log(` • ${plan.label}: already has M2 as "${plan.existing}", skipping`);
|
|
29
45
|
}
|
|
30
46
|
else if (plan.blocked) {
|
|
31
|
-
console.log(` ! ${plan.label}: can't set up automatically
|
|
47
|
+
console.log(` ! ${plan.label}: can't set up automatically. ${plan.blocked}`);
|
|
32
48
|
}
|
|
33
49
|
else {
|
|
34
50
|
console.log(` ✓ ${plan.label}`);
|
|
@@ -38,61 +54,102 @@ function printPlan(plans) {
|
|
|
38
54
|
}
|
|
39
55
|
console.log('');
|
|
40
56
|
}
|
|
41
|
-
function
|
|
42
|
-
if (
|
|
43
|
-
return `
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (outcome.signIn === 'failed')
|
|
47
|
-
return `added, but sign-in did not finish${outcome.note ? ` (${outcome.note})` : ''}`;
|
|
48
|
-
return `added. ${outcome.note ?? ''}`.trim();
|
|
57
|
+
function describePending(step) {
|
|
58
|
+
if (step.login) {
|
|
59
|
+
return `run \`${[step.login.command, ...step.login.args].map(shellQuote).join(' ')}\` (opens the browser sign-in)`;
|
|
60
|
+
}
|
|
61
|
+
return step.manual ?? '';
|
|
49
62
|
}
|
|
50
63
|
async function main() {
|
|
51
|
-
const
|
|
64
|
+
const argv = process.argv.slice(2);
|
|
65
|
+
const args = new Set(argv);
|
|
52
66
|
if (args.has('-h') || args.has('--help')) {
|
|
53
67
|
console.log(HELP);
|
|
54
68
|
return 0;
|
|
55
69
|
}
|
|
56
|
-
const
|
|
70
|
+
const interactive = Boolean(process.stdin.isTTY);
|
|
71
|
+
// agent 代跑时由起手式带上自己的 --client:只处理这一家,其他客户端不动、不提 ——
|
|
72
|
+
// 用户只是在一个客户端里粘了一段话,不该顺带改掉、也不该被列出别的客户端。
|
|
73
|
+
const clientIndex = argv.indexOf('--client');
|
|
74
|
+
const requested = clientIndex >= 0 ? (argv[clientIndex + 1] ?? '') : null;
|
|
75
|
+
if (requested !== null && !isClientId(requested)) {
|
|
76
|
+
console.error(`Unknown --client "${requested}". Use one of: ${CLIENT_IDS}.`);
|
|
77
|
+
return 2;
|
|
78
|
+
}
|
|
79
|
+
const only = requested ?? undefined;
|
|
80
|
+
const plans = detectClients(only);
|
|
81
|
+
if (only && plans.length === 0) {
|
|
82
|
+
console.log(`Couldn't find a working ${only} CLI on this machine.\nAdd this MCP server by hand instead: ${M2_MCP_URL} (streamable HTTP, OAuth sign-in).`);
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
57
85
|
if (plans.length === 0) {
|
|
58
86
|
console.log(`No supported agent client found.\nAdd this MCP server in your client by hand: ${M2_MCP_URL} (streamable HTTP, OAuth sign-in).`);
|
|
59
87
|
return 1;
|
|
60
88
|
}
|
|
61
89
|
printPlan(plans);
|
|
62
90
|
const todo = plans.filter((plan) => !plan.existing && !plan.blocked);
|
|
63
|
-
if (
|
|
91
|
+
if (args.has('--dry-run'))
|
|
64
92
|
return 0;
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
93
|
+
if (todo.length === 0) {
|
|
94
|
+
printExistingSignIn(plans, interactive);
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
// 非 TTY 下没人能回答确认提示 —— 执行本命令的 agent 就是替用户操作的一方。
|
|
98
|
+
if (interactive && !args.has('-y') && !args.has('--yes')) {
|
|
71
99
|
if (!(await confirm(`Set up ${todo.map((plan) => plan.label).join(', ')}? (Y/n) `)))
|
|
72
100
|
return 0;
|
|
73
101
|
}
|
|
74
102
|
const results = [];
|
|
75
103
|
for (const plan of todo) {
|
|
76
|
-
|
|
77
|
-
let outcome;
|
|
104
|
+
let added;
|
|
78
105
|
try {
|
|
79
|
-
outcome = plan.
|
|
106
|
+
const outcome = plan.add();
|
|
107
|
+
added = { plan, added: outcome.ok, error: outcome.note, pending: outcome.ok ? plan.signIn(interactive, SERVER_NAME) : null };
|
|
80
108
|
}
|
|
81
109
|
catch (error) {
|
|
82
|
-
|
|
110
|
+
added = { plan, added: false, error: error instanceof Error ? error.message : String(error), pending: null };
|
|
83
111
|
}
|
|
84
|
-
|
|
112
|
+
const login = added.pending?.login;
|
|
113
|
+
if (interactive && login) {
|
|
114
|
+
console.log(`\n→ ${plan.label}: finish the sign-in in your browser.`);
|
|
115
|
+
if (run(login.command, login.args, true).status === 0)
|
|
116
|
+
added.pending = null;
|
|
117
|
+
}
|
|
118
|
+
results.push(added);
|
|
119
|
+
}
|
|
120
|
+
console.log('\nResult');
|
|
121
|
+
for (const result of results) {
|
|
122
|
+
const state = !result.added ? `failed: ${result.error ?? 'unknown error'}` : result.pending ? 'added, sign-in pending' : 'added and signed in';
|
|
123
|
+
console.log(` ${result.plan.label}: ${state}`);
|
|
85
124
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
console.log(
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
125
|
+
const pending = results.filter((result) => result.added && result.pending);
|
|
126
|
+
if (pending.length > 0) {
|
|
127
|
+
console.log('\nTo finish signing in:');
|
|
128
|
+
for (const result of pending)
|
|
129
|
+
console.log(` - ${result.plan.label}: ${describePending(result.pending)}`);
|
|
130
|
+
}
|
|
131
|
+
const loaded = results.filter((result) => result.added);
|
|
132
|
+
if (loaded.length > 0) {
|
|
133
|
+
console.log('\nThen, to load M2 Canvas:');
|
|
134
|
+
for (const result of loaded)
|
|
135
|
+
console.log(` - ${result.plan.restart}`);
|
|
136
|
+
}
|
|
137
|
+
printExistingSignIn(plans, interactive);
|
|
138
|
+
return results.every((result) => result.added) ? 0 : 1;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* 已有入口不代表已授权:agent 首轮写入配置后用户可能还没登录,重粘起手式时 agent 仍没有工具、
|
|
142
|
+
* 会再跑一次本命令。此时只报 "skipping" 就是死路,所以把这些入口的授权步骤也列出来。
|
|
143
|
+
* 安装器读不到授权态(token 在各客户端自己的存储里),只能写成「如果还没登录」。
|
|
144
|
+
*/
|
|
145
|
+
function printExistingSignIn(plans, interactive) {
|
|
146
|
+
const existing = plans.filter((plan) => plan.existing);
|
|
147
|
+
if (existing.length === 0)
|
|
148
|
+
return;
|
|
149
|
+
console.log('\nAlready added. If a client still shows M2 as not signed in:');
|
|
150
|
+
for (const plan of existing) {
|
|
151
|
+
console.log(` - ${plan.label}: ${describePending(plan.signIn(interactive, plan.existing))}`);
|
|
94
152
|
}
|
|
95
|
-
return results.every(([, outcome]) => outcome.ok) ? 0 : 1;
|
|
96
153
|
}
|
|
97
154
|
main().then((code) => process.exit(code), (error) => {
|
|
98
155
|
console.error(error instanceof Error ? error.message : error);
|
package/dist/clients.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
// 各客户端的探测 / 写入 / 登录。每家的命令与坑位都经本机逐一实测,见 README。
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import { M2_MCP_URL, SERVER_NAME, findInClaudeConfig, findInCodexToml, findInQoderSettings, mergeCursorConfig, mergeOpencodeConfig, } from './config-files.js';
|
|
4
|
-
import { existsSync, firstExecutable, firstRunnable, home, readJson, readText, run, which, writeJsonWithBackup
|
|
5
|
-
function signInWith(command, args) {
|
|
6
|
-
return run(command, args, true).status === 0 ? 'done' : 'failed';
|
|
7
|
-
}
|
|
5
|
+
import { existsSync, firstExecutable, firstRunnable, home, readJson, readText, run, which, writeJsonWithBackup } from './system.js';
|
|
8
6
|
function jsonExisting(path, find) {
|
|
9
7
|
const read = readJson(path);
|
|
10
8
|
return read.kind === 'ok' ? find(read.value) : null;
|
|
11
9
|
}
|
|
10
|
+
function addWith(command, args) {
|
|
11
|
+
const result = run(command, args);
|
|
12
|
+
return result.status === 0 ? { ok: true } : { ok: false, note: result.output };
|
|
13
|
+
}
|
|
12
14
|
function claudeCode() {
|
|
13
15
|
const bin = firstRunnable([which('claude')]);
|
|
14
16
|
if (!bin)
|
|
@@ -17,14 +19,13 @@ function claudeCode() {
|
|
|
17
19
|
label: 'Claude Code',
|
|
18
20
|
existing: jsonExisting(join(home, '.claude.json'), findInClaudeConfig),
|
|
19
21
|
blocked: null,
|
|
20
|
-
actions: [`claude mcp add --transport http --scope user ${SERVER_NAME} ${M2_MCP_URL}
|
|
22
|
+
actions: [`claude mcp add --transport http --scope user ${SERVER_NAME} ${M2_MCP_URL}`],
|
|
21
23
|
restart: 'Restart Claude Code (claude --continue keeps your conversation).',
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
},
|
|
24
|
+
// `claude mcp login` 在非 TTY 下直接失败(实测 "stdin isn't a terminal"),agent 的 shell 就是这种。
|
|
25
|
+
signIn: (interactive, name) => interactive
|
|
26
|
+
? { login: { command: bin, args: ['mcp', 'login', name] }, manual: null }
|
|
27
|
+
: { login: null, manual: `In Claude Code, type /mcp, pick ${name} and choose Authenticate.` },
|
|
28
|
+
add: () => addWith(bin, ['mcp', 'add', '--transport', 'http', '--scope', 'user', SERVER_NAME, M2_MCP_URL]),
|
|
28
29
|
};
|
|
29
30
|
}
|
|
30
31
|
function codex() {
|
|
@@ -38,14 +39,14 @@ function codex() {
|
|
|
38
39
|
label: 'Codex',
|
|
39
40
|
existing: toml ? findInCodexToml(toml) : null,
|
|
40
41
|
blocked: null,
|
|
41
|
-
actions: [`codex mcp add ${SERVER_NAME} --url ${M2_MCP_URL}
|
|
42
|
-
// 顺序敏感:login 的本地回调端口挂在 login
|
|
43
|
-
restart: 'Fully quit and reopen Codex.',
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return
|
|
42
|
+
actions: [`codex mcp add ${SERVER_NAME} --url ${M2_MCP_URL}`],
|
|
43
|
+
// 顺序敏感:login 的本地回调端口挂在 login 进程上,必须授权完成后再重启 Codex。
|
|
44
|
+
restart: 'Fully quit and reopen Codex (after signing in).',
|
|
45
|
+
signIn: (_interactive, name) => ({ login: { command: bin, args: ['mcp', 'login', name] }, manual: null }),
|
|
46
|
+
add() {
|
|
47
|
+
// codex 在 CODEX_HOME 目录不存在时直接报 "failed to load configuration",不会自己建。
|
|
48
|
+
mkdirSync(codexHome, { recursive: true });
|
|
49
|
+
return addWith(bin, ['mcp', 'add', SERVER_NAME, '--url', M2_MCP_URL]);
|
|
49
50
|
},
|
|
50
51
|
};
|
|
51
52
|
}
|
|
@@ -60,17 +61,12 @@ function qoder() {
|
|
|
60
61
|
blocked: null,
|
|
61
62
|
actions: [`qodercli mcp add -s user -t http ${SERVER_NAME} ${M2_MCP_URL}`],
|
|
62
63
|
restart: 'Reopen Qoder if it is running.',
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
ok: true,
|
|
70
|
-
signIn: 'manual',
|
|
71
|
-
note: 'Sign in: Qoder → Extensions → Connectors → m2 → Authorize (or run qodercli, then /mcp).',
|
|
72
|
-
};
|
|
73
|
-
},
|
|
64
|
+
// qodercli 没有 mcp login 子命令,授权只能在 UI 里点。
|
|
65
|
+
signIn: (_interactive, name) => ({
|
|
66
|
+
login: null,
|
|
67
|
+
manual: `In Qoder, open Extensions → Connectors → ${name} and click Authorize (or run qodercli, then /mcp).`,
|
|
68
|
+
}),
|
|
69
|
+
add: () => addWith(bin, ['mcp', 'add', '-s', 'user', '-t', 'http', SERVER_NAME, M2_MCP_URL]),
|
|
74
70
|
};
|
|
75
71
|
}
|
|
76
72
|
function opencode() {
|
|
@@ -83,9 +79,11 @@ function opencode() {
|
|
|
83
79
|
label: 'OpenCode',
|
|
84
80
|
existing: null,
|
|
85
81
|
blocked: null,
|
|
86
|
-
actions: [`add "${SERVER_NAME}" to ${path}
|
|
82
|
+
actions: [`add "${SERVER_NAME}" to ${path}`],
|
|
87
83
|
restart: 'Restart OpenCode.',
|
|
88
|
-
|
|
84
|
+
// auth 不需要 TTY,但等浏览器回调有超时(实测 "Authentication expired")。
|
|
85
|
+
signIn: (_interactive, name) => ({ login: { command: bin, args: ['mcp', 'auth', name] }, manual: null }),
|
|
86
|
+
add: () => ({ ok: false }),
|
|
89
87
|
};
|
|
90
88
|
// jsonc 允许注释,按 JSON 回写会抹掉用户注释 —— 不碰,交给用户手动加。
|
|
91
89
|
if (existsSync(join(dir, 'opencode.jsonc'))) {
|
|
@@ -98,9 +96,9 @@ function opencode() {
|
|
|
98
96
|
return {
|
|
99
97
|
...base,
|
|
100
98
|
existing: merged.existing,
|
|
101
|
-
|
|
99
|
+
add() {
|
|
102
100
|
writeJsonWithBackup(path, merged.config);
|
|
103
|
-
return { ok: true
|
|
101
|
+
return { ok: true };
|
|
104
102
|
},
|
|
105
103
|
};
|
|
106
104
|
}
|
|
@@ -115,30 +113,41 @@ function cursor() {
|
|
|
115
113
|
label: 'Cursor',
|
|
116
114
|
existing: null,
|
|
117
115
|
blocked: null,
|
|
118
|
-
actions: [`add "${SERVER_NAME}" to ${path}`, ...(bin ? [
|
|
116
|
+
actions: [`add "${SERVER_NAME}" to ${path}`, ...(bin ? [`cursor-agent mcp enable ${SERVER_NAME}`] : [])],
|
|
119
117
|
restart: 'Restart Cursor if it is running.',
|
|
120
|
-
|
|
118
|
+
signIn: (_interactive, name) => bin
|
|
119
|
+
? { login: { command: bin, args: ['mcp', 'login', name] }, manual: null }
|
|
120
|
+
: { login: null, manual: `In Cursor, open Settings → MCP, enable ${name} and log in.` },
|
|
121
|
+
add: () => ({ ok: false }),
|
|
121
122
|
};
|
|
122
123
|
if (read.kind === 'invalid')
|
|
123
124
|
return { ...base, blocked: `${path} is not valid JSON (${read.error}).` };
|
|
124
|
-
const
|
|
125
|
-
const merged = mergeCursorConfig(current);
|
|
125
|
+
const merged = mergeCursorConfig(read.kind === 'ok' ? read.value : {});
|
|
126
126
|
return {
|
|
127
127
|
...base,
|
|
128
128
|
existing: merged.existing,
|
|
129
|
-
|
|
129
|
+
add() {
|
|
130
130
|
writeJsonWithBackup(path, merged.config);
|
|
131
|
-
if (!bin)
|
|
132
|
-
return { ok: true
|
|
133
|
-
|
|
134
|
-
// 不 enable 会停在 "needs approval",login 也加载不到它。
|
|
131
|
+
if (!bin)
|
|
132
|
+
return { ok: true };
|
|
133
|
+
// 不 enable 会停在 "needs approval",login 也加载不到它。enable 不需要 TTY。
|
|
135
134
|
const enable = run(bin, ['mcp', 'enable', SERVER_NAME]);
|
|
136
|
-
|
|
137
|
-
return { ok: true, signIn: 'failed', note: enable.output };
|
|
138
|
-
return { ok: true, signIn: signInWith(bin, ['mcp', 'login', SERVER_NAME]) };
|
|
135
|
+
return enable.status === 0 ? { ok: true } : { ok: false, note: enable.output };
|
|
139
136
|
},
|
|
140
137
|
};
|
|
141
138
|
}
|
|
142
|
-
|
|
143
|
-
|
|
139
|
+
/** `--client` 的取值 → 探测函数。探测会真跑 `--version`,所以按需只跑被点名的那家。 */
|
|
140
|
+
export const CLIENTS = {
|
|
141
|
+
'claude-code': claudeCode,
|
|
142
|
+
codex,
|
|
143
|
+
cursor,
|
|
144
|
+
opencode,
|
|
145
|
+
qoder,
|
|
146
|
+
};
|
|
147
|
+
export function isClientId(value) {
|
|
148
|
+
return Object.hasOwn(CLIENTS, value);
|
|
149
|
+
}
|
|
150
|
+
export function detectClients(only) {
|
|
151
|
+
const ids = only ? [only] : Object.keys(CLIENTS);
|
|
152
|
+
return ids.map((id) => CLIENTS[id]()).filter((plan) => plan !== null);
|
|
144
153
|
}
|
package/package.json
CHANGED