@eforest-finance/agent-skills 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/.env.example ADDED
@@ -0,0 +1,13 @@
1
+ # aelf wallet private key (hex string, 64 chars)
2
+ # Used for signing transactions on aelf blockchain.
3
+ # WARNING: Never commit real private keys to version control.
4
+ AELF_PRIVATE_KEY=your_private_key_here
5
+
6
+ # Optional: override environment (default: mainnet)
7
+ # AELF_ENV=testnet
8
+
9
+ # Optional: override API base URL
10
+ # AELF_API_URL=https://test.eforest.finance/api
11
+
12
+ # Optional: override RPC URL
13
+ # AELF_RPC_URL=https://aelf-test-node.aelf.io
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 eForest Finance
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,187 @@
1
+ [English](README.md) | [中文](README_zh-CN.md)
2
+
3
+ # eForest Agent Skills
4
+
5
+ AI Agent Kit for aelf token lifecycle on [eForest](https://www.eforest.finance). Provides CLI, MCP Server, and SDK interfaces for:
6
+
7
+ - **buy-seed** — Purchase a SEED from the SymbolRegister contract
8
+ - **create-token** — Create a new FT token using an owned SEED (with cross-chain sync)
9
+ - **issue-token** — Issue tokens via Proxy ForwardCall
10
+
11
+ ## Quick Start
12
+
13
+ ### Prerequisites
14
+
15
+ - [Bun](https://bun.sh) >= 1.0
16
+ - An aelf wallet private key with ELF balance
17
+
18
+ ### Install
19
+
20
+ ```bash
21
+ git clone https://github.com/eforest-finance/eforest-agent-skills.git
22
+ cd eforest-agent-skills
23
+ bun install
24
+ ```
25
+
26
+ ### Configure
27
+
28
+ ```bash
29
+ cp .env.example .env
30
+ # Edit .env and set your AELF_PRIVATE_KEY
31
+ ```
32
+
33
+ ### CLI Usage
34
+
35
+ ```bash
36
+ # Check SEED price (dry-run)
37
+ bun run cli buy-seed --symbol MYTOKEN --issuer <your-address> --dry-run
38
+
39
+ # Buy SEED (max 2 ELF)
40
+ bun run cli buy-seed --symbol MYTOKEN --issuer <your-address> --force 2
41
+
42
+ # Create token on tDVV side chain
43
+ bun run cli create-token \
44
+ --symbol MYTOKEN --token-name "My Token" \
45
+ --seed-symbol SEED-321 \
46
+ --total-supply 100000000 --decimals 8 \
47
+ --issue-chain tDVV
48
+
49
+ # Issue tokens
50
+ bun run cli issue-token \
51
+ --symbol MYTOKEN --amount 10000000000000000 \
52
+ --to <recipient-address> --chain tDVV
53
+ ```
54
+
55
+ ## MCP Server (Claude Desktop / Cursor)
56
+
57
+ One-command setup for AI platforms:
58
+
59
+ ```bash
60
+ # Claude Desktop
61
+ bun run setup claude
62
+
63
+ # Cursor IDE (project-level)
64
+ bun run setup cursor
65
+
66
+ # Cursor IDE (global)
67
+ bun run setup cursor --global
68
+
69
+ # Check status
70
+ bun run setup list
71
+ ```
72
+
73
+ Then edit the generated config to replace `<YOUR_PRIVATE_KEY>` with your actual key.
74
+
75
+ ### Manual MCP Config
76
+
77
+ If you prefer manual configuration, add this to your MCP settings:
78
+
79
+ ```json
80
+ {
81
+ "mcpServers": {
82
+ "eforest-token": {
83
+ "command": "bun",
84
+ "args": ["run", "/path/to/eforest-agent-skills/src/mcp/server.ts"],
85
+ "env": {
86
+ "AELF_PRIVATE_KEY": "your_private_key",
87
+ "EFOREST_NETWORK": "mainnet"
88
+ }
89
+ }
90
+ }
91
+ }
92
+ ```
93
+
94
+ ## OpenClaw
95
+
96
+ ```bash
97
+ # Generate OpenClaw config with absolute paths
98
+ bun run setup openclaw
99
+
100
+ # Merge into existing config
101
+ bun run setup openclaw --config-path /path/to/openclaw.json
102
+ ```
103
+
104
+ ## SDK Usage
105
+
106
+ ```typescript
107
+ import { buySeed, createToken, issueToken } from '@eforest-finance/agent-skills';
108
+ import { getNetworkConfig } from '@eforest-finance/agent-skills';
109
+
110
+ const config = await getNetworkConfig({ env: 'mainnet', privateKey: '...' });
111
+
112
+ const seedResult = await buySeed(config, {
113
+ symbol: 'MYTOKEN',
114
+ issueTo: config.walletAddress,
115
+ force: 2,
116
+ });
117
+
118
+ const tokenResult = await createToken(config, {
119
+ symbol: 'MYTOKEN',
120
+ tokenName: 'My Token',
121
+ seedSymbol: seedResult.seedSymbol!,
122
+ totalSupply: '100000000',
123
+ decimals: 8,
124
+ issuer: config.walletAddress,
125
+ issueChain: 'tDVV',
126
+ isBurnable: true,
127
+ tokenImage: '',
128
+ });
129
+ ```
130
+
131
+ ## Architecture
132
+
133
+ ```
134
+ eforest-agent-skills/
135
+ ├── lib/ # Infrastructure layer
136
+ │ ├── types.ts # Interfaces, constants, validators
137
+ │ ├── config.ts # Network config & .env loader
138
+ │ ├── aelf-client.ts # aelf-sdk wrapper
139
+ │ └── api-client.ts # eForest backend API client
140
+ ├── src/
141
+ │ ├── core/ # Pure business logic (no I/O side effects)
142
+ │ │ ├── seed.ts # buySeed + parseSeedSymbolFromLogs
143
+ │ │ ├── token.ts # createToken
144
+ │ │ └── issue.ts # issueToken + encodeIssueInput
145
+ │ └── mcp/
146
+ │ └── server.ts # MCP Server adapter (Zod validation)
147
+ ├── bin/
148
+ │ ├── setup.ts # Setup CLI entry point
149
+ │ └── platforms/ # Claude, Cursor, OpenClaw adapters
150
+ ├── create_token_skill.ts # CLI adapter (thin wrapper)
151
+ ├── index.ts # SDK entry (re-exports)
152
+ ├── openclaw.json # OpenClaw tool definitions
153
+ └── __tests__/ # Unit + Integration tests
154
+ ```
155
+
156
+ ## Configuration Priority
157
+
158
+ Settings are resolved in this order (highest priority first):
159
+
160
+ 1. Function params (SDK callers)
161
+ 2. CLI args (`--env`, `--rpc-url`)
162
+ 3. `EFOREST_*` / `AELF_*` environment variables
163
+ 4. `.env` file
164
+ 5. CMS remote config
165
+ 6. Code defaults (ENV_PRESETS)
166
+
167
+ ## Testing
168
+
169
+ ```bash
170
+ bun test # All tests
171
+ bun test:unit # Unit tests only
172
+ bun test:integration # Integration tests only
173
+ ```
174
+
175
+ ## Environment Variables
176
+
177
+ | Variable | Description | Default |
178
+ |----------|-------------|---------|
179
+ | `AELF_PRIVATE_KEY` | aelf wallet private key | (required) |
180
+ | `EFOREST_NETWORK` / `AELF_ENV` | `mainnet` or `testnet` | `mainnet` |
181
+ | `EFOREST_API_URL` / `AELF_API_URL` | Backend API URL | auto |
182
+ | `EFOREST_RPC_URL` / `AELF_RPC_URL` | AELF MainChain RPC | auto |
183
+ | `EFOREST_RPC_URL_TDVV` | tDVV RPC URL | auto |
184
+
185
+ ## License
186
+
187
+ [MIT](LICENSE)
@@ -0,0 +1,65 @@
1
+ // ============================================================
2
+ // Setup: Claude Desktop — write MCP config
3
+ // ============================================================
4
+
5
+ import {
6
+ getPlatformPaths,
7
+ readJsonFile,
8
+ writeJsonFile,
9
+ generateMcpEntry,
10
+ mergeMcpConfig,
11
+ removeMcpEntry,
12
+ SERVER_NAME,
13
+ LOG,
14
+ } from './utils';
15
+
16
+ export function setupClaude(opts: {
17
+ configPath?: string;
18
+ serverPath?: string;
19
+ force?: boolean;
20
+ }): boolean {
21
+ const configPath = opts.configPath || getPlatformPaths().claude;
22
+ const entry = generateMcpEntry(opts.serverPath);
23
+
24
+ LOG.step(`Config file: ${configPath}`);
25
+ LOG.step(`MCP server: ${entry.args[1]}`);
26
+
27
+ const existing = readJsonFile(configPath);
28
+ const { config, action } = mergeMcpConfig(
29
+ existing,
30
+ SERVER_NAME,
31
+ entry,
32
+ opts.force,
33
+ );
34
+
35
+ if (action === 'skipped') {
36
+ LOG.warn(`"${SERVER_NAME}" already exists in Claude Desktop config.`);
37
+ LOG.info('Use --force to overwrite.');
38
+ return false;
39
+ }
40
+
41
+ writeJsonFile(configPath, config);
42
+ LOG.success(`Claude Desktop MCP config ${action}: ${configPath}`);
43
+ LOG.info('');
44
+ LOG.warn(
45
+ 'IMPORTANT: Edit the config file and replace <YOUR_PRIVATE_KEY> with your actual aelf private key.',
46
+ );
47
+ LOG.info('Then restart Claude Desktop to pick up the new MCP server.');
48
+ return true;
49
+ }
50
+
51
+ export function uninstallClaude(opts: { configPath?: string }): boolean {
52
+ const configPath = opts.configPath || getPlatformPaths().claude;
53
+ const existing = readJsonFile(configPath);
54
+ const { config, removed } = removeMcpEntry(existing, SERVER_NAME);
55
+
56
+ if (!removed) {
57
+ LOG.info(`"${SERVER_NAME}" not found in Claude Desktop config.`);
58
+ return false;
59
+ }
60
+
61
+ writeJsonFile(configPath, config);
62
+ LOG.success(`Removed "${SERVER_NAME}" from Claude Desktop config.`);
63
+ LOG.info('Restart Claude Desktop to apply changes.');
64
+ return true;
65
+ }
@@ -0,0 +1,95 @@
1
+ // ============================================================
2
+ // Setup: Cursor — write MCP config (project or global)
3
+ // ============================================================
4
+
5
+ import {
6
+ getPlatformPaths,
7
+ getCursorProjectPath,
8
+ readJsonFile,
9
+ writeJsonFile,
10
+ generateMcpEntry,
11
+ mergeMcpConfig,
12
+ removeMcpEntry,
13
+ SERVER_NAME,
14
+ LOG,
15
+ } from './utils';
16
+
17
+ export function setupCursor(opts: {
18
+ global?: boolean;
19
+ configPath?: string;
20
+ serverPath?: string;
21
+ force?: boolean;
22
+ projectDir?: string;
23
+ }): boolean {
24
+ let configPath: string;
25
+ let scope: string;
26
+
27
+ if (opts.configPath) {
28
+ configPath = opts.configPath;
29
+ scope = 'custom';
30
+ } else if (opts.global) {
31
+ configPath = getPlatformPaths().cursorGlobal;
32
+ scope = 'global';
33
+ } else {
34
+ configPath = getCursorProjectPath(opts.projectDir);
35
+ scope = 'project';
36
+ }
37
+
38
+ const entry = generateMcpEntry(opts.serverPath);
39
+
40
+ LOG.step(`Scope: ${scope}`);
41
+ LOG.step(`Config file: ${configPath}`);
42
+ LOG.step(`MCP server: ${entry.args[1]}`);
43
+
44
+ const existing = readJsonFile(configPath);
45
+ const { config, action } = mergeMcpConfig(
46
+ existing,
47
+ SERVER_NAME,
48
+ entry,
49
+ opts.force,
50
+ );
51
+
52
+ if (action === 'skipped') {
53
+ LOG.warn(`"${SERVER_NAME}" already exists in Cursor ${scope} config.`);
54
+ LOG.info('Use --force to overwrite.');
55
+ return false;
56
+ }
57
+
58
+ writeJsonFile(configPath, config);
59
+ LOG.success(`Cursor ${scope} MCP config ${action}: ${configPath}`);
60
+ LOG.info('');
61
+ LOG.warn(
62
+ 'IMPORTANT: Edit the config file and replace <YOUR_PRIVATE_KEY> with your actual aelf private key.',
63
+ );
64
+ LOG.info(
65
+ 'Cursor will auto-detect the new MCP server (or restart Cursor).',
66
+ );
67
+ return true;
68
+ }
69
+
70
+ export function uninstallCursor(opts: {
71
+ global?: boolean;
72
+ configPath?: string;
73
+ projectDir?: string;
74
+ }): boolean {
75
+ let configPath: string;
76
+ if (opts.configPath) {
77
+ configPath = opts.configPath;
78
+ } else if (opts.global) {
79
+ configPath = getPlatformPaths().cursorGlobal;
80
+ } else {
81
+ configPath = getCursorProjectPath(opts.projectDir);
82
+ }
83
+
84
+ const existing = readJsonFile(configPath);
85
+ const { config, removed } = removeMcpEntry(existing, SERVER_NAME);
86
+
87
+ if (!removed) {
88
+ LOG.info(`"${SERVER_NAME}" not found in Cursor config: ${configPath}`);
89
+ return false;
90
+ }
91
+
92
+ writeJsonFile(configPath, config);
93
+ LOG.success(`Removed "${SERVER_NAME}" from Cursor config: ${configPath}`);
94
+ return true;
95
+ }
@@ -0,0 +1,125 @@
1
+ // ============================================================
2
+ // Setup: OpenClaw — register skills from openclaw.json
3
+ // ============================================================
4
+
5
+ import * as path from 'path';
6
+ import * as fs from 'fs';
7
+ import { getPackageRoot, readJsonFile, writeJsonFile, LOG } from './utils';
8
+
9
+ /** Skill names owned by this package (for uninstall) */
10
+ const EFOREST_SKILL_NAMES = new Set([
11
+ 'aelf-buy-seed',
12
+ 'aelf-create-token',
13
+ 'aelf-issue-token',
14
+ ]);
15
+
16
+ export function setupOpenClaw(opts: {
17
+ configPath?: string;
18
+ cwd?: string;
19
+ force?: boolean;
20
+ }): boolean {
21
+ const packageRoot = getPackageRoot();
22
+ const sourceFile = path.join(packageRoot, 'openclaw.json');
23
+
24
+ if (!fs.existsSync(sourceFile)) {
25
+ LOG.error(`openclaw.json not found at ${sourceFile}`);
26
+ return false;
27
+ }
28
+
29
+ const source = readJsonFile(sourceFile);
30
+ const skills: any[] = source.skills || [];
31
+
32
+ if (!skills.length) {
33
+ LOG.error('No skills found in openclaw.json');
34
+ return false;
35
+ }
36
+
37
+ // Resolve working_directory for all skills — use explicit cwd, or package root
38
+ const resolvedCwd = opts.cwd || packageRoot;
39
+
40
+ const updatedSkills = skills.map((skill: any) => ({
41
+ ...skill,
42
+ working_directory: resolvedCwd,
43
+ }));
44
+
45
+ // If user specified a target config path, merge into it
46
+ if (opts.configPath) {
47
+ LOG.step(`Merging ${skills.length} skills into: ${opts.configPath}`);
48
+
49
+ const existing = readJsonFile(opts.configPath);
50
+ if (!existing.skills) existing.skills = [];
51
+
52
+ let added = 0;
53
+ let updated = 0;
54
+ let skipped = 0;
55
+
56
+ for (const skill of updatedSkills) {
57
+ const idx = existing.skills.findIndex(
58
+ (s: any) => s.name === skill.name,
59
+ );
60
+ if (idx >= 0) {
61
+ if (opts.force) {
62
+ existing.skills[idx] = skill;
63
+ updated++;
64
+ } else {
65
+ skipped++;
66
+ }
67
+ } else {
68
+ existing.skills.push(skill);
69
+ added++;
70
+ }
71
+ }
72
+
73
+ writeJsonFile(opts.configPath, existing);
74
+ LOG.success(
75
+ `OpenClaw config updated: ${added} added, ${updated} updated, ${skipped} skipped.`,
76
+ );
77
+ if (skipped > 0) {
78
+ LOG.info('Use --force to overwrite existing skills.');
79
+ }
80
+ } else {
81
+ // No target path: generate a standalone config file in current dir
82
+ const outPath = path.join(process.cwd(), 'eforest-openclaw.json');
83
+ LOG.step(`Generating OpenClaw config: ${outPath}`);
84
+ LOG.step(`Skill working_directory: ${resolvedCwd}`);
85
+
86
+ writeJsonFile(outPath, { skills: updatedSkills });
87
+ LOG.success(`OpenClaw config generated: ${outPath}`);
88
+ LOG.info(
89
+ `Contains ${updatedSkills.length} skills with working_directory set to: ${resolvedCwd}`,
90
+ );
91
+ LOG.info('Import this file into your OpenClaw configuration.');
92
+ }
93
+
94
+ return true;
95
+ }
96
+
97
+ export function uninstallOpenClaw(opts: { configPath?: string }): boolean {
98
+ if (!opts.configPath) {
99
+ LOG.info(
100
+ 'OpenClaw: no --config-path specified. Remove skills manually from your OpenClaw config.',
101
+ );
102
+ return false;
103
+ }
104
+
105
+ const existing = readJsonFile(opts.configPath);
106
+ if (!existing.skills?.length) {
107
+ LOG.info('No skills found in config.');
108
+ return false;
109
+ }
110
+
111
+ const before = existing.skills.length;
112
+ existing.skills = existing.skills.filter(
113
+ (s: any) => !EFOREST_SKILL_NAMES.has(s.name),
114
+ );
115
+ const removed = before - existing.skills.length;
116
+
117
+ if (removed === 0) {
118
+ LOG.info('No eForest skills found in config.');
119
+ return false;
120
+ }
121
+
122
+ writeJsonFile(opts.configPath, existing);
123
+ LOG.success(`Removed ${removed} eForest skills from OpenClaw config.`);
124
+ return true;
125
+ }
@@ -0,0 +1,180 @@
1
+ // ============================================================
2
+ // Setup Utilities — path detection, JSON merge, cross-platform
3
+ // ============================================================
4
+
5
+ import * as os from 'os';
6
+ import * as path from 'path';
7
+ import * as fs from 'fs';
8
+
9
+ // ============================================================================
10
+ // Constants
11
+ // ============================================================================
12
+
13
+ export const SERVER_NAME = 'eforest-token';
14
+
15
+ // ============================================================================
16
+ // Package root detection
17
+ // ============================================================================
18
+
19
+ /** Resolve the absolute path to this package's root directory */
20
+ export function getPackageRoot(): string {
21
+ // import.meta.dir points to bin/platforms/ — go up 2 levels
22
+ return path.resolve(import.meta.dir, '..', '..');
23
+ }
24
+
25
+ /** Resolve the absolute path to the MCP server.ts */
26
+ export function getMcpServerPath(): string {
27
+ return path.join(getPackageRoot(), 'src', 'mcp', 'server.ts');
28
+ }
29
+
30
+ /** Detect bun executable path */
31
+ export function getBunPath(): string {
32
+ const platform = os.platform();
33
+ try {
34
+ const cmd = platform === 'win32' ? 'where bun' : 'which bun';
35
+ const result = Bun.spawnSync(cmd.split(' '));
36
+ const stdout = result.stdout.toString().trim();
37
+ if (stdout) return stdout.split('\n')[0].trim();
38
+ } catch {}
39
+ return 'bun';
40
+ }
41
+
42
+ // ============================================================================
43
+ // Platform config paths
44
+ // ============================================================================
45
+
46
+ export interface PlatformPaths {
47
+ claude: string;
48
+ cursorGlobal: string;
49
+ }
50
+
51
+ export function getPlatformPaths(): PlatformPaths {
52
+ const home = os.homedir();
53
+ const platform = os.platform();
54
+
55
+ let claude: string;
56
+ if (platform === 'darwin') {
57
+ claude = path.join(
58
+ home,
59
+ 'Library',
60
+ 'Application Support',
61
+ 'Claude',
62
+ 'claude_desktop_config.json',
63
+ );
64
+ } else if (platform === 'win32') {
65
+ claude = path.join(
66
+ process.env.APPDATA || path.join(home, 'AppData', 'Roaming'),
67
+ 'Claude',
68
+ 'claude_desktop_config.json',
69
+ );
70
+ } else {
71
+ claude = path.join(
72
+ home,
73
+ '.config',
74
+ 'Claude',
75
+ 'claude_desktop_config.json',
76
+ );
77
+ }
78
+
79
+ const cursorGlobal = path.join(home, '.cursor', 'mcp.json');
80
+
81
+ return { claude, cursorGlobal };
82
+ }
83
+
84
+ export function getCursorProjectPath(projectDir?: string): string {
85
+ const dir = projectDir || process.cwd();
86
+ return path.join(dir, '.cursor', 'mcp.json');
87
+ }
88
+
89
+ // ============================================================================
90
+ // JSON file operations (safe merge)
91
+ // ============================================================================
92
+
93
+ /** Read a JSON file, return empty object if not exists or invalid */
94
+ export function readJsonFile(filePath: string): any {
95
+ try {
96
+ if (!fs.existsSync(filePath)) return {};
97
+ const content = fs.readFileSync(filePath, 'utf-8');
98
+ return JSON.parse(content);
99
+ } catch {
100
+ return {};
101
+ }
102
+ }
103
+
104
+ /** Write JSON to file, creating parent dirs if needed */
105
+ export function writeJsonFile(filePath: string, data: any): void {
106
+ const dir = path.dirname(filePath);
107
+ if (!fs.existsSync(dir)) {
108
+ fs.mkdirSync(dir, { recursive: true });
109
+ }
110
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
111
+ }
112
+
113
+ // ============================================================================
114
+ // MCP config generation
115
+ // ============================================================================
116
+
117
+ export interface McpServerEntry {
118
+ command: string;
119
+ args: string[];
120
+ env: Record<string, string>;
121
+ }
122
+
123
+ export function generateMcpEntry(customPath?: string): McpServerEntry {
124
+ const serverPath = customPath || getMcpServerPath();
125
+ return {
126
+ command: getBunPath(),
127
+ args: ['run', serverPath],
128
+ env: {
129
+ AELF_PRIVATE_KEY: '<YOUR_PRIVATE_KEY>',
130
+ EFOREST_NETWORK: 'mainnet',
131
+ },
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Merge our MCP server entry into an existing config.
137
+ * Does NOT overwrite other servers — only operates on `serverName`.
138
+ */
139
+ export function mergeMcpConfig(
140
+ existing: any,
141
+ serverName: string,
142
+ entry: McpServerEntry,
143
+ force: boolean = false,
144
+ ): { config: any; action: 'created' | 'updated' | 'skipped' } {
145
+ const config = { ...existing };
146
+ if (!config.mcpServers) config.mcpServers = {};
147
+
148
+ if (config.mcpServers[serverName] && !force) {
149
+ return { config, action: 'skipped' };
150
+ }
151
+
152
+ const action = config.mcpServers[serverName] ? 'updated' : 'created';
153
+ config.mcpServers[serverName] = entry;
154
+ return { config, action };
155
+ }
156
+
157
+ /** Remove our MCP server entry from config */
158
+ export function removeMcpEntry(
159
+ existing: any,
160
+ serverName: string,
161
+ ): { config: any; removed: boolean } {
162
+ const config = { ...existing };
163
+ if (!config.mcpServers || !config.mcpServers[serverName]) {
164
+ return { config, removed: false };
165
+ }
166
+ delete config.mcpServers[serverName];
167
+ return { config, removed: true };
168
+ }
169
+
170
+ // ============================================================================
171
+ // Console output helpers
172
+ // ============================================================================
173
+
174
+ export const LOG = {
175
+ success: (msg: string) => console.log(` ✅ ${msg}`),
176
+ info: (msg: string) => console.log(` ℹ️ ${msg}`),
177
+ warn: (msg: string) => console.log(` ⚠️ ${msg}`),
178
+ error: (msg: string) => console.error(` ❌ ${msg}`),
179
+ step: (msg: string) => console.log(` → ${msg}`),
180
+ };