@m2canvas/connect 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
- 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.
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"), it adds the server to every client 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
  |---|---|---|
package/dist/cli.js CHANGED
@@ -1,12 +1,23 @@
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';
10
+ import { M2_MCP_URL, SERVER_NAME } from './config-files.js';
5
11
  import { detectClients } from './clients.js';
12
+ import { run } from './system.js';
6
13
  const HELP = `Usage: npx @m2canvas/connect [--yes] [--dry-run]
7
14
 
8
15
  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.
16
+ (Claude Code, Codex, Cursor, OpenCode, Qoder), then signs each one in.
17
+
18
+ In a terminal it asks before changing anything and opens each sign-in in your browser.
19
+ When run by an agent (no terminal), it only adds the server and prints the remaining
20
+ sign-in steps.
10
21
 
11
22
  -y, --yes skip the confirmation prompt
12
23
  --dry-run only show what would change
@@ -21,14 +32,17 @@ async function confirm(question) {
21
32
  rl.close();
22
33
  }
23
34
  }
35
+ function shellQuote(value) {
36
+ return /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
37
+ }
24
38
  function printPlan(plans) {
25
39
  console.log(`\nM2 Canvas MCP: ${M2_MCP_URL}\n`);
26
40
  for (const plan of plans) {
27
41
  if (plan.existing) {
28
- console.log(` • ${plan.label}: already connected as "${plan.existing}" — skipping`);
42
+ console.log(` • ${plan.label}: already has M2 as "${plan.existing}", skipping`);
29
43
  }
30
44
  else if (plan.blocked) {
31
- console.log(` ! ${plan.label}: can't set up automatically — ${plan.blocked}`);
45
+ console.log(` ! ${plan.label}: can't set up automatically. ${plan.blocked}`);
32
46
  }
33
47
  else {
34
48
  console.log(` ✓ ${plan.label}`);
@@ -38,14 +52,11 @@ function printPlan(plans) {
38
52
  }
39
53
  console.log('');
40
54
  }
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();
55
+ function describePending(step) {
56
+ if (step.login) {
57
+ return `run \`${[step.login.command, ...step.login.args].map(shellQuote).join(' ')}\` (opens the browser sign-in)`;
58
+ }
59
+ return step.manual ?? '';
49
60
  }
50
61
  async function main() {
51
62
  const args = new Set(process.argv.slice(2));
@@ -53,6 +64,7 @@ async function main() {
53
64
  console.log(HELP);
54
65
  return 0;
55
66
  }
67
+ const interactive = Boolean(process.stdin.isTTY);
56
68
  const plans = detectClients();
57
69
  if (plans.length === 0) {
58
70
  console.log(`No supported agent client found.\nAdd this MCP server in your client by hand: ${M2_MCP_URL} (streamable HTTP, OAuth sign-in).`);
@@ -60,39 +72,68 @@ async function main() {
60
72
  }
61
73
  printPlan(plans);
62
74
  const todo = plans.filter((plan) => !plan.existing && !plan.blocked);
63
- if (todo.length === 0 || args.has('--dry-run'))
75
+ if (args.has('--dry-run'))
64
76
  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
- }
77
+ if (todo.length === 0) {
78
+ printExistingSignIn(plans, interactive);
79
+ return 0;
80
+ }
81
+ // 非 TTY 下没人能回答确认提示 —— 执行本命令的 agent 就是替用户操作的一方。
82
+ if (interactive && !args.has('-y') && !args.has('--yes')) {
71
83
  if (!(await confirm(`Set up ${todo.map((plan) => plan.label).join(', ')}? (Y/n) `)))
72
84
  return 0;
73
85
  }
74
86
  const results = [];
75
87
  for (const plan of todo) {
76
- console.log(`\n→ ${plan.label}: finish the sign-in in your browser if one opens.`);
77
- let outcome;
88
+ let added;
78
89
  try {
79
- outcome = plan.apply();
90
+ const outcome = plan.add();
91
+ added = { plan, added: outcome.ok, error: outcome.note, pending: outcome.ok ? plan.signIn(interactive, SERVER_NAME) : null };
80
92
  }
81
93
  catch (error) {
82
- outcome = { ok: false, note: error instanceof Error ? error.message : String(error) };
94
+ added = { plan, added: false, error: error instanceof Error ? error.message : String(error), pending: null };
83
95
  }
84
- results.push([plan, outcome]);
96
+ const login = added.pending?.login;
97
+ if (interactive && login) {
98
+ console.log(`\n→ ${plan.label}: finish the sign-in in your browser.`);
99
+ if (run(login.command, login.args, true).status === 0)
100
+ added.pending = null;
101
+ }
102
+ results.push(added);
103
+ }
104
+ console.log('\nResult');
105
+ for (const result of results) {
106
+ const state = !result.added ? `failed: ${result.error ?? 'unknown error'}` : result.pending ? 'added, sign-in pending' : 'added and signed in';
107
+ console.log(` ${result.plan.label}: ${state}`);
108
+ }
109
+ const pending = results.filter((result) => result.added && result.pending);
110
+ if (pending.length > 0) {
111
+ console.log('\nTo finish signing in:');
112
+ for (const result of pending)
113
+ console.log(` - ${result.plan.label}: ${describePending(result.pending)}`);
85
114
  }
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}`);
115
+ const loaded = results.filter((result) => result.added);
116
+ if (loaded.length > 0) {
117
+ console.log('\nThen, to load M2 Canvas:');
118
+ for (const result of loaded)
119
+ console.log(` - ${result.plan.restart}`);
120
+ }
121
+ printExistingSignIn(plans, interactive);
122
+ return results.every((result) => result.added) ? 0 : 1;
123
+ }
124
+ /**
125
+ * 已有入口不代表已授权:agent 首轮写入配置后用户可能还没登录,重粘起手式时 agent 仍没有工具、
126
+ * 会再跑一次本命令。此时只报 "skipping" 就是死路,所以把这些入口的授权步骤也列出来。
127
+ * 安装器读不到授权态(token 在各客户端自己的存储里),只能写成「如果还没登录」。
128
+ */
129
+ function printExistingSignIn(plans, interactive) {
130
+ const existing = plans.filter((plan) => plan.existing);
131
+ if (existing.length === 0)
132
+ return;
133
+ console.log('\nAlready added. If a client still shows M2 as not signed in:');
134
+ for (const plan of existing) {
135
+ console.log(` - ${plan.label}: ${describePending(plan.signIn(interactive, plan.existing))}`);
94
136
  }
95
- return results.every(([, outcome]) => outcome.ok) ? 0 : 1;
96
137
  }
97
138
  main().then((code) => process.exit(code), (error) => {
98
139
  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, } from './system.js';
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}`, 'sign in (browser)'],
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
- 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
- },
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}`, '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]) };
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
- 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
- },
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}`, 'sign in (browser)'],
82
+ actions: [`add "${SERVER_NAME}" to ${path}`],
87
83
  restart: 'Restart OpenCode.',
88
- apply: () => ({ ok: false }),
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
- apply() {
99
+ add() {
102
100
  writeJsonWithBackup(path, merged.config);
103
- return { ok: true, signIn: signInWith(bin, ['mcp', 'auth', SERVER_NAME]) };
101
+ return { ok: true };
104
102
  },
105
103
  };
106
104
  }
@@ -115,27 +113,26 @@ function cursor() {
115
113
  label: 'Cursor',
116
114
  existing: null,
117
115
  blocked: null,
118
- actions: [`add "${SERVER_NAME}" to ${path}`, ...(bin ? ['cursor-agent mcp enable m2', 'sign in (browser)'] : [])],
116
+ actions: [`add "${SERVER_NAME}" to ${path}`, ...(bin ? [`cursor-agent mcp enable ${SERVER_NAME}`] : [])],
119
117
  restart: 'Restart Cursor if it is running.',
120
- apply: () => ({ ok: false }),
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 current = read.kind === 'ok' ? read.value : {};
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
- apply() {
129
+ add() {
130
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 也加载不到它。
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
- if (enable.status !== 0)
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m2canvas/connect",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Connect your local agent clients (Claude Code, Codex, Cursor, OpenCode, Qoder) to M2 Canvas over MCP in one command.",
5
5
  "type": "module",
6
6
  "bin": {