epicmerch-mcp 1.0.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.
@@ -0,0 +1,97 @@
1
+ // src/adapters/openapi.js
2
+ import express from 'express';
3
+ import { sessionTools, sessionToolDefs } from '../tools/session.js';
4
+ import { developerTools, developerToolDefs } from '../tools/developer.js';
5
+ import { scaffoldTools, scaffoldToolDefs } from '../tools/scaffold.js';
6
+ import { merchantTools, merchantToolDefs } from '../tools/merchant.js';
7
+
8
+ function buildOpenApiSpec(toolDefs, serverUrl) {
9
+ const paths = {};
10
+ for (const def of toolDefs) {
11
+ const properties = {};
12
+ const required = [];
13
+ for (const [key, schema] of Object.entries(def.schema || {})) {
14
+ properties[key] = { type: schema.type, description: schema.description || '' };
15
+ if (schema.required) required.push(key);
16
+ }
17
+ paths[`/tools/${def.name}`] = {
18
+ post: {
19
+ operationId: def.name,
20
+ summary: def.description,
21
+ requestBody: {
22
+ content: {
23
+ 'application/json': {
24
+ schema: { type: 'object', properties, ...(required.length ? { required } : {}) },
25
+ },
26
+ },
27
+ },
28
+ responses: {
29
+ 200: {
30
+ description: 'Tool result',
31
+ content: { 'application/json': { schema: { type: 'object' } } },
32
+ },
33
+ },
34
+ },
35
+ };
36
+ }
37
+
38
+ return {
39
+ openapi: '3.1.0',
40
+ info: { title: 'EpicMerch MCP Tools', version: '1.0.0', description: 'EpicMerch tools for Claude and ChatGPT' },
41
+ servers: [{ url: serverUrl || 'https://mcp.epicmerch.in' }],
42
+ paths,
43
+ components: {
44
+ securitySchemes: {
45
+ apiKey: { type: 'apiKey', in: 'header', name: 'x-api-key' },
46
+ bearerAuth: { type: 'http', scheme: 'bearer' },
47
+ },
48
+ },
49
+ };
50
+ }
51
+
52
+ export function buildOpenApiApp(session, client) {
53
+ const app = express();
54
+ app.use(express.json());
55
+
56
+ const allToolDefs = [...sessionToolDefs, ...developerToolDefs, ...scaffoldToolDefs, ...merchantToolDefs];
57
+ const allHandlers = {
58
+ ...sessionTools(session),
59
+ ...developerTools(client),
60
+ ...scaffoldTools(),
61
+ ...merchantTools(client, session),
62
+ };
63
+
64
+ // X-Store-Name header middleware — switches active store per request
65
+ app.use((req, _res, next) => {
66
+ const storeName = req.headers['x-store-name'];
67
+ if (storeName) {
68
+ try { session.switch(storeName); } catch (_) { /* ignore unknown store names */ }
69
+ }
70
+ next();
71
+ });
72
+
73
+ // Serve OpenAPI spec
74
+ app.get('/openapi.json', (req, res) => {
75
+ const host = `${req.protocol}://${req.get('host')}`;
76
+ res.json(buildOpenApiSpec(allToolDefs, host));
77
+ });
78
+
79
+ // Register tool endpoints
80
+ for (const def of allToolDefs) {
81
+ app.post(`/tools/${def.name}`, async (req, res) => {
82
+ try {
83
+ const result = await allHandlers[def.name](req.body || {});
84
+ res.json(result);
85
+ } catch (err) {
86
+ res.status(500).json({ isError: true, content: [{ type: 'text', text: err.message }] });
87
+ }
88
+ });
89
+ }
90
+
91
+ return app;
92
+ }
93
+
94
+ export function startOpenApiAdapter(session, client, port = 3101) {
95
+ const app = buildOpenApiApp(session, client);
96
+ app.listen(port, () => console.error(`[EpicMerch MCP] OpenAPI server listening on :${port}`));
97
+ }
package/src/auth.js ADDED
@@ -0,0 +1,118 @@
1
+ import { readTokenStore } from './token-store.js';
2
+
3
+ export class Session {
4
+ #stores;
5
+ #active;
6
+ #apiUrl;
7
+ #jwts = new Map();
8
+ #authMode;
9
+ #tokenStore;
10
+
11
+ constructor({ stores, defaultStore, apiUrl, authMode = 'apikey', tokenStore = null }) {
12
+ if (!stores || Object.keys(stores).length === 0) {
13
+ throw new Error('At least one store must be configured in EPICMERCH_STORES');
14
+ }
15
+ this.#stores = { ...stores };
16
+ this.#active = defaultStore || Object.keys(stores)[0];
17
+ this.#apiUrl = (apiUrl || 'https://api.epicmerch.in/api').replace(/\/$/, '');
18
+ this.#authMode = authMode;
19
+ this.#tokenStore = tokenStore;
20
+
21
+ if (!(this.#active in this.#stores)) {
22
+ throw new Error(`Default store "${this.#active}" not found in EPICMERCH_STORES`);
23
+ }
24
+ }
25
+
26
+ activeStore() { return this.#active; }
27
+ getApiKey() { return this.#stores[this.#active]; }
28
+ getApiUrl() { return this.#apiUrl; }
29
+
30
+ getAuthMode() {
31
+ return this.#authMode;
32
+ }
33
+
34
+ getActiveTokenEntry() {
35
+ if (this.#authMode !== 'oauth' || !this.#tokenStore) return null;
36
+ const targetName = process.env.EPICMERCH_ACTIVE_STORE || this.#active;
37
+ return this.#tokenStore.stores[targetName] ?? null;
38
+ }
39
+
40
+ // Allow refresh.js to update the in-memory token store after a refresh
41
+ _updateTokenEntry(storeName, entry) {
42
+ if (this.#tokenStore?.stores) {
43
+ this.#tokenStore.stores[storeName] = entry;
44
+ }
45
+ if (this.#stores) {
46
+ this.#stores[storeName] = entry.accessToken;
47
+ // If this is the active store, getApiKey() now returns the new token
48
+ }
49
+ }
50
+
51
+ switch(name) {
52
+ if (!(name in this.#stores)) throw new Error(`Store "${name}" not configured`);
53
+ this.#active = name;
54
+ }
55
+
56
+ addStore(name, apiKey) {
57
+ this.#stores[name] = apiKey;
58
+ }
59
+
60
+ listStores() {
61
+ return Object.keys(this.#stores).map(name => ({
62
+ name,
63
+ active: name === this.#active,
64
+ }));
65
+ }
66
+
67
+ setJwt(storeName, jwt) { this.#jwts.set(storeName, jwt); }
68
+ getJwt(storeName) { return this.#jwts.get(storeName) ?? null; }
69
+ getActiveJwt() { return this.getJwt(this.#active); }
70
+
71
+ static parseStoresEnv(envString) {
72
+ if (!envString || !envString.trim()) {
73
+ throw new Error('EPICMERCH_STORES must not be empty');
74
+ }
75
+ return Object.fromEntries(
76
+ envString.split(',').map(pair => {
77
+ const eqIdx = pair.indexOf('=');
78
+ if (eqIdx === -1) throw new Error(`Invalid store entry (missing "="): "${pair.trim()}"`);
79
+ const name = pair.slice(0, eqIdx).trim();
80
+ const key = pair.slice(eqIdx + 1).trim();
81
+ if (!name || !key) throw new Error(`Invalid store entry (empty name or key): "${pair.trim()}"`);
82
+ return [name, key];
83
+ })
84
+ );
85
+ }
86
+
87
+ static fromEnv() {
88
+ // --- Prefer OAuth token file when present ---
89
+ let tokenStore = null;
90
+ try { tokenStore = readTokenStore(); } catch (_) { tokenStore = null; }
91
+
92
+ if (tokenStore && tokenStore.stores && Object.keys(tokenStore.stores).length > 0) {
93
+ // Build a name → accessToken map so getApiKey() returns the access token
94
+ const stores = Object.fromEntries(
95
+ Object.entries(tokenStore.stores).map(([name, e]) => [name, e.accessToken])
96
+ );
97
+ return new Session({
98
+ stores,
99
+ defaultStore: tokenStore.defaultStore || Object.keys(stores)[0],
100
+ apiUrl: process.env.EPICMERCH_API_URL || 'https://api.epicmerch.in/api',
101
+ authMode: 'oauth',
102
+ tokenStore,
103
+ });
104
+ }
105
+ // --- End token-file path ---
106
+
107
+ // Fallback: env-var API key mode
108
+ const storesEnv = process.env.EPICMERCH_STORES;
109
+ if (!storesEnv) throw new Error('EPICMERCH_STORES env var is required (format: name=key,name2=key2)');
110
+
111
+ return new Session({
112
+ stores: Session.parseStoresEnv(storesEnv),
113
+ defaultStore: process.env.EPICMERCH_DEFAULT_STORE,
114
+ apiUrl: process.env.EPICMERCH_API_URL,
115
+ authMode: 'apikey',
116
+ });
117
+ }
118
+ }
package/src/client.js ADDED
@@ -0,0 +1,81 @@
1
+ // src/client.js
2
+ import { wrapWithRefresh } from './refresh.js';
3
+
4
+ export class Client {
5
+ #session;
6
+ #request;
7
+
8
+ constructor(session) {
9
+ this.#session = session;
10
+
11
+ const rawRequest = async (endpoint, options = {}) => {
12
+ const url = `${this.#session.getApiUrl()}${endpoint}`;
13
+ // Re-evaluate headers on every call so a refreshed access token from
14
+ // wrapWithRefresh's retry actually goes on the wire. If headers is a
15
+ // function, call it now to pick up the freshly-updated session state.
16
+ const headers = typeof options.headers === 'function'
17
+ ? options.headers()
18
+ : options.headers;
19
+ const res = await fetch(url, { ...options, headers });
20
+ const data = await res.json();
21
+ if (!res.ok) {
22
+ const err = new Error(data.message || `API error ${res.status}`);
23
+ err.status = res.status;
24
+ throw err;
25
+ }
26
+ return data;
27
+ };
28
+
29
+ this.#request = wrapWithRefresh(rawRequest, session);
30
+ }
31
+
32
+ #baseHeaders() {
33
+ const headers = { 'Content-Type': 'application/json' };
34
+ const authMode = this.#session.getAuthMode?.();
35
+ if (authMode === 'oauth') {
36
+ const entry = this.#session.getActiveTokenEntry();
37
+ if (entry?.accessToken) {
38
+ headers['Authorization'] = `Bearer ${entry.accessToken}`;
39
+ }
40
+ } else {
41
+ headers['x-api-key'] = this.#session.getApiKey();
42
+ }
43
+ return headers;
44
+ }
45
+
46
+ #authHeaders() {
47
+ const jwt = this.#session.getActiveJwt();
48
+ return {
49
+ ...this.#baseHeaders(),
50
+ ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}),
51
+ };
52
+ }
53
+
54
+ get(endpoint) {
55
+ return this.#request(endpoint, { headers: () => this.#baseHeaders() });
56
+ }
57
+
58
+ post(endpoint, body) {
59
+ return this.#request(endpoint, { method: 'POST', headers: () => this.#baseHeaders(), body: JSON.stringify(body) });
60
+ }
61
+
62
+ authGet(endpoint) {
63
+ return this.#request(endpoint, { headers: () => this.#authHeaders() });
64
+ }
65
+
66
+ authPost(endpoint, body) {
67
+ return this.#request(endpoint, { method: 'POST', headers: () => this.#authHeaders(), body: JSON.stringify(body) });
68
+ }
69
+
70
+ authPut(endpoint, body) {
71
+ return this.#request(endpoint, { method: 'PUT', headers: () => this.#authHeaders(), body: JSON.stringify(body) });
72
+ }
73
+
74
+ authDelete(endpoint) {
75
+ return this.#request(endpoint, { method: 'DELETE', headers: () => this.#authHeaders() });
76
+ }
77
+
78
+ authPatch(endpoint, body) {
79
+ return this.#request(endpoint, { method: 'PATCH', headers: () => this.#authHeaders(), body: JSON.stringify(body) });
80
+ }
81
+ }
package/src/index.js ADDED
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ // src/index.js
3
+ import { login } from './login.js';
4
+ import { logout } from './logout.js';
5
+ import { status } from './status.js';
6
+ import { install, setup } from './install.js';
7
+
8
+ async function main() {
9
+ const argv = process.argv.slice(2);
10
+ const cmd = argv[0];
11
+
12
+ const storeFlag = argv.findIndex((a) => a === '--store');
13
+ const storeName = storeFlag >= 0 ? argv[storeFlag + 1] : 'default';
14
+
15
+ if (cmd === 'login') { await login({ storeName }); return; }
16
+ if (cmd === 'logout') { await logout({ storeName }); return; }
17
+ if (cmd === 'status') { await status(); return; }
18
+
19
+ if (cmd === 'install') {
20
+ const flagSet = new Set(argv);
21
+ const clientFlag = argv.findIndex((a) => a === '--client');
22
+ const clientName = clientFlag >= 0 ? argv[clientFlag + 1] : undefined;
23
+ const code = await install({
24
+ client: clientName,
25
+ yes: flagSet.has('--yes') || flagSet.has('-y'),
26
+ force: flagSet.has('--force'),
27
+ dryRun: flagSet.has('--dry-run'),
28
+ noSkills: flagSet.has('--no-skills'),
29
+ });
30
+ process.exit(code);
31
+ return;
32
+ }
33
+
34
+ if (cmd === 'setup') {
35
+ const code = await setup({ storeName });
36
+ process.exit(code);
37
+ return;
38
+ }
39
+
40
+ // No subcommand → start the MCP server (default behavior)
41
+ const { Session } = await import('./auth.js');
42
+ const { Client } = await import('./client.js');
43
+ const { startMcpAdapter } = await import('./adapters/mcp.js');
44
+ const { startOpenApiAdapter } = await import('./adapters/openapi.js');
45
+
46
+ const session = Session.fromEnv();
47
+ const client = new Client(session);
48
+
49
+ const mode = process.env.EPICMERCH_MODE || 'mcp';
50
+
51
+ if (mode === 'http') {
52
+ const port = parseInt(process.env.PORT || '3101', 10);
53
+ startOpenApiAdapter(session, client, port);
54
+ } else if (mode === 'both') {
55
+ const port = parseInt(process.env.PORT || '3101', 10);
56
+ startOpenApiAdapter(session, client, port);
57
+ await startMcpAdapter(session, client);
58
+ } else {
59
+ // Default: stdio MCP mode
60
+ await startMcpAdapter(session, client);
61
+ }
62
+ }
63
+
64
+ main().catch((err) => {
65
+ console.error(err.message || err);
66
+ process.exit(1);
67
+ });
package/src/install.js ADDED
@@ -0,0 +1,269 @@
1
+ // src/install.js
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, copyFileSync } from 'fs';
3
+ import { homedir, platform } from 'os';
4
+ import { join, dirname } from 'path';
5
+ import { fileURLToPath } from 'url';
6
+
7
+ const SERVER_NAME = 'epicmerch';
8
+ const SERVER_CONFIG = { command: 'npx', args: ['epicmerch-mcp'] };
9
+
10
+ // Resolve the directory of OUR bundled skills (~/.../packages/epicmerch-mcp/skills/)
11
+ function bundledSkillsDir() {
12
+ const __dir = dirname(fileURLToPath(import.meta.url));
13
+ return join(__dir, '..', 'skills');
14
+ }
15
+
16
+ // Default install location for slash commands across Claude Code, Cursor, etc.
17
+ function defaultCommandsDir() {
18
+ return join(homedir(), '.claude', 'commands');
19
+ }
20
+
21
+ /**
22
+ * Copy our bundled skill *.md files into ~/.claude/commands/ (or a
23
+ * specified dir). Skips files that already exist with identical content.
24
+ * @returns {{ installed: string[], skipped: string[], destDir: string }}
25
+ */
26
+ export function installSkills(opts = {}) {
27
+ const srcDir = bundledSkillsDir();
28
+ const destDir = opts.destDir || defaultCommandsDir();
29
+ if (!existsSync(srcDir)) {
30
+ return { installed: [], skipped: [], destDir, error: `Bundled skills directory not found at ${srcDir}` };
31
+ }
32
+ mkdirSync(destDir, { recursive: true });
33
+
34
+ const installed = [];
35
+ const skipped = [];
36
+ const entries = readdirSync(srcDir).filter((f) => f.endsWith('.md'));
37
+ for (const name of entries) {
38
+ const src = join(srcDir, name);
39
+ const dst = join(destDir, name);
40
+ if (existsSync(dst) && !opts.force) {
41
+ const same = readFileSync(src, 'utf-8') === readFileSync(dst, 'utf-8');
42
+ if (same) { skipped.push(name); continue; }
43
+ if (!opts.force) { skipped.push(name); continue; } // don't overwrite without --force
44
+ }
45
+ copyFileSync(src, dst);
46
+ installed.push(name);
47
+ }
48
+ return { installed, skipped, destDir };
49
+ }
50
+
51
+ /**
52
+ * Detect installed MCP-aware clients on this machine.
53
+ * @returns {Array<{ client: 'claude'|'cursor', configPath: string }>}
54
+ */
55
+ export function detectClients() {
56
+ const results = [];
57
+ const plat = platform();
58
+
59
+ const claudeDir =
60
+ plat === 'darwin' ? join(homedir(), 'Library', 'Application Support', 'Claude') :
61
+ plat === 'win32' ? join(process.env.APPDATA || '', 'Claude') :
62
+ join(homedir(), '.config', 'Claude');
63
+ if (claudeDir && existsSync(claudeDir)) {
64
+ results.push({ client: 'claude', configPath: join(claudeDir, 'claude_desktop_config.json') });
65
+ }
66
+
67
+ const cursorDir = join(homedir(), '.cursor');
68
+ if (existsSync(cursorDir)) {
69
+ results.push({ client: 'cursor', configPath: join(cursorDir, 'mcp.json') });
70
+ }
71
+
72
+ return results;
73
+ }
74
+
75
+ /**
76
+ * Merge an MCP server entry into a config object. Returns a new object.
77
+ * @param {object} config - Existing config object
78
+ * @param {string} serverName - Name of the server entry
79
+ * @param {object} serverConfig - Server config to inject
80
+ * @param {{ overwrite?: boolean }} opts
81
+ * @returns {object}
82
+ */
83
+ export function mergeConfig(config, serverName, serverConfig, opts = {}) {
84
+ const out = { ...config };
85
+ out.mcpServers = { ...(out.mcpServers || {}) };
86
+ if (out.mcpServers[serverName] && !opts.overwrite) {
87
+ throw new Error(`'${serverName}' entry already exists in mcpServers. Use { overwrite: true } to replace.`);
88
+ }
89
+ out.mcpServers[serverName] = serverConfig;
90
+ return out;
91
+ }
92
+
93
+ /**
94
+ * Read existing config (if any), merge in desiredConfig, write back.
95
+ * @param {string} configPath - Absolute path to the JSON config file
96
+ * @param {object} desiredConfig - Config object to merge in
97
+ */
98
+ export function writeConfigFile(configPath, desiredConfig) {
99
+ let existing = {};
100
+ if (existsSync(configPath)) {
101
+ let raw;
102
+ try { raw = readFileSync(configPath, 'utf-8'); }
103
+ catch (e) { throw new Error(`Cannot read ${configPath}: ${e.message}`); }
104
+ if (raw.trim()) {
105
+ try { existing = JSON.parse(raw); }
106
+ catch (e) { throw new Error(`Config at ${configPath} is corrupt JSON: ${e.message}`); }
107
+ }
108
+ } else {
109
+ mkdirSync(dirname(configPath), { recursive: true });
110
+ }
111
+ const merged = {
112
+ ...existing,
113
+ ...desiredConfig,
114
+ mcpServers: { ...(existing.mcpServers || {}), ...(desiredConfig.mcpServers || {}) },
115
+ };
116
+ writeFileSync(configPath, JSON.stringify(merged, null, 2) + '\n');
117
+ }
118
+
119
+ /**
120
+ * Top-level install: detect clients, prompt unless --yes, apply.
121
+ * @param {{ client?: string, yes?: boolean, force?: boolean, dryRun?: boolean }} opts
122
+ * @returns {Promise<number>} exit code
123
+ */
124
+ export async function install(opts = {}) {
125
+ const clients = detectClients().filter((c) => !opts.client || c.client === opts.client);
126
+ if (clients.length === 0) {
127
+ console.error('No MCP-aware clients detected. Install Claude Desktop or Cursor first.');
128
+ return 1;
129
+ }
130
+
131
+ console.error('Detected clients:');
132
+ for (const c of clients) console.error(` ✓ ${c.client.padEnd(8)} → ${c.configPath}`);
133
+ console.error('');
134
+
135
+ if (opts.dryRun) {
136
+ console.error('Dry-run: would write `epicmerch` entry to the above. No changes made.');
137
+ return 0;
138
+ }
139
+
140
+ if (!opts.yes) {
141
+ const isTTY = process.stdin.isTTY;
142
+ if (isTTY) {
143
+ const readline = await import('node:readline');
144
+ const answer = await new Promise((resolve) => {
145
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
146
+ rl.question(`Add 'epicmerch' MCP server to all detected clients? [Y/n] `, (a) => { rl.close(); resolve(a.trim().toLowerCase()); });
147
+ });
148
+ if (answer && answer !== 'y' && answer !== 'yes') {
149
+ console.error('Cancelled.');
150
+ return 1;
151
+ }
152
+ }
153
+ }
154
+
155
+ for (const c of clients) {
156
+ try {
157
+ let existing = {};
158
+ if (existsSync(c.configPath)) {
159
+ const raw = readFileSync(c.configPath, 'utf-8').trim();
160
+ if (raw) existing = JSON.parse(raw);
161
+ }
162
+ const merged = mergeConfig(existing, SERVER_NAME, SERVER_CONFIG, { overwrite: opts.force });
163
+ writeConfigFile(c.configPath, merged);
164
+ console.error(`✓ Added to ${c.client}`);
165
+ } catch (e) {
166
+ if (/already exists/i.test(e.message) && !opts.force) {
167
+ console.error(`⚠ ${c.client}: entry already exists. Re-run with --force to overwrite.`);
168
+ } else {
169
+ console.error(`✗ ${c.client}: ${e.message}`);
170
+ }
171
+ }
172
+ }
173
+
174
+ // Also install slash commands (skills) unless explicitly opted out
175
+ if (!opts.noSkills) {
176
+ const result = installSkills({ force: opts.force });
177
+ if (result.error) {
178
+ console.error(`\n⚠ Skill install: ${result.error}`);
179
+ } else {
180
+ console.error('');
181
+ if (result.installed.length > 0) {
182
+ console.error(`✓ Installed ${result.installed.length} slash command(s) to ${result.destDir}:`);
183
+ for (const f of result.installed) console.error(` /${f.replace(/\.md$/, '')}`);
184
+ }
185
+ if (result.skipped.length > 0) {
186
+ console.error(` (${result.skipped.length} skill(s) already up to date — pass --force to overwrite)`);
187
+ }
188
+ }
189
+ }
190
+
191
+ console.error('');
192
+ console.error('Next:');
193
+ console.error(' 1. Run `npx epicmerch-mcp login` to authenticate (if you haven\'t already).');
194
+ console.error(' 2. Restart Claude Desktop / Cursor / VS Code for the config to take effect.');
195
+ return 0;
196
+ }
197
+
198
+ /**
199
+ * One-shot onboarding: login (skipped if already authed) → install
200
+ * (MCP config + slash commands). The merchant-friendly path; no
201
+ * filesystem knowledge required.
202
+ *
203
+ * @param {{ storeName?: string, force?: boolean }} opts
204
+ * @returns {Promise<number>} exit code
205
+ */
206
+ export async function setup(opts = {}) {
207
+ const storeName = opts.storeName || 'default';
208
+ console.error('━━━ EpicMerch setup ━━━');
209
+ console.error('');
210
+
211
+ // --- Step 1: Login (skipped if a valid token already exists) ---
212
+ // ESM dynamic import of an ESM-mocked token-store needs the same module specifier
213
+ // as the test mocks; the helper above uses createRequire under the hood when needed.
214
+ const skipLogin = await import('./token-store.js').then(async (m) => {
215
+ try {
216
+ const data = m.readTokenStore();
217
+ const entry = data?.stores?.[data?.defaultStore];
218
+ return entry && new Date(entry.refreshTokenExpiresAt) > new Date();
219
+ } catch (_) { return false; }
220
+ });
221
+
222
+ if (skipLogin) {
223
+ console.error('Step 1/2: ✓ Already authenticated (skipping browser login).');
224
+ } else {
225
+ console.error('Step 1/2: Authenticating via browser…');
226
+ console.error('');
227
+ try {
228
+ const { login } = await import('./login.js');
229
+ await login({ storeName });
230
+ } catch (e) {
231
+ console.error('');
232
+ console.error(`✗ Login failed: ${e.message}`);
233
+ console.error(' Re-run \`npx epicmerch-mcp setup\` to try again.');
234
+ return 1;
235
+ }
236
+ }
237
+
238
+ // --- Step 2: Install MCP config + skills ---
239
+ console.error('');
240
+ console.error('Step 2/2: Wiring up MCP config + slash commands…');
241
+ console.error('');
242
+ let installCode;
243
+ try {
244
+ installCode = await install({ yes: true, force: opts.force });
245
+ } catch (e) {
246
+ console.error('');
247
+ console.error(`✗ Install failed: ${e.message}`);
248
+ console.error(' You can retry just this step with: npx epicmerch-mcp install');
249
+ return 1;
250
+ }
251
+
252
+ if (installCode !== 0) {
253
+ console.error('');
254
+ console.error(`✗ Install returned exit code ${installCode}.`);
255
+ console.error(' You can retry just this step with: npx epicmerch-mcp install');
256
+ return installCode;
257
+ }
258
+
259
+ console.error('');
260
+ console.error('━━━ ✓ Done — restart your AI client(s) ━━━');
261
+ console.error('');
262
+ console.error('In Claude Desktop / Cursor, try a slash command:');
263
+ console.error(' /epicmerch full integration walkthrough');
264
+ console.error(' /epicmerch-stripe configure Stripe payments');
265
+ console.error(' /epicmerch-migrate migrate a Shopify store');
266
+ console.error('');
267
+ console.error('Or just ask: "Show me my EpicMerch products" — Claude will route it.');
268
+ return 0;
269
+ }