@ainote/mcp 1.3.1 → 1.3.3

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.
@@ -0,0 +1,211 @@
1
+ /**
2
+ * `ainote-mcp call <tool> [--key=value ...] [--json '<obj>']`
3
+ *
4
+ * Direct shell invocation of an ainote MCP tool — no agent required. Useful for:
5
+ * - one-off CLI use, scripts, alfred/raycast workflows
6
+ * - debugging tool responses without spinning up Claude
7
+ * - automation that wants a single tool result, not an interactive loop
8
+ *
9
+ * Resolves the MCP key from (in order):
10
+ * 1. AINOTE_API_KEY env
11
+ * 2. token store (mcpKey written by `ainote-mcp login`)
12
+ *
13
+ * Posts JSON-RPC 2.0 `tools/call` to AINOTE_API_URL (default https://api.ainote.dev/api/mcp).
14
+ *
15
+ * Argument parsing supports two styles, in priority order:
16
+ * --json '{"due_today": true}' raw JSON object as arguments
17
+ * --key=value one flag per top-level field; values are
18
+ * JSON.parse-d (so `--limit=5` is 5, not "5";
19
+ * `--name=hello` falls back to the raw string
20
+ * if JSON.parse fails)
21
+ *
22
+ * Output:
23
+ * default — pretty JSON of the tool's result content
24
+ * --raw — entire JSON-RPC envelope
25
+ */
26
+ import process from 'node:process';
27
+ import axios from 'axios';
28
+ import { getCredentials } from '../auth/token-store.js';
29
+
30
+ const DEFAULT_API_URL = 'https://api.ainote.dev';
31
+
32
+ function parseArgs(argv) {
33
+ // argv shape: [toolName, ...flags]
34
+ const toolName = argv[0];
35
+ const flags = argv.slice(1);
36
+ const out = { tool: toolName, args: {}, raw: false, help: false };
37
+
38
+ for (let i = 0; i < flags.length; i++) {
39
+ const a = flags[i];
40
+ if (a === '--raw') { out.raw = true; continue; }
41
+ if (a === '-h' || a === '--help') { out.help = true; continue; }
42
+ if (a === '--json') {
43
+ const next = flags[++i];
44
+ if (!next) throw new Error('--json requires a JSON object argument');
45
+ let parsed;
46
+ try { parsed = JSON.parse(next); }
47
+ catch (e) { throw new Error(`--json value is not valid JSON: ${e.message}`); }
48
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
49
+ throw new Error('--json value must be a JSON object');
50
+ }
51
+ out.args = { ...out.args, ...parsed };
52
+ continue;
53
+ }
54
+ if (a.startsWith('--')) {
55
+ const eq = a.indexOf('=');
56
+ let key, rawVal;
57
+ if (eq > 0) {
58
+ key = a.slice(2, eq);
59
+ rawVal = a.slice(eq + 1);
60
+ } else {
61
+ key = a.slice(2);
62
+ rawVal = flags[++i];
63
+ if (rawVal === undefined) {
64
+ out.args[key] = true;
65
+ continue;
66
+ }
67
+ }
68
+ // Try JSON.parse first (numbers, booleans, null, arrays/objects),
69
+ // fall back to raw string.
70
+ let value;
71
+ try { value = JSON.parse(rawVal); }
72
+ catch { value = rawVal; }
73
+ out.args[key] = value;
74
+ continue;
75
+ }
76
+ throw new Error(`unexpected positional argument: ${a}`);
77
+ }
78
+
79
+ return out;
80
+ }
81
+
82
+ function printHelp() {
83
+ const help = `\
84
+ Usage: ainote-mcp call <tool> [options]
85
+
86
+ Direct shell invocation of an ainote MCP tool over HTTP. Bypasses any
87
+ running MCP host (Claude Desktop, Cursor, etc.) and posts directly to
88
+ api.ainote.dev — useful for scripts and one-off queries.
89
+
90
+ Authentication:
91
+ - AINOTE_API_KEY env var, OR
92
+ - mcp key from \`ainote-mcp login\` (stored in OS keychain)
93
+
94
+ Options:
95
+ --key=value pass an argument to the tool (JSON.parse-d, falls
96
+ back to string). Repeatable.
97
+ --json '<obj>' pass the entire arguments object as JSON. Merged
98
+ with any --key=value flags (--key=value wins on
99
+ conflict).
100
+ --raw print the full JSON-RPC envelope instead of just
101
+ the result content.
102
+ -h, --help this help.
103
+
104
+ Examples:
105
+ ainote-mcp call list_tasks --due_today=true
106
+ ainote-mcp call list_tasks --limit=5 --status=pending
107
+ ainote-mcp call handoff_save \\
108
+ --json '{"project":"demo","topic":"poc","content":"..."}'
109
+ ainote-mcp call handoff_get --project=demo --topic=poc
110
+ ainote-mcp call list_dev_categories
111
+ `;
112
+ process.stdout.write(help);
113
+ }
114
+
115
+ export async function run(argv) {
116
+ // argv: everything after the `call` subcommand
117
+ if (!argv || argv.length === 0) {
118
+ printHelp();
119
+ process.exit(1);
120
+ return;
121
+ }
122
+
123
+ let parsed;
124
+ try { parsed = parseArgs(argv); }
125
+ catch (e) {
126
+ console.error(`error: ${e.message}\n`);
127
+ printHelp();
128
+ process.exit(2);
129
+ return;
130
+ }
131
+
132
+ if (parsed.help || !parsed.tool || parsed.tool.startsWith('-')) {
133
+ printHelp();
134
+ process.exit(parsed.help ? 0 : 1);
135
+ return;
136
+ }
137
+
138
+ const apiUrl = (process.env.AINOTE_API_URL || DEFAULT_API_URL).replace(/\/+$/, '');
139
+
140
+ let mcpKey = process.env.AINOTE_API_KEY;
141
+ if (!mcpKey) {
142
+ const creds = await getCredentials(apiUrl);
143
+ mcpKey = creds?.mcpKey;
144
+ }
145
+ if (!mcpKey) {
146
+ console.error('error: no MCP key. Run `ainote-mcp login` or set AINOTE_API_KEY.');
147
+ process.exit(3);
148
+ return;
149
+ }
150
+
151
+ const body = {
152
+ jsonrpc: '2.0',
153
+ id: 1,
154
+ method: 'tools/call',
155
+ params: { name: parsed.tool, arguments: parsed.args }
156
+ };
157
+
158
+ let response;
159
+ try {
160
+ response = await axios.post(`${apiUrl}/api/mcp`, body, {
161
+ headers: {
162
+ 'Content-Type': 'application/json',
163
+ Authorization: `McpKey ${mcpKey}`,
164
+ Accept: 'application/json'
165
+ },
166
+ timeout: 60000,
167
+ validateStatus: () => true
168
+ });
169
+ } catch (e) {
170
+ console.error(`error: request failed: ${e.message}`);
171
+ process.exit(4);
172
+ return;
173
+ }
174
+
175
+ if (response.status >= 400 && response.status !== 200) {
176
+ console.error(`error: HTTP ${response.status}`);
177
+ console.error(JSON.stringify(response.data, null, 2));
178
+ process.exit(5);
179
+ return;
180
+ }
181
+
182
+ const env = response.data || {};
183
+ if (env.error) {
184
+ console.error(`error: ${env.error.message || 'unknown'} (code ${env.error.code})`);
185
+ if (env.error.data) console.error(JSON.stringify(env.error.data, null, 2));
186
+ process.exit(6);
187
+ return;
188
+ }
189
+
190
+ if (parsed.raw) {
191
+ console.log(JSON.stringify(env, null, 2));
192
+ return;
193
+ }
194
+
195
+ // Default: print just the result.content[] payload(s) so shell pipelines
196
+ // can grep / jq directly without the JSON-RPC envelope noise.
197
+ const content = env?.result?.content;
198
+ if (Array.isArray(content)) {
199
+ for (const block of content) {
200
+ if (block && typeof block === 'object' && 'text' in block) {
201
+ console.log(block.text);
202
+ } else {
203
+ console.log(JSON.stringify(block, null, 2));
204
+ }
205
+ }
206
+ return;
207
+ }
208
+
209
+ // Fallback — some tools may emit a non-standard shape; dump result.
210
+ console.log(JSON.stringify(env.result ?? env, null, 2));
211
+ }
package/lib/cli/login.js CHANGED
@@ -1,238 +1,238 @@
1
- /**
2
- * `ainote-mcp login` — interactive device-authorization grant flow.
3
- *
4
- * UX:
5
- * 1. Request device code from server.
6
- * 2. Display the user_code in an ASCII box.
7
- * 3. Print verification URL and try to open it in a browser (unless --no-browser).
8
- * 4. Spin while polling for token.
9
- * 5. On success, persist to keychain/file.
10
- *
11
- * Flags:
12
- * --scope=mcp,read,write Comma-separated scope list (default: mcp,read,write).
13
- * --no-browser Don't auto-open the verification URL.
14
- * --client-name=<name> Override client_name shown on the consent screen.
15
- *
16
- * SIGINT cancels the in-flight authorization (best effort) and exits 1.
17
- */
18
- import process from 'node:process';
19
- import os from 'node:os';
20
- import {
21
- requestDeviceCode,
22
- pollForToken,
23
- cancel,
24
- DeviceAccessDeniedError,
25
- DeviceExpiredError,
26
- DevicePkceError,
27
- DeviceNetworkError
28
- } from '../auth/device-flow.js';
29
- import { setCredentials, _credentialsLocations } from '../auth/token-store.js';
30
-
31
- const DEFAULT_API_URL = 'https://ainote-5muq.onrender.com';
32
- const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
33
-
34
- function parseArgs(argv) {
35
- const args = {
36
- scope: 'mcp,read,write',
37
- noBrowser: false,
38
- clientName: `ainote-mcp CLI (${os.hostname()})`
39
- };
40
- for (const arg of argv) {
41
- if (arg === '--no-browser') {
42
- args.noBrowser = true;
43
- } else if (arg.startsWith('--scope=')) {
44
- args.scope = arg.slice('--scope='.length);
45
- } else if (arg === '--scope') {
46
- // next-token form is not supported in this minimal parser
47
- args.scope = args.scope;
48
- } else if (arg.startsWith('--client-name=')) {
49
- args.clientName = arg.slice('--client-name='.length);
50
- }
51
- }
52
- return args;
53
- }
54
-
55
- function drawUserCodeBox(userCode) {
56
- const padded = ` ${userCode} `;
57
- const width = padded.length;
58
- const top = `┌${'─'.repeat(width)}┐`;
59
- const mid = `│${padded}│`;
60
- const bot = `└${'─'.repeat(width)}┘`;
61
- return `${top}\n${mid}\n${bot}`;
62
- }
63
-
64
- class Spinner {
65
- constructor() {
66
- this.frame = 0;
67
- this.suffix = '';
68
- this.timer = null;
69
- this.active = false;
70
- }
71
-
72
- start(initialSuffix = '') {
73
- if (this.active) return;
74
- this.active = true;
75
- this.suffix = initialSuffix;
76
- this.timer = setInterval(() => this._render(), 100);
77
- this._render();
78
- }
79
-
80
- update(suffix) {
81
- this.suffix = suffix;
82
- }
83
-
84
- _render() {
85
- const frame = SPINNER_FRAMES[this.frame % SPINNER_FRAMES.length];
86
- this.frame += 1;
87
- if (process.stderr.isTTY) {
88
- process.stderr.write(`\r${frame} ${this.suffix}\x1b[K`);
89
- }
90
- }
91
-
92
- stop(finalLine) {
93
- if (!this.active) return;
94
- this.active = false;
95
- if (this.timer) {
96
- clearInterval(this.timer);
97
- this.timer = null;
98
- }
99
- if (process.stderr.isTTY) {
100
- process.stderr.write('\r\x1b[K');
101
- }
102
- if (finalLine) {
103
- process.stderr.write(`${finalLine}\n`);
104
- }
105
- }
106
- }
107
-
108
- async function tryOpenBrowser(uri) {
109
- try {
110
- const mod = await import('open');
111
- const open = mod.default || mod;
112
- await open(uri);
113
- return true;
114
- } catch (err) {
115
- return false;
116
- }
117
- }
118
-
119
- export async function run(argv = process.argv.slice(3)) {
120
- const apiUrl = (process.env.AINOTE_API_URL || DEFAULT_API_URL).replace(/\/+$/, '');
121
- const args = parseArgs(argv);
122
- const scopes = args.scope.split(',').map((s) => s.trim()).filter(Boolean);
123
-
124
- console.log('');
125
- console.log(` AI Note MCP — Login`);
126
- console.log(` API: ${apiUrl}`);
127
- console.log('');
128
-
129
- let device;
130
- try {
131
- device = await requestDeviceCode({
132
- apiUrl,
133
- clientName: args.clientName,
134
- scopes
135
- });
136
- } catch (err) {
137
- if (err instanceof DeviceNetworkError) {
138
- console.error(` Network error: ${err.message}`);
139
- } else {
140
- console.error(` Failed to start login: ${err.message}`);
141
- }
142
- process.exit(1);
143
- return;
144
- }
145
-
146
- console.log(` Visit the URL below and enter the code:`);
147
- console.log('');
148
- console.log(drawUserCodeBox(device.user_code));
149
- console.log('');
150
- console.log(` ${device.verification_uri}`);
151
- if (device.verification_uri_complete && device.verification_uri_complete !== device.verification_uri) {
152
- console.log(` (direct link: ${device.verification_uri_complete})`);
153
- }
154
- console.log('');
155
-
156
- if (!args.noBrowser) {
157
- const target = device.verification_uri_complete || device.verification_uri;
158
- const opened = await tryOpenBrowser(target);
159
- if (opened) {
160
- console.log(' Opening your browser…');
161
- } else {
162
- console.log(' (Could not open browser automatically — please open the URL above.)');
163
- }
164
- console.log('');
165
- }
166
-
167
- const spinner = new Spinner();
168
- spinner.start(`Waiting for authorization… (0s elapsed, next poll in ${device.interval}s)`);
169
-
170
- let cancelled = false;
171
- const sigintHandler = async () => {
172
- if (cancelled) return;
173
- cancelled = true;
174
- spinner.stop('Cancelled.');
175
- await cancel({ apiUrl, deviceCode: device.device_code });
176
- process.exit(1);
177
- };
178
- process.once('SIGINT', sigintHandler);
179
-
180
- try {
181
- const tokens = await pollForToken({
182
- apiUrl,
183
- deviceCode: device.device_code,
184
- codeVerifier: device.code_verifier,
185
- interval: device.interval,
186
- expiresIn: device.expires_in,
187
- onTick: ({ elapsed, interval }) => {
188
- spinner.update(`Waiting for authorization… (${elapsed}s elapsed, next poll in ${interval}s)`);
189
- }
190
- });
191
-
192
- spinner.stop();
193
- process.removeListener('SIGINT', sigintHandler);
194
-
195
- const expiresAt = tokens.expires_in
196
- ? Date.now() + Number(tokens.expires_in) * 1000
197
- : undefined;
198
-
199
- const persisted = await setCredentials(apiUrl, {
200
- mcpKey: tokens.mcp_key,
201
- accessToken: tokens.access_token,
202
- refreshToken: tokens.refresh_token,
203
- expiresAt
204
- });
205
-
206
- const email = tokens.user?.email;
207
- const locationDesc = persisted.storage === 'keychain'
208
- ? 'OS keychain'
209
- : persisted.storage === 'mixed'
210
- ? `OS keychain + ${persisted.file}`
211
- : persisted.file || _credentialsLocations().file;
212
-
213
- console.log('');
214
- if (email) {
215
- console.log(` ✓ Logged in as ${email}.`);
216
- } else {
217
- console.log(` ✓ Logged in.`);
218
- }
219
- console.log(` Credentials saved to ${locationDesc}.`);
220
- console.log('');
221
- } catch (err) {
222
- spinner.stop();
223
- process.removeListener('SIGINT', sigintHandler);
224
-
225
- if (err instanceof DeviceAccessDeniedError) {
226
- console.error(' Access denied. The authorization request was rejected.');
227
- } else if (err instanceof DeviceExpiredError) {
228
- console.error(' Code expired. Please run `ainote-mcp login` again.');
229
- } else if (err instanceof DevicePkceError) {
230
- console.error(' Authentication failed (PKCE mismatch). Please retry.');
231
- } else if (err instanceof DeviceNetworkError) {
232
- console.error(` Network error: ${err.message}`);
233
- } else {
234
- console.error(` Login failed: ${err.message}`);
235
- }
236
- process.exit(1);
237
- }
238
- }
1
+ /**
2
+ * `ainote-mcp login` — interactive device-authorization grant flow.
3
+ *
4
+ * UX:
5
+ * 1. Request device code from server.
6
+ * 2. Display the user_code in an ASCII box.
7
+ * 3. Print verification URL and try to open it in a browser (unless --no-browser).
8
+ * 4. Spin while polling for token.
9
+ * 5. On success, persist to keychain/file.
10
+ *
11
+ * Flags:
12
+ * --scope=mcp,read,write Comma-separated scope list (default: mcp,read,write).
13
+ * --no-browser Don't auto-open the verification URL.
14
+ * --client-name=<name> Override client_name shown on the consent screen.
15
+ *
16
+ * SIGINT cancels the in-flight authorization (best effort) and exits 1.
17
+ */
18
+ import process from 'node:process';
19
+ import os from 'node:os';
20
+ import {
21
+ requestDeviceCode,
22
+ pollForToken,
23
+ cancel,
24
+ DeviceAccessDeniedError,
25
+ DeviceExpiredError,
26
+ DevicePkceError,
27
+ DeviceNetworkError
28
+ } from '../auth/device-flow.js';
29
+ import { setCredentials, _credentialsLocations } from '../auth/token-store.js';
30
+
31
+ const DEFAULT_API_URL = 'https://ainote-5muq.onrender.com';
32
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
33
+
34
+ function parseArgs(argv) {
35
+ const args = {
36
+ scope: 'mcp,read,write',
37
+ noBrowser: false,
38
+ clientName: `ainote-mcp CLI (${os.hostname()})`
39
+ };
40
+ for (const arg of argv) {
41
+ if (arg === '--no-browser') {
42
+ args.noBrowser = true;
43
+ } else if (arg.startsWith('--scope=')) {
44
+ args.scope = arg.slice('--scope='.length);
45
+ } else if (arg === '--scope') {
46
+ // next-token form is not supported in this minimal parser
47
+ args.scope = args.scope;
48
+ } else if (arg.startsWith('--client-name=')) {
49
+ args.clientName = arg.slice('--client-name='.length);
50
+ }
51
+ }
52
+ return args;
53
+ }
54
+
55
+ function drawUserCodeBox(userCode) {
56
+ const padded = ` ${userCode} `;
57
+ const width = padded.length;
58
+ const top = `┌${'─'.repeat(width)}┐`;
59
+ const mid = `│${padded}│`;
60
+ const bot = `└${'─'.repeat(width)}┘`;
61
+ return `${top}\n${mid}\n${bot}`;
62
+ }
63
+
64
+ class Spinner {
65
+ constructor() {
66
+ this.frame = 0;
67
+ this.suffix = '';
68
+ this.timer = null;
69
+ this.active = false;
70
+ }
71
+
72
+ start(initialSuffix = '') {
73
+ if (this.active) return;
74
+ this.active = true;
75
+ this.suffix = initialSuffix;
76
+ this.timer = setInterval(() => this._render(), 100);
77
+ this._render();
78
+ }
79
+
80
+ update(suffix) {
81
+ this.suffix = suffix;
82
+ }
83
+
84
+ _render() {
85
+ const frame = SPINNER_FRAMES[this.frame % SPINNER_FRAMES.length];
86
+ this.frame += 1;
87
+ if (process.stderr.isTTY) {
88
+ process.stderr.write(`\r${frame} ${this.suffix}\x1b[K`);
89
+ }
90
+ }
91
+
92
+ stop(finalLine) {
93
+ if (!this.active) return;
94
+ this.active = false;
95
+ if (this.timer) {
96
+ clearInterval(this.timer);
97
+ this.timer = null;
98
+ }
99
+ if (process.stderr.isTTY) {
100
+ process.stderr.write('\r\x1b[K');
101
+ }
102
+ if (finalLine) {
103
+ process.stderr.write(`${finalLine}\n`);
104
+ }
105
+ }
106
+ }
107
+
108
+ async function tryOpenBrowser(uri) {
109
+ try {
110
+ const mod = await import('open');
111
+ const open = mod.default || mod;
112
+ await open(uri);
113
+ return true;
114
+ } catch (err) {
115
+ return false;
116
+ }
117
+ }
118
+
119
+ export async function run(argv = process.argv.slice(3)) {
120
+ const apiUrl = (process.env.AINOTE_API_URL || DEFAULT_API_URL).replace(/\/+$/, '');
121
+ const args = parseArgs(argv);
122
+ const scopes = args.scope.split(',').map((s) => s.trim()).filter(Boolean);
123
+
124
+ console.log('');
125
+ console.log(` AI Note MCP — Login`);
126
+ console.log(` API: ${apiUrl}`);
127
+ console.log('');
128
+
129
+ let device;
130
+ try {
131
+ device = await requestDeviceCode({
132
+ apiUrl,
133
+ clientName: args.clientName,
134
+ scopes
135
+ });
136
+ } catch (err) {
137
+ if (err instanceof DeviceNetworkError) {
138
+ console.error(` Network error: ${err.message}`);
139
+ } else {
140
+ console.error(` Failed to start login: ${err.message}`);
141
+ }
142
+ process.exit(1);
143
+ return;
144
+ }
145
+
146
+ console.log(` Visit the URL below and enter the code:`);
147
+ console.log('');
148
+ console.log(drawUserCodeBox(device.user_code));
149
+ console.log('');
150
+ console.log(` ${device.verification_uri}`);
151
+ if (device.verification_uri_complete && device.verification_uri_complete !== device.verification_uri) {
152
+ console.log(` (direct link: ${device.verification_uri_complete})`);
153
+ }
154
+ console.log('');
155
+
156
+ if (!args.noBrowser) {
157
+ const target = device.verification_uri_complete || device.verification_uri;
158
+ const opened = await tryOpenBrowser(target);
159
+ if (opened) {
160
+ console.log(' Opening your browser…');
161
+ } else {
162
+ console.log(' (Could not open browser automatically — please open the URL above.)');
163
+ }
164
+ console.log('');
165
+ }
166
+
167
+ const spinner = new Spinner();
168
+ spinner.start(`Waiting for authorization… (0s elapsed, next poll in ${device.interval}s)`);
169
+
170
+ let cancelled = false;
171
+ const sigintHandler = async () => {
172
+ if (cancelled) return;
173
+ cancelled = true;
174
+ spinner.stop('Cancelled.');
175
+ await cancel({ apiUrl, deviceCode: device.device_code });
176
+ process.exit(1);
177
+ };
178
+ process.once('SIGINT', sigintHandler);
179
+
180
+ try {
181
+ const tokens = await pollForToken({
182
+ apiUrl,
183
+ deviceCode: device.device_code,
184
+ codeVerifier: device.code_verifier,
185
+ interval: device.interval,
186
+ expiresIn: device.expires_in,
187
+ onTick: ({ elapsed, interval }) => {
188
+ spinner.update(`Waiting for authorization… (${elapsed}s elapsed, next poll in ${interval}s)`);
189
+ }
190
+ });
191
+
192
+ spinner.stop();
193
+ process.removeListener('SIGINT', sigintHandler);
194
+
195
+ const expiresAt = tokens.expires_in
196
+ ? Date.now() + Number(tokens.expires_in) * 1000
197
+ : undefined;
198
+
199
+ const persisted = await setCredentials(apiUrl, {
200
+ mcpKey: tokens.mcp_key,
201
+ accessToken: tokens.access_token,
202
+ refreshToken: tokens.refresh_token,
203
+ expiresAt
204
+ });
205
+
206
+ const email = tokens.user?.email;
207
+ const locationDesc = persisted.storage === 'keychain'
208
+ ? 'OS keychain'
209
+ : persisted.storage === 'mixed'
210
+ ? `OS keychain + ${persisted.file}`
211
+ : persisted.file || _credentialsLocations().file;
212
+
213
+ console.log('');
214
+ if (email) {
215
+ console.log(` ✓ Logged in as ${email}.`);
216
+ } else {
217
+ console.log(` ✓ Logged in.`);
218
+ }
219
+ console.log(` Credentials saved to ${locationDesc}.`);
220
+ console.log('');
221
+ } catch (err) {
222
+ spinner.stop();
223
+ process.removeListener('SIGINT', sigintHandler);
224
+
225
+ if (err instanceof DeviceAccessDeniedError) {
226
+ console.error(' Access denied. The authorization request was rejected.');
227
+ } else if (err instanceof DeviceExpiredError) {
228
+ console.error(' Code expired. Please run `ainote-mcp login` again.');
229
+ } else if (err instanceof DevicePkceError) {
230
+ console.error(' Authentication failed (PKCE mismatch). Please retry.');
231
+ } else if (err instanceof DeviceNetworkError) {
232
+ console.error(` Network error: ${err.message}`);
233
+ } else {
234
+ console.error(` Login failed: ${err.message}`);
235
+ }
236
+ process.exit(1);
237
+ }
238
+ }