@m2canvas/connect 0.1.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 ADDED
@@ -0,0 +1,25 @@
1
+ # @m2canvas/connect
2
+
3
+ Connect the agent clients on your machine to [M2 Canvas](https://m2ui.ai) in one command.
4
+
5
+ ```bash
6
+ npx @m2canvas/connect
7
+ ```
8
+
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
+
11
+ Run it in a regular terminal. Some clients' sign-in (Claude Code's in particular) requires an interactive terminal, so it won't work from inside an agent session.
12
+
13
+ | Client | How it is set up | Sign-in |
14
+ |---|---|---|
15
+ | Claude Code | `claude mcp add --transport http --scope user m2 …` | `claude mcp login m2` |
16
+ | Codex (CLI or the desktop app's bundled CLI) | `codex mcp add m2 --url …` | `codex mcp login m2` |
17
+ | Cursor | merges `m2` into `~/.cursor/mcp.json`, then `cursor-agent mcp enable m2` | `cursor-agent mcp login m2` |
18
+ | OpenCode | merges `m2` into `~/.config/opencode/opencode.json`, keeping the file's existing format | `opencode mcp auth m2` |
19
+ | Qoder (CLI + desktop app) | `qodercli mcp add -s user -t http m2 …` | in Qoder: Extensions → Connectors → m2 → Authorize |
20
+
21
+ 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
+
23
+ Options: `--dry-run` shows the plan without changing anything; `--yes` skips the confirmation.
24
+
25
+ After setup, restart each client so it loads the new server.
package/dist/cli.js ADDED
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ // npx @m2canvas/connect —— 探测本机 agent 客户端,把 M2 MCP 写入各自配置并逐个走浏览器授权。
3
+ import { createInterface } from 'node:readline/promises';
4
+ import { M2_MCP_URL } from './config-files.js';
5
+ import { detectClients } from './clients.js';
6
+ const HELP = `Usage: npx @m2canvas/connect [--yes] [--dry-run]
7
+
8
+ Adds M2 Canvas (${M2_MCP_URL}) to every agent client found on this machine
9
+ (Claude Code, Codex, Cursor, OpenCode, Qoder), then opens each client's browser sign-in.
10
+
11
+ -y, --yes skip the confirmation prompt
12
+ --dry-run only show what would change
13
+ -h, --help show this help`;
14
+ async function confirm(question) {
15
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
16
+ try {
17
+ const answer = (await rl.question(question)).trim().toLowerCase();
18
+ return answer === '' || answer === 'y' || answer === 'yes';
19
+ }
20
+ finally {
21
+ rl.close();
22
+ }
23
+ }
24
+ function printPlan(plans) {
25
+ console.log(`\nM2 Canvas MCP: ${M2_MCP_URL}\n`);
26
+ for (const plan of plans) {
27
+ if (plan.existing) {
28
+ console.log(` • ${plan.label}: already connected as "${plan.existing}" — skipping`);
29
+ }
30
+ else if (plan.blocked) {
31
+ console.log(` ! ${plan.label}: can't set up automatically — ${plan.blocked}`);
32
+ }
33
+ else {
34
+ console.log(` ✓ ${plan.label}`);
35
+ for (const action of plan.actions)
36
+ console.log(` ${action}`);
37
+ }
38
+ }
39
+ console.log('');
40
+ }
41
+ function describe(outcome) {
42
+ if (!outcome.ok)
43
+ return `failed${outcome.note ? `: ${outcome.note}` : ''}`;
44
+ if (outcome.signIn === 'done')
45
+ return 'added and signed in';
46
+ if (outcome.signIn === 'failed')
47
+ return `added, but sign-in did not finish${outcome.note ? ` (${outcome.note})` : ''}`;
48
+ return `added. ${outcome.note ?? ''}`.trim();
49
+ }
50
+ async function main() {
51
+ const args = new Set(process.argv.slice(2));
52
+ if (args.has('-h') || args.has('--help')) {
53
+ console.log(HELP);
54
+ return 0;
55
+ }
56
+ const plans = detectClients();
57
+ if (plans.length === 0) {
58
+ 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
+ return 1;
60
+ }
61
+ printPlan(plans);
62
+ const todo = plans.filter((plan) => !plan.existing && !plan.blocked);
63
+ if (todo.length === 0 || args.has('--dry-run'))
64
+ return 0;
65
+ if (!args.has('-y') && !args.has('--yes')) {
66
+ // 授权要在真终端里完成(Claude Code 的 login 硬性要求 TTY),非 TTY 下直接拒绝而不是跑一半。
67
+ if (!process.stdin.isTTY) {
68
+ console.error('Run this in an interactive terminal (browser sign-in needs one), or pass --yes.');
69
+ return 1;
70
+ }
71
+ if (!(await confirm(`Set up ${todo.map((plan) => plan.label).join(', ')}? (Y/n) `)))
72
+ return 0;
73
+ }
74
+ const results = [];
75
+ for (const plan of todo) {
76
+ console.log(`\n→ ${plan.label}: finish the sign-in in your browser if one opens.`);
77
+ let outcome;
78
+ try {
79
+ outcome = plan.apply();
80
+ }
81
+ catch (error) {
82
+ outcome = { ok: false, note: error instanceof Error ? error.message : String(error) };
83
+ }
84
+ results.push([plan, outcome]);
85
+ }
86
+ console.log('\nSummary');
87
+ for (const [plan, outcome] of results)
88
+ console.log(` ${plan.label}: ${describe(outcome)}`);
89
+ const restarts = results.filter(([, outcome]) => outcome.ok).map(([plan]) => plan.restart);
90
+ if (restarts.length > 0) {
91
+ console.log('\nTo load M2 Canvas:');
92
+ for (const line of restarts)
93
+ console.log(` - ${line}`);
94
+ }
95
+ return results.every(([, outcome]) => outcome.ok) ? 0 : 1;
96
+ }
97
+ main().then((code) => process.exit(code), (error) => {
98
+ console.error(error instanceof Error ? error.message : error);
99
+ process.exit(1);
100
+ });
@@ -0,0 +1,144 @@
1
+ // 各客户端的探测 / 写入 / 登录。每家的命令与坑位都经本机逐一实测,见 README。
2
+ import { join } from 'node:path';
3
+ 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, } from './system.js';
5
+ function signInWith(command, args) {
6
+ return run(command, args, true).status === 0 ? 'done' : 'failed';
7
+ }
8
+ function jsonExisting(path, find) {
9
+ const read = readJson(path);
10
+ return read.kind === 'ok' ? find(read.value) : null;
11
+ }
12
+ function claudeCode() {
13
+ const bin = firstRunnable([which('claude')]);
14
+ if (!bin)
15
+ return null;
16
+ return {
17
+ label: 'Claude Code',
18
+ existing: jsonExisting(join(home, '.claude.json'), findInClaudeConfig),
19
+ blocked: null,
20
+ actions: [`claude mcp add --transport http --scope user ${SERVER_NAME} ${M2_MCP_URL}`, 'sign in (browser)'],
21
+ restart: 'Restart Claude Code (claude --continue keeps your conversation).',
22
+ apply() {
23
+ const add = run(bin, ['mcp', 'add', '--transport', 'http', '--scope', 'user', SERVER_NAME, M2_MCP_URL]);
24
+ if (add.status !== 0)
25
+ return { ok: false, note: add.output };
26
+ return { ok: true, signIn: signInWith(bin, ['mcp', 'login', SERVER_NAME]) };
27
+ },
28
+ };
29
+ }
30
+ function codex() {
31
+ // Codex 桌面端(ChatGPT.app)自带 CLI,与独立安装的 codex 共用 ~/.codex。
32
+ const bin = firstRunnable([which('codex'), '/Applications/ChatGPT.app/Contents/Resources/codex']);
33
+ if (!bin)
34
+ return null;
35
+ const codexHome = process.env.CODEX_HOME ?? join(home, '.codex');
36
+ const toml = readText(join(codexHome, 'config.toml'));
37
+ return {
38
+ label: 'Codex',
39
+ existing: toml ? findInCodexToml(toml) : null,
40
+ blocked: null,
41
+ actions: [`codex mcp add ${SERVER_NAME} --url ${M2_MCP_URL}`, 'sign in (browser)'],
42
+ // 顺序敏感:login 的本地回调端口挂在 login 子进程上,必须授权完成后再重启 Codex,所以重启提示放在最后。
43
+ restart: 'Fully quit and reopen Codex.',
44
+ apply() {
45
+ const add = run(bin, ['mcp', 'add', SERVER_NAME, '--url', M2_MCP_URL]);
46
+ if (add.status !== 0)
47
+ return { ok: false, note: add.output };
48
+ return { ok: true, signIn: signInWith(bin, ['mcp', 'login', SERVER_NAME]) };
49
+ },
50
+ };
51
+ }
52
+ function qoder() {
53
+ // CLI 写 ~/.qoder/settings.json,Qoder 桌面端读同一份,一次覆盖两端。
54
+ const bin = firstRunnable([which('qodercli'), join(home, '.local', 'bin', 'qodercli')]);
55
+ if (!bin)
56
+ return null;
57
+ return {
58
+ label: 'Qoder',
59
+ existing: jsonExisting(join(home, '.qoder', 'settings.json'), findInQoderSettings),
60
+ blocked: null,
61
+ actions: [`qodercli mcp add -s user -t http ${SERVER_NAME} ${M2_MCP_URL}`],
62
+ restart: 'Reopen Qoder if it is running.',
63
+ apply() {
64
+ const add = run(bin, ['mcp', 'add', '-s', 'user', '-t', 'http', SERVER_NAME, M2_MCP_URL]);
65
+ if (add.status !== 0)
66
+ return { ok: false, note: add.output };
67
+ // qodercli 没有 mcp login 子命令,授权只能在 UI 里点。
68
+ return {
69
+ ok: true,
70
+ signIn: 'manual',
71
+ note: 'Sign in: Qoder → Extensions → Connectors → m2 → Authorize (or run qodercli, then /mcp).',
72
+ };
73
+ },
74
+ };
75
+ }
76
+ function opencode() {
77
+ const bin = which('opencode') ?? firstExecutable(['/Applications/OpenCode.app/Contents/Resources/opencode-cli']);
78
+ if (!bin)
79
+ return null;
80
+ const dir = join(process.env.XDG_CONFIG_HOME ?? join(home, '.config'), 'opencode');
81
+ const path = join(dir, 'opencode.json');
82
+ const base = {
83
+ label: 'OpenCode',
84
+ existing: null,
85
+ blocked: null,
86
+ actions: [`add "${SERVER_NAME}" to ${path}`, 'sign in (browser)'],
87
+ restart: 'Restart OpenCode.',
88
+ apply: () => ({ ok: false }),
89
+ };
90
+ // jsonc 允许注释,按 JSON 回写会抹掉用户注释 —— 不碰,交给用户手动加。
91
+ if (existsSync(join(dir, 'opencode.jsonc'))) {
92
+ return { ...base, blocked: `uses opencode.jsonc; add "${SERVER_NAME}": { "type": "remote", "url": "${M2_MCP_URL}" } under "mcp" by hand.` };
93
+ }
94
+ const read = readJson(path);
95
+ if (read.kind === 'invalid')
96
+ return { ...base, blocked: `${path} is not valid JSON (${read.error}).` };
97
+ const merged = mergeOpencodeConfig(read.kind === 'ok' ? read.value : {});
98
+ return {
99
+ ...base,
100
+ existing: merged.existing,
101
+ apply() {
102
+ writeJsonWithBackup(path, merged.config);
103
+ return { ok: true, signIn: signInWith(bin, ['mcp', 'auth', SERVER_NAME]) };
104
+ },
105
+ };
106
+ }
107
+ function cursor() {
108
+ const bin = which('cursor-agent');
109
+ const dir = join(home, '.cursor');
110
+ if (!bin && !existsSync(dir))
111
+ return null;
112
+ const path = join(dir, 'mcp.json');
113
+ const read = readJson(path);
114
+ const base = {
115
+ label: 'Cursor',
116
+ existing: null,
117
+ blocked: null,
118
+ actions: [`add "${SERVER_NAME}" to ${path}`, ...(bin ? ['cursor-agent mcp enable m2', 'sign in (browser)'] : [])],
119
+ restart: 'Restart Cursor if it is running.',
120
+ apply: () => ({ ok: false }),
121
+ };
122
+ if (read.kind === 'invalid')
123
+ return { ...base, blocked: `${path} is not valid JSON (${read.error}).` };
124
+ const current = read.kind === 'ok' ? read.value : {};
125
+ const merged = mergeCursorConfig(current);
126
+ return {
127
+ ...base,
128
+ existing: merged.existing,
129
+ apply() {
130
+ writeJsonWithBackup(path, merged.config);
131
+ if (!bin) {
132
+ return { ok: true, signIn: 'manual', note: 'Sign in: Cursor Settings → MCP → enable m2 and log in.' };
133
+ }
134
+ // 不 enable 会停在 "needs approval",login 也加载不到它。
135
+ const enable = run(bin, ['mcp', 'enable', SERVER_NAME]);
136
+ if (enable.status !== 0)
137
+ return { ok: true, signIn: 'failed', note: enable.output };
138
+ return { ok: true, signIn: signInWith(bin, ['mcp', 'login', SERVER_NAME]) };
139
+ },
140
+ };
141
+ }
142
+ export function detectClients() {
143
+ return [claudeCode(), codex(), cursor(), opencode(), qoder()].filter((plan) => plan !== null);
144
+ }
@@ -0,0 +1,90 @@
1
+ // 各客户端配置文件的纯函数读写:查已有 m2 入口、按原格式合并。全部不碰磁盘,便于单测。
2
+ export const M2_MCP_URL = 'https://mcp.m2ui.ai/mcp';
3
+ export const SERVER_NAME = 'm2';
4
+ function isObject(value) {
5
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
6
+ }
7
+ /** 按 host 判定是不是 M2 的 MCP —— 老入口可能带尾斜杠、query 或别的 path。 */
8
+ export function isM2Url(value) {
9
+ if (typeof value !== 'string')
10
+ return false;
11
+ try {
12
+ return new URL(value).host === new URL(M2_MCP_URL).host;
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ }
18
+ /** 在 `{ name: { url } }` 形状的 server 表里找指向 M2 的入口名(任意命名都算)。 */
19
+ export function findM2Entry(servers) {
20
+ if (!isObject(servers))
21
+ return null;
22
+ for (const [name, entry] of Object.entries(servers)) {
23
+ if (isObject(entry) && (isM2Url(entry.url) || isM2Url(entry.serverUrl)))
24
+ return name;
25
+ }
26
+ return null;
27
+ }
28
+ /** Claude Code `~/.claude.json` 顶层 mcpServers = user scope。 */
29
+ export function findInClaudeConfig(config) {
30
+ return isObject(config) ? findM2Entry(config.mcpServers) : null;
31
+ }
32
+ /** Qoder `~/.qoder/settings.json`,CLI 与桌面端共用。 */
33
+ export function findInQoderSettings(config) {
34
+ return isObject(config) ? findM2Entry(config.mcpServers) : null;
35
+ }
36
+ /**
37
+ * Codex `~/.codex/config.toml`:不引 TOML 解析器,只按行跟踪 `[mcp_servers.<name>]` 段,
38
+ * 段内出现 M2 url 即命中。只用于「是否已配置」的判断,不做写入。
39
+ */
40
+ export function findInCodexToml(toml) {
41
+ let section = null;
42
+ for (const raw of toml.split(/\r?\n/)) {
43
+ const line = raw.trim();
44
+ const header = /^\[mcp_servers\.("?)([^\]"]+)\1\]$/.exec(line);
45
+ if (header) {
46
+ section = header[2];
47
+ continue;
48
+ }
49
+ if (line.startsWith('[')) {
50
+ section = null;
51
+ continue;
52
+ }
53
+ const url = /^url\s*=\s*["']([^"']+)["']/.exec(line);
54
+ if (section && url && isM2Url(url[1]))
55
+ return section;
56
+ }
57
+ return null;
58
+ }
59
+ /** Cursor `~/.cursor/mcp.json`:合并 `{ url }`,保留其余 server。 */
60
+ export function mergeCursorConfig(config) {
61
+ const base = isObject(config) ? config : {};
62
+ const existing = findM2Entry(base.mcpServers);
63
+ if (existing)
64
+ return { existing, config: base };
65
+ const servers = isObject(base.mcpServers) ? base.mcpServers : {};
66
+ return {
67
+ existing: null,
68
+ config: { ...base, mcpServers: { ...servers, [SERVER_NAME]: { url: M2_MCP_URL } } },
69
+ };
70
+ }
71
+ /**
72
+ * OpenCode `opencode.json`:`mcp` 有两种形状 —— V1 扁平 `mcp.<name>` 与 V2 嵌套
73
+ * `mcp.servers.<name>`。实测 `opencode mcp add` 恒写 V2,而文件已有 V1 扁平入口时,
74
+ * 新入口会被当成名为 "servers" 的 server,auth 报 "MCP server not found"。
75
+ * 所以跟随文件已有形状:只有纯 V2 文件写嵌套,其余(含空文件、混合)写扁平 —— V2 也读扁平。
76
+ */
77
+ export function mergeOpencodeConfig(config) {
78
+ const base = isObject(config) ? config : {};
79
+ const mcp = isObject(base.mcp) ? base.mcp : {};
80
+ const nested = isObject(mcp.servers) ? mcp.servers : null;
81
+ const flatEntries = Object.fromEntries(Object.entries(mcp).filter(([key]) => key !== 'servers'));
82
+ const existing = findM2Entry(flatEntries) ?? findM2Entry(nested);
83
+ if (existing)
84
+ return { existing, config: base };
85
+ const useNested = nested !== null && Object.keys(flatEntries).length === 0;
86
+ const nextMcp = useNested
87
+ ? { ...mcp, servers: { ...nested, [SERVER_NAME]: { type: 'remote', url: M2_MCP_URL } } }
88
+ : { ...mcp, [SERVER_NAME]: { type: 'remote', url: M2_MCP_URL, enabled: true } };
89
+ return { existing: null, config: { ...base, mcp: nextMcp } };
90
+ }
package/dist/system.js ADDED
@@ -0,0 +1,81 @@
1
+ // 本机探测与文件 / 子进程 IO。只放副作用,逻辑判断留在 config-files.ts。
2
+ import { spawnSync } from 'node:child_process';
3
+ import { accessSync, constants, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { delimiter, dirname, join } from 'node:path';
6
+ export const home = homedir();
7
+ /** 在 PATH 里找可执行文件;不 spawn `which`,Windows 下补 PATHEXT 后缀。 */
8
+ export function which(command) {
9
+ const exts = process.platform === 'win32' ? (process.env.PATHEXT ?? '.EXE;.CMD').split(';') : [''];
10
+ for (const dir of (process.env.PATH ?? '').split(delimiter)) {
11
+ if (!dir)
12
+ continue;
13
+ for (const ext of exts) {
14
+ const candidate = join(dir, command + ext);
15
+ if (isExecutable(candidate))
16
+ return candidate;
17
+ }
18
+ }
19
+ return null;
20
+ }
21
+ export function isExecutable(path) {
22
+ try {
23
+ accessSync(path, constants.X_OK);
24
+ return true;
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ export function firstExecutable(paths) {
31
+ return paths.find(isExecutable) ?? null;
32
+ }
33
+ /**
34
+ * 候选里第一个真能跑起来的(`--version` 退出码 0)。PATH 上的 shim 可能是坏的 ——
35
+ * 实测 Homebrew/npm 装的 `codex` 指向已删除的 vendor 二进制,spawn 直接 ENOENT,
36
+ * 而 Codex 桌面端自带的 CLI 是好的;只看「存在且可执行」会挑中坏的那个。
37
+ */
38
+ export function firstRunnable(candidates) {
39
+ for (const candidate of candidates) {
40
+ if (candidate && isExecutable(candidate) && run(candidate, ['--version']).status === 0)
41
+ return candidate;
42
+ }
43
+ return null;
44
+ }
45
+ export { existsSync };
46
+ export function readJson(path) {
47
+ if (!existsSync(path))
48
+ return { kind: 'missing' };
49
+ try {
50
+ return { kind: 'ok', value: JSON.parse(readFileSync(path, 'utf8')) };
51
+ }
52
+ catch (error) {
53
+ return { kind: 'invalid', error: error instanceof Error ? error.message : String(error) };
54
+ }
55
+ }
56
+ export function readText(path) {
57
+ return existsSync(path) ? readFileSync(path, 'utf8') : null;
58
+ }
59
+ /** 覆写前先备份到同目录 `<file>.bak-m2canvas`,返回备份路径(新建文件时为 null)。 */
60
+ export function writeJsonWithBackup(path, value) {
61
+ mkdirSync(dirname(path), { recursive: true });
62
+ let backup = null;
63
+ if (existsSync(path)) {
64
+ backup = `${path}.bak-m2canvas`;
65
+ copyFileSync(path, backup);
66
+ }
67
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
68
+ return backup;
69
+ }
70
+ /**
71
+ * 跑子进程。`interactive` 时继承当前终端 —— login / auth 要开浏览器并等回调,
72
+ * Claude Code 的 `mcp login` 还硬性要求 TTY(stdin 不是终端时直接失败),所以必须 inherit。
73
+ */
74
+ export function run(command, args, interactive = false) {
75
+ const result = spawnSync(command, args, {
76
+ stdio: interactive ? 'inherit' : 'pipe',
77
+ encoding: 'utf8',
78
+ });
79
+ const output = interactive ? '' : `${result.stdout ?? ''}${result.stderr ?? ''}`.trim();
80
+ return { status: result.error ? null : result.status, output: result.error ? result.error.message : output };
81
+ }
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@m2canvas/connect",
3
+ "version": "0.1.0",
4
+ "description": "Connect your local agent clients (Claude Code, Codex, Cursor, OpenCode, Qoder) to M2 Canvas over MCP in one command.",
5
+ "type": "module",
6
+ "bin": {
7
+ "m2canvas-connect": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "scripts": {
16
+ "build": "tsc -p tsconfig.json",
17
+ "prepublishOnly": "npm run build"
18
+ },
19
+ "homepage": "https://m2ui.ai/mcp",
20
+ "license": "MIT",
21
+ "publishConfig": {
22
+ "access": "public",
23
+ "registry": "https://registry.npmjs.org/"
24
+ }
25
+ }