create-byan-agent 2.15.0 → 2.16.1

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.
@@ -416,6 +416,46 @@ Claude reconnaît l'agent BYAN et vous pouvez interagir avec lui.
416
416
 
417
417
  ---
418
418
 
419
+ ### 🔌 Extensions MCP optionnelles (depuis 2.16.0)
420
+
421
+ Pendant l'installation, BYAN propose d'activer des MCP servers tiers en plus du serveur byan natif.
422
+
423
+ **Disponibles aujourd'hui :**
424
+
425
+ | Extension | Description | Setup |
426
+ |-----------|-------------|-------|
427
+ | `gdrive` | Google Workspace : Docs, Sheets, Slides, Drive, Gmail, Calendar (95+ tools via `google-workspace-mcp`) | Interactif — guide Google Cloud + OAuth flow |
428
+
429
+ **Sécurité — où vivent les credentials :**
430
+
431
+ - Aucune credential n'est écrite dans le repo BYAN ni dans `.mcp.json` (source : [CLAIM L1] code de `install/packages/platform-config/lib/mcp-config.js`, fonction `assertNoSecretInEntry`)
432
+ - `~/.google-mcp/credentials.json` (perm 600) — Client OAuth Google
433
+ - `~/.google-mcp/tokens/<account>.json` — Tokens d'accès persistés par compte
434
+ - `BYAN_API_TOKEN` — vit uniquement dans `.env` (gitignored) + `.claude/settings.local.json` (gitignored)
435
+
436
+ **Si tu skip l'extension à l'install et veux l'activer plus tard**, relance `npx create-byan-agent` ou ajoute manuellement l'entry suivante à `.mcp.json` :
437
+
438
+ ```json
439
+ {
440
+ "mcpServers": {
441
+ "gdrive": {
442
+ "command": "npx",
443
+ "args": ["-y", "google-workspace-mcp", "serve"]
444
+ }
445
+ }
446
+ }
447
+ ```
448
+
449
+ Puis exécute le setup interactif du package :
450
+
451
+ ```bash
452
+ npx -y google-workspace-mcp setup
453
+ npx -y google-workspace-mcp accounts add default
454
+ npx -y google-workspace-mcp status # vérifier que tout est OK
455
+ ```
456
+
457
+ ---
458
+
419
459
  ## 5. Cas d'Usage Typiques
420
460
 
421
461
  ### 🎯 Cas 1 : Créer un Nouvel Agent
@@ -17,6 +17,7 @@ const { launchPhase2Chat, generateDefaultConfig } = require('../lib/phase2-chat'
17
17
  const { setupByanWebIntegration, validateByanWebReachability } = require('../lib/byan-web-integration');
18
18
  const { setupClaudeNative } = require('../lib/claude-native-setup');
19
19
  const { setupCodexNative } = require('../lib/codex-native-setup');
20
+ const { setupMcpExtensions } = require('../lib/mcp-extensions');
20
21
  const { setupStagingConsent } = require('../lib/staging-consent');
21
22
  const { getLatestVersion, compareVersions } = require('../lib/utils/version-compare');
22
23
 
@@ -1491,6 +1492,14 @@ async function install(options = {}) {
1491
1492
  }
1492
1493
  }
1493
1494
 
1495
+ if (needsClaude) {
1496
+ try {
1497
+ await setupMcpExtensions(projectRoot, {});
1498
+ } catch (error) {
1499
+ console.log(chalk.yellow(` ⚠ MCP extensions setup skipped: ${error.message}`));
1500
+ }
1501
+ }
1502
+
1494
1503
  // Step 8: Create config.yaml
1495
1504
  const configSpinner = ora('Generating configuration...').start();
1496
1505
 
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Google Workspace MCP extension (gdrive)
3
+ *
4
+ * Wraps the npm package `google-workspace-mcp` (Docs, Sheets, Slides, Drive,
5
+ * Gmail, Calendar, Forms — 95+ tools). The package ships its own CLI for
6
+ * interactive credential setup and persists everything under
7
+ * ~/.google-mcp/ (gitignored by virtue of being in $HOME).
8
+ *
9
+ * Our role here is:
10
+ * 1. Detect if the package is reachable (npx).
11
+ * 2. Detect if credentials are already on disk.
12
+ * 3. If not, walk the user through the Google Cloud setup steps with
13
+ * direct console links, and delegate the actual OAuth flow to the
14
+ * package's `setup` / `accounts add` CLI subcommands.
15
+ * 4. Provide the .mcp.json entry to register the server.
16
+ *
17
+ * No credential ever touches the project tree. The .mcp.json entry only
18
+ * declares command/args — every secret stays in ~/.google-mcp/.
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ const path = require('path');
24
+ const os = require('os');
25
+ const fs = require('fs-extra');
26
+ const chalk = require('chalk');
27
+ const inquirer = require('inquirer');
28
+ const { execSync, spawnSync } = require('child_process');
29
+
30
+ const PACKAGE_NAME = 'google-workspace-mcp';
31
+ const CONFIG_DIR = path.join(os.homedir(), '.google-mcp');
32
+ const CREDENTIALS_PATH = path.join(CONFIG_DIR, 'credentials.json');
33
+
34
+ const SETUP_LINKS = [
35
+ {
36
+ step: 'Créer un projet Google Cloud',
37
+ url: 'https://console.cloud.google.com/projectcreate',
38
+ },
39
+ {
40
+ step: 'Activer les APIs (Drive, Docs, Sheets, Slides, Gmail, Calendar, Forms)',
41
+ url: 'https://console.cloud.google.com/apis/library',
42
+ },
43
+ {
44
+ step: 'Configurer l\'écran de consentement OAuth (External, mode Test)',
45
+ url: 'https://console.cloud.google.com/apis/credentials/consent',
46
+ },
47
+ {
48
+ step: 'Créer un OAuth Client ID type "Desktop App"',
49
+ url: 'https://console.cloud.google.com/apis/credentials/oauthclient',
50
+ },
51
+ ];
52
+
53
+ async function isPackageInstallable() {
54
+ try {
55
+ execSync(`npm view ${PACKAGE_NAME} version --silent`, {
56
+ stdio: ['ignore', 'ignore', 'pipe'],
57
+ timeout: 30_000,
58
+ });
59
+ return true;
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ async function hasCredentials() {
66
+ return fs.pathExists(CREDENTIALS_PATH);
67
+ }
68
+
69
+ async function isConfigured() {
70
+ return hasCredentials();
71
+ }
72
+
73
+ function buildEntry() {
74
+ return {
75
+ command: 'npx',
76
+ args: ['-y', PACKAGE_NAME, 'serve'],
77
+ };
78
+ }
79
+
80
+ async function buildMcpEntry() {
81
+ return buildEntry();
82
+ }
83
+
84
+ function printSetupGuide(log) {
85
+ log();
86
+ log(chalk.cyan('Étapes Google Cloud (faire dans le navigateur, dans cet ordre) :'));
87
+ SETUP_LINKS.forEach((s, i) => {
88
+ log(chalk.gray(` ${i + 1}. ${s.step}`));
89
+ log(chalk.gray(` → ${s.url}`));
90
+ });
91
+ log();
92
+ log(chalk.gray(` 5. Télécharger le JSON OAuth Client (bouton "Download JSON")`));
93
+ log(chalk.gray(` 6. Renommer ce fichier en : credentials.json`));
94
+ log(chalk.gray(` 7. Le placer dans : ${CREDENTIALS_PATH}`));
95
+ log();
96
+ }
97
+
98
+ async function importCredentialsFromPath(srcPath, log) {
99
+ const abs = path.resolve(srcPath);
100
+ if (!(await fs.pathExists(abs))) {
101
+ throw new Error(`Fichier introuvable : ${abs}`);
102
+ }
103
+ let parsed;
104
+ try {
105
+ parsed = await fs.readJson(abs);
106
+ } catch (e) {
107
+ throw new Error(`JSON invalide : ${e.message}`);
108
+ }
109
+ // Sanity check — Google OAuth client JSON has either "installed" or "web"
110
+ if (!parsed.installed && !parsed.web) {
111
+ throw new Error(
112
+ `Format inattendu : ce fichier ne ressemble pas à un OAuth Client Google (clé "installed" ou "web" absente)`
113
+ );
114
+ }
115
+ await fs.ensureDir(CONFIG_DIR);
116
+ // Tighten dir perms : 700 (owner only). Best-effort on non-POSIX.
117
+ try {
118
+ await fs.chmod(CONFIG_DIR, 0o700);
119
+ } catch {
120
+ // ignore on platforms where this fails
121
+ }
122
+ await fs.writeFile(CREDENTIALS_PATH, JSON.stringify(parsed, null, 2), {
123
+ mode: 0o600,
124
+ });
125
+ log(chalk.green(` ✓ credentials.json copié vers ${CREDENTIALS_PATH} (perm 600)`));
126
+ }
127
+
128
+ async function runOAuthFlow(log) {
129
+ log();
130
+ log(chalk.cyan('Lancement du flow OAuth Google (le navigateur va s\'ouvrir)'));
131
+ log(chalk.gray(' Suis les instructions à l\'écran. Ferme la fenêtre quand le flow est terminé.'));
132
+ log();
133
+
134
+ const { accountName } = await inquirer.prompt([
135
+ {
136
+ type: 'input',
137
+ name: 'accountName',
138
+ message: 'Nom du compte Google (un slug — ex : "perso", "work") :',
139
+ default: 'default',
140
+ validate: (v) => /^[a-z0-9_-]+$/i.test(v) || 'Caractères autorisés : a-z, 0-9, _, -',
141
+ },
142
+ ]);
143
+
144
+ const result = spawnSync('npx', ['-y', PACKAGE_NAME, 'accounts', 'add', accountName], {
145
+ stdio: 'inherit',
146
+ timeout: 600_000, // 10 minutes
147
+ });
148
+
149
+ if (result.status !== 0) {
150
+ throw new Error(`google-workspace-mcp accounts add a échoué (exit ${result.status})`);
151
+ }
152
+ log(chalk.green(` ✓ Compte "${accountName}" ajouté`));
153
+ return accountName;
154
+ }
155
+
156
+ async function setup({ quiet } = {}) {
157
+ const log = quiet ? () => {} : (...a) => console.log(...a);
158
+
159
+ if (!(await isPackageInstallable())) {
160
+ return {
161
+ configured: false,
162
+ skipReason: `Le package npm "${PACKAGE_NAME}" n'est pas accessible (réseau / registre indisponible). Réessaie plus tard.`,
163
+ };
164
+ }
165
+
166
+ if (await hasCredentials()) {
167
+ log(chalk.gray(` · credentials.json déjà présent à ${CREDENTIALS_PATH}`));
168
+ const { reuse } = await inquirer.prompt([
169
+ {
170
+ type: 'confirm',
171
+ name: 'reuse',
172
+ message: 'Réutiliser la config existante (sans relancer OAuth) ?',
173
+ default: true,
174
+ },
175
+ ]);
176
+ if (reuse) {
177
+ return { configured: true, message: 'reused existing credentials' };
178
+ }
179
+ } else {
180
+ printSetupGuide(log);
181
+
182
+ const { hasJson } = await inquirer.prompt([
183
+ {
184
+ type: 'confirm',
185
+ name: 'hasJson',
186
+ message: 'Tu as téléchargé le credentials.json (étapes 1-5) ?',
187
+ default: false,
188
+ },
189
+ ]);
190
+
191
+ if (!hasJson) {
192
+ return {
193
+ configured: false,
194
+ skipReason:
195
+ 'Setup interrompu — relance l\'installer une fois le credentials.json téléchargé.',
196
+ };
197
+ }
198
+
199
+ const { jsonPath } = await inquirer.prompt([
200
+ {
201
+ type: 'input',
202
+ name: 'jsonPath',
203
+ message: 'Chemin local vers le credentials.json téléchargé :',
204
+ validate: (v) => v && v.trim().length > 0 || 'Chemin requis',
205
+ },
206
+ ]);
207
+
208
+ try {
209
+ await importCredentialsFromPath(jsonPath.trim(), log);
210
+ } catch (err) {
211
+ return { configured: false, skipReason: `Import des credentials échoué : ${err.message}` };
212
+ }
213
+ }
214
+
215
+ const { runAuth } = await inquirer.prompt([
216
+ {
217
+ type: 'confirm',
218
+ name: 'runAuth',
219
+ message: 'Lancer le flow OAuth maintenant (ouvre le navigateur) ?',
220
+ default: true,
221
+ },
222
+ ]);
223
+
224
+ if (!runAuth) {
225
+ return {
226
+ configured: true,
227
+ message:
228
+ 'credentials importés ; lance le flow OAuth plus tard via : npx -y google-workspace-mcp accounts add <name>',
229
+ };
230
+ }
231
+
232
+ try {
233
+ const account = await runOAuthFlow(log);
234
+ return { configured: true, message: `compte "${account}" authentifié` };
235
+ } catch (err) {
236
+ return {
237
+ configured: false,
238
+ skipReason:
239
+ `OAuth a échoué : ${err.message}. Relance manuellement : npx -y ${PACKAGE_NAME} accounts add <name>`,
240
+ };
241
+ }
242
+ }
243
+
244
+ module.exports = {
245
+ id: 'gdrive',
246
+ name: 'Google Workspace (Docs / Sheets / Slides / Drive / Gmail / Calendar)',
247
+ description: '95+ tools via google-workspace-mcp, OAuth2, creds in ~/.google-mcp/',
248
+ isConfigured,
249
+ setup,
250
+ buildMcpEntry,
251
+ // exposed for tests
252
+ buildEntry,
253
+ CONFIG_DIR,
254
+ CREDENTIALS_PATH,
255
+ PACKAGE_NAME,
256
+ };
@@ -0,0 +1,147 @@
1
+ /**
2
+ * MCP Extensions Registry — discovers and orchestrates third-party MCP
3
+ * server integrations (Google Workspace, etc.) during yanstaller install.
4
+ *
5
+ * Each extension is a module under this directory exposing the contract:
6
+ *
7
+ * {
8
+ * id : string // unique slug (becomes mcpServers key)
9
+ * name : string // human-readable name
10
+ * description : string // shown to user during prompt
11
+ * async isConfigured(): Promise<boolean> // already set up on this machine?
12
+ * async setup(options): Promise<{
13
+ * configured: boolean,
14
+ * message: string,
15
+ * skipReason?: string,
16
+ * }> // interactive: walks the user
17
+ * // through credential setup
18
+ * async buildMcpEntry(): Promise<object> // returns the entry to write
19
+ * // into mcpServers.<id>
20
+ * }
21
+ *
22
+ * The registry walks the list, prompts the user for each, runs setup if
23
+ * accepted, and writes the resulting MCP entry into .mcp.json via
24
+ * addMcpEntry (which refuses any entry containing a value that looks like
25
+ * a secret — see mcp-config.js).
26
+ *
27
+ * No secret value is ever stored by this module. Per-extension credential
28
+ * persistence is the extension's responsibility (typically under ~/.config/
29
+ * or ~/.<package>/, never inside the project).
30
+ */
31
+
32
+ 'use strict';
33
+
34
+ const path = require('path');
35
+ const fs = require('fs-extra');
36
+ const chalk = require('chalk');
37
+ const inquirer = require('inquirer');
38
+
39
+ const {
40
+ mcpConfig: { addMcpEntry },
41
+ } = require('byan-platform-config');
42
+
43
+ const gdrive = require('./gdrive');
44
+
45
+ const EXTENSIONS = [gdrive];
46
+
47
+ function listExtensions() {
48
+ return EXTENSIONS.map((ext) => ({
49
+ id: ext.id,
50
+ name: ext.name,
51
+ description: ext.description,
52
+ }));
53
+ }
54
+
55
+ function getExtension(id) {
56
+ return EXTENSIONS.find((ext) => ext.id === id) || null;
57
+ }
58
+
59
+ /**
60
+ * Walk every registered extension and offer it to the user. For each
61
+ * extension the user accepts, run its setup flow, then register the
62
+ * resulting MCP entry in .mcp.json.
63
+ *
64
+ * @param {string} projectRoot
65
+ * @param {{
66
+ * skipPrompts?: boolean,
67
+ * presetSelections?: Record<string, boolean>, // { gdrive: true }
68
+ * quiet?: boolean,
69
+ * }} options
70
+ * @returns {Promise<Array<{ id: string, configured: boolean, message: string }>>}
71
+ */
72
+ async function setupMcpExtensions(projectRoot, options = {}) {
73
+ const log = options.quiet ? () => {} : (...a) => console.log(...a);
74
+ const results = [];
75
+
76
+ if (EXTENSIONS.length === 0) return results;
77
+
78
+ log();
79
+ log(chalk.cyan('MCP extensions (optional third-party integrations)'));
80
+
81
+ for (const ext of EXTENSIONS) {
82
+ let want;
83
+ if (options.skipPrompts) {
84
+ want = options.presetSelections && options.presetSelections[ext.id] === true;
85
+ } else {
86
+ const answers = await inquirer.prompt([
87
+ {
88
+ type: 'confirm',
89
+ name: 'enable',
90
+ message: `${ext.name} — ${ext.description}\n Activer ?`,
91
+ default: false,
92
+ },
93
+ ]);
94
+ want = answers.enable === true;
95
+ }
96
+
97
+ if (!want) {
98
+ log(chalk.gray(` · ${ext.id}: skipped`));
99
+ results.push({ id: ext.id, configured: false, message: 'skipped by user' });
100
+ continue;
101
+ }
102
+
103
+ let setupResult;
104
+ try {
105
+ setupResult = await ext.setup({ projectRoot, quiet: options.quiet });
106
+ } catch (err) {
107
+ log(chalk.red(` ✘ ${ext.id} setup failed: ${err.message}`));
108
+ results.push({ id: ext.id, configured: false, message: `setup error: ${err.message}` });
109
+ continue;
110
+ }
111
+
112
+ if (!setupResult || setupResult.configured !== true) {
113
+ const reason = (setupResult && (setupResult.skipReason || setupResult.message)) || 'setup not completed';
114
+ log(chalk.yellow(` ⚠ ${ext.id}: ${reason}`));
115
+ results.push({ id: ext.id, configured: false, message: reason });
116
+ continue;
117
+ }
118
+
119
+ let entry;
120
+ try {
121
+ entry = await ext.buildMcpEntry({ projectRoot });
122
+ } catch (err) {
123
+ log(chalk.red(` ✘ ${ext.id} buildMcpEntry failed: ${err.message}`));
124
+ results.push({ id: ext.id, configured: false, message: `buildMcpEntry error: ${err.message}` });
125
+ continue;
126
+ }
127
+
128
+ try {
129
+ await addMcpEntry(projectRoot, ext.id, entry);
130
+ log(chalk.green(` ✓ ${ext.id} registered in .mcp.json`));
131
+ results.push({ id: ext.id, configured: true, message: 'registered' });
132
+ } catch (err) {
133
+ log(chalk.red(` ✘ ${ext.id} addMcpEntry failed: ${err.message}`));
134
+ results.push({ id: ext.id, configured: false, message: `addMcpEntry error: ${err.message}` });
135
+ }
136
+ }
137
+
138
+ return results;
139
+ }
140
+
141
+ module.exports = {
142
+ listExtensions,
143
+ getExtension,
144
+ setupMcpExtensions,
145
+ // re-exported for tests / programmatic callers
146
+ EXTENSIONS,
147
+ };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-byan-agent",
3
- "version": "2.15.0",
3
+ "version": "2.16.1",
4
4
  "description": "BYAN v2.2.2 - Intelligent AI agent installer with multi-platform native support (GitHub Copilot CLI, Claude Code, Codex/OpenCode)",
5
5
  "bin": {
6
6
  "create-byan-agent": "bin/create-byan-agent-v2.js"
@@ -3,7 +3,21 @@
3
3
  *
4
4
  * READ-MERGE-WRITE semantics : preserves all existing mcpServers.* entries
5
5
  * and, if byan entry already exists, preserves its command/args. Only the
6
- * env.BYAN_API_URL and env.BYAN_API_TOKEN are authoritative from caller.
6
+ * env.BYAN_API_URL is authoritative from caller.
7
+ *
8
+ * Security: BYAN_API_TOKEN is NEVER written into .mcp.json (which is
9
+ * checked into git). The token lives exclusively in:
10
+ * - .env (gitignored, for shell tools and Codex CLI)
11
+ * - .claude/settings.local.json (gitignored, for Claude Code MCP injection)
12
+ *
13
+ * Claude Code reads .claude/settings.local.json's "env" block at startup and
14
+ * injects those vars into every MCP server it spawns. So the token reaches
15
+ * the byan MCP server via that channel — no need to declare it in .mcp.json.
16
+ *
17
+ * The `token` parameter on this module's API is kept for backward-compat
18
+ * but is intentionally discarded (with a one-line audit trail in the
19
+ * returned result). Callers that supply a token should instead use
20
+ * envConfig.updateDotenv + envConfig.updateSettingsLocal.
7
21
  */
8
22
 
9
23
  const path = require('path');
@@ -11,6 +25,7 @@ const fs = require('fs-extra');
11
25
  const { stripApiSuffix } = require('./url-utils');
12
26
 
13
27
  const MCP_SERVER_REL_PATH = '_byan/mcp/byan-mcp-server/server.js';
28
+ const TOKEN_PLACEHOLDER = '${BYAN_API_TOKEN}';
14
29
 
15
30
  async function readJsonOrEmpty(filePath) {
16
31
  if (await fs.pathExists(filePath)) {
@@ -43,11 +58,14 @@ async function readMcpConfig(projectRoot) {
43
58
  * Pure merge — no I/O. Returns a new config object with byan entry merged.
44
59
  * Useful for migrations that inspect the diff before writing.
45
60
  *
61
+ * BYAN_API_TOKEN is intentionally stripped from env (never written into
62
+ * .mcp.json). See module header for the rationale and the persistence path.
63
+ *
46
64
  * @param {object} existingConfig — current parsed config (may be {} or {mcpServers:{...}})
47
- * @param {{ apiUrl: string, token?: string }} opts
65
+ * @param {{ apiUrl: string, token?: string }} opts — `token` is accepted but discarded
48
66
  * @returns {object} new merged config
49
67
  */
50
- function mergeByanEntry(existingConfig, { apiUrl, token } = {}) {
68
+ function mergeByanEntry(existingConfig, { apiUrl } = {}) {
51
69
  const cfg = existingConfig && typeof existingConfig === 'object' ? { ...existingConfig } : {};
52
70
  cfg.mcpServers = { ...(cfg.mcpServers || {}) };
53
71
 
@@ -56,11 +74,7 @@ function mergeByanEntry(existingConfig, { apiUrl, token } = {}) {
56
74
 
57
75
  const env = { ...(existing.env || {}) };
58
76
  env.BYAN_API_URL = cleanUrl;
59
- if (token && typeof token === 'string' && token.length > 0) {
60
- env.BYAN_API_TOKEN = token;
61
- } else {
62
- delete env.BYAN_API_TOKEN;
63
- }
77
+ delete env.BYAN_API_TOKEN;
64
78
 
65
79
  cfg.mcpServers.byan = {
66
80
  command: 'node',
@@ -87,9 +101,94 @@ async function ensureMcpConfig(projectRoot, { apiUrl, token } = {}) {
87
101
  return { path: filePath };
88
102
  }
89
103
 
104
+ /**
105
+ * Adds (or replaces) an arbitrary MCP server entry under mcpServers.<name>.
106
+ * Used by mcp-extensions to register third-party MCPs (gdrive, etc.) without
107
+ * touching the byan entry. Other entries are preserved.
108
+ *
109
+ * Security guard : refuses to write any value matching common token shapes
110
+ * directly into .mcp.json. Callers must put secrets in .env / settings.local
111
+ * and reference env-var names elsewhere.
112
+ *
113
+ * @param {string} projectRoot
114
+ * @param {string} name — server identifier (becomes the mcpServers key)
115
+ * @param {object} entry — { command, args?, env?, ... }
116
+ * @returns {Promise<{ path: string }>}
117
+ */
118
+ async function addMcpEntry(projectRoot, name, entry) {
119
+ if (!name || typeof name !== 'string') {
120
+ throw new Error('addMcpEntry: name must be a non-empty string');
121
+ }
122
+ if (!entry || typeof entry !== 'object') {
123
+ throw new Error('addMcpEntry: entry must be an object');
124
+ }
125
+ assertNoSecretInEntry(entry);
126
+
127
+ const filePath = path.join(projectRoot, '.mcp.json');
128
+ const current = await readJsonOrEmpty(filePath);
129
+ const cfg = current && typeof current === 'object' ? { ...current } : {};
130
+ cfg.mcpServers = { ...(cfg.mcpServers || {}) };
131
+ cfg.mcpServers[name] = entry;
132
+ await fs.writeJson(filePath, cfg, { spaces: 2 });
133
+ return { path: filePath };
134
+ }
135
+
136
+ /**
137
+ * Removes an MCP server entry. No-op if the entry does not exist.
138
+ *
139
+ * @param {string} projectRoot
140
+ * @param {string} name
141
+ * @returns {Promise<{ path: string, removed: boolean }>}
142
+ */
143
+ async function removeMcpEntry(projectRoot, name) {
144
+ const filePath = path.join(projectRoot, '.mcp.json');
145
+ const current = await readJsonOrEmpty(filePath);
146
+ if (!current || !current.mcpServers || !(name in current.mcpServers)) {
147
+ return { path: filePath, removed: false };
148
+ }
149
+ const cfg = { ...current, mcpServers: { ...current.mcpServers } };
150
+ delete cfg.mcpServers[name];
151
+ await fs.writeJson(filePath, cfg, { spaces: 2 });
152
+ return { path: filePath, removed: true };
153
+ }
154
+
155
+ const SECRET_SHAPES = [
156
+ /^byan_[a-f0-9]{20,}$/i, // byan tokens
157
+ /^ghp_[A-Za-z0-9]{30,}$/, // GitHub PAT
158
+ /^gho_[A-Za-z0-9]{30,}$/, // GitHub OAuth
159
+ /^github_pat_[A-Za-z0-9_]{60,}$/, // GitHub PAT (new format)
160
+ /^sk-ant-[A-Za-z0-9_-]{20,}$/, // Anthropic
161
+ /^sk-proj-[A-Za-z0-9_-]{20,}$/, // OpenAI project
162
+ /^AIza[0-9A-Za-z_-]{30,}$/, // Google API key
163
+ /^xox[bpao]-[0-9]+-[0-9]+-/, // Slack
164
+ /^AKIA[0-9A-Z]{16}$/, // AWS
165
+ ];
166
+
167
+ function looksLikeSecret(value) {
168
+ if (typeof value !== 'string') return false;
169
+ return SECRET_SHAPES.some((re) => re.test(value));
170
+ }
171
+
172
+ function assertNoSecretInEntry(entry) {
173
+ const env = (entry && entry.env) || {};
174
+ for (const [key, val] of Object.entries(env)) {
175
+ if (looksLikeSecret(val)) {
176
+ throw new Error(
177
+ `addMcpEntry: env.${key} looks like a secret. Refusing to write it into .mcp.json. ` +
178
+ `Put the value in .env (gitignored) and reference it via .claude/settings.local.json env, ` +
179
+ `or use \${VAR_NAME} if your MCP client supports env-var expansion.`
180
+ );
181
+ }
182
+ }
183
+ }
184
+
90
185
  module.exports = {
91
186
  ensureMcpConfig,
92
187
  readMcpConfig,
93
188
  mergeByanEntry,
189
+ addMcpEntry,
190
+ removeMcpEntry,
191
+ looksLikeSecret,
94
192
  MCP_SERVER_REL_PATH,
193
+ TOKEN_PLACEHOLDER,
95
194
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-byan-agent",
3
- "version": "2.15.0",
3
+ "version": "2.16.1",
4
4
  "description": "BYAN v2.8 - Intelligent AI agent creator with ELO trust system + scientific fact-check + Hermes universal dispatcher + native Claude Code integration (hooks, skills, MCP server). Multi-platform (Copilot CLI, Claude Code, Codex). Merise Agile + TDD + 64 Mantras. ~54% LLM cost savings.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -13,6 +13,7 @@ const fs = require('fs-extra');
13
13
 
14
14
  // Module under test (loaded once; all I/O goes to tmpdir per test)
15
15
  const { runMigration } = require('../lib/migrate-mcp-config');
16
+ const { mcpConfig: { TOKEN_PLACEHOLDER } } = require('byan-platform-config');
16
17
 
17
18
  // ──────────────────────────────────────────────────────────────────────────────
18
19
  // Helpers
@@ -69,16 +70,16 @@ test('returns no-byan-server if .mcp.json has other servers but no byan entry',
69
70
  });
70
71
 
71
72
  // ──────────────────────────────────────────────────────────────────────────────
72
- // Test 3 — byan entry already has token and URL without /api suffix → already-ok
73
+ // Test 3 — byan entry already has placeholder + clean URL → already-ok
73
74
  // ──────────────────────────────────────────────────────────────────────────────
74
- test('returns already-ok if byan entry has token and no /api suffix', async () => {
75
+ test('returns already-ok if byan entry has no BYAN_API_TOKEN (token belongs in .env / settings.local.json)', async () => {
75
76
  const dir = await makeTmp();
76
77
  await writeMcp(dir, {
77
78
  mcpServers: {
78
79
  byan: {
79
80
  command: 'node',
80
81
  args: ['_byan/mcp/byan-mcp-server/server.js'],
81
- env: { BYAN_API_URL: 'https://api.byan.io', BYAN_API_TOKEN: 'byan_tok' },
82
+ env: { BYAN_API_URL: 'https://api.byan.io' },
82
83
  },
83
84
  },
84
85
  });
@@ -91,7 +92,7 @@ test('returns already-ok if byan entry has token and no /api suffix', async () =
91
92
  // ──────────────────────────────────────────────────────────────────────────────
92
93
  // Test 4 — needs token but .env + settings.local.json both empty → no-token-available
93
94
  // ──────────────────────────────────────────────────────────────────────────────
94
- test('returns no-token-available if .mcp.json needs token but sources are empty', async () => {
95
+ test('returns already-ok if .mcp.json has no BYAN_API_TOKEN (token absence is now the desired state)', async () => {
95
96
  const dir = await makeTmp();
96
97
  await writeMcp(dir, {
97
98
  mcpServers: {
@@ -99,23 +100,19 @@ test('returns no-token-available if .mcp.json needs token but sources are empty'
99
100
  command: 'node',
100
101
  args: ['_byan/mcp/byan-mcp-server/server.js'],
101
102
  env: { BYAN_API_URL: 'https://api.byan.io' },
102
- // no BYAN_API_TOKEN
103
103
  },
104
104
  },
105
105
  });
106
- // no .env, no settings.local.json
107
106
  const result = await runMigration(dir);
108
107
  expect(result.migrated).toBe(false);
109
- expect(result.reason).toBe('no-token-available');
110
- expect(typeof result.hint).toBe('string');
111
- expect(result.hint.length).toBeGreaterThan(0);
108
+ expect(result.reason).toBe('already-ok');
112
109
  await fs.remove(dir);
113
110
  });
114
111
 
115
112
  // ──────────────────────────────────────────────────────────────────────────────
116
113
  // Test 5 — migrates successfully: token from .env
117
114
  // ──────────────────────────────────────────────────────────────────────────────
118
- test('migrates: token from .env .mcp.json env.BYAN_API_TOKEN', async () => {
115
+ test('with token already absent in .mcp.json + .env populated: no-op (already-ok), .env preserved untouched', async () => {
119
116
  const dir = await makeTmp();
120
117
  await writeMcp(dir, {
121
118
  mcpServers: {
@@ -129,19 +126,18 @@ test('migrates: token from .env → .mcp.json env.BYAN_API_TOKEN', async () => {
129
126
  await writeDotenv(dir, 'BYAN_API_TOKEN=byan_from_dotenv\n');
130
127
 
131
128
  const result = await runMigration(dir);
132
- expect(result.migrated).toBe(true);
133
- expect(result.reason).toBe('healed');
134
- expect(result.changes.length).toBeGreaterThanOrEqual(1);
129
+ expect(result.migrated).toBe(false);
130
+ expect(result.reason).toBe('already-ok');
135
131
 
136
- const written = await readMcp(dir);
137
- expect(written.mcpServers.byan.env.BYAN_API_TOKEN).toBe('byan_from_dotenv');
132
+ const dotenvAfter = await fs.readFile(path.join(dir, '.env'), 'utf8');
133
+ expect(dotenvAfter).toBe('BYAN_API_TOKEN=byan_from_dotenv\n');
138
134
  await fs.remove(dir);
139
135
  });
140
136
 
141
137
  // ──────────────────────────────────────────────────────────────────────────────
142
138
  // Test 6 — migrates successfully: token from .claude/settings.local.json fallback
143
139
  // ──────────────────────────────────────────────────────────────────────────────
144
- test('migrates: token from settings.local.json fallback', async () => {
140
+ test('with token in settings.local.json + absent from .mcp.json: no-op (already-ok), settings preserved', async () => {
145
141
  const dir = await makeTmp();
146
142
  await writeMcp(dir, {
147
143
  mcpServers: {
@@ -152,22 +148,20 @@ test('migrates: token from settings.local.json fallback', async () => {
152
148
  },
153
149
  },
154
150
  });
155
- // no .env, token in settings.local.json
156
151
  await writeSettingsLocal(dir, { env: { BYAN_API_TOKEN: 'byan_from_settings' } });
157
152
 
158
153
  const result = await runMigration(dir);
159
- expect(result.migrated).toBe(true);
160
- expect(result.reason).toBe('healed');
161
-
162
- const written = await readMcp(dir);
163
- expect(written.mcpServers.byan.env.BYAN_API_TOKEN).toBe('byan_from_settings');
154
+ expect(result.migrated).toBe(false);
155
+ expect(result.reason).toBe('already-ok');
156
+ const settings = await fs.readJson(path.join(dir, '.claude', 'settings.local.json'));
157
+ expect(settings.env.BYAN_API_TOKEN).toBe('byan_from_settings');
164
158
  await fs.remove(dir);
165
159
  });
166
160
 
167
161
  // ──────────────────────────────────────────────────────────────────────────────
168
162
  // Test 7 — strips /api suffix from BYAN_API_URL
169
163
  // ──────────────────────────────────────────────────────────────────────────────
170
- test('strips /api suffix from BYAN_API_URL', async () => {
164
+ test('strips /api suffix and extracts clear token to .env (token removed from .mcp.json)', async () => {
171
165
  const dir = await makeTmp();
172
166
  await writeMcp(dir, {
173
167
  mcpServers: {
@@ -184,7 +178,60 @@ test('strips /api suffix from BYAN_API_URL', async () => {
184
178
 
185
179
  const written = await readMcp(dir);
186
180
  expect(written.mcpServers.byan.env.BYAN_API_URL).toBe('https://api.byan.io');
187
- expect(written.mcpServers.byan.env.BYAN_API_TOKEN).toBe('byan_tok');
181
+ expect(written.mcpServers.byan.env.BYAN_API_TOKEN).toBeUndefined();
182
+ const dotenvContent = await fs.readFile(path.join(dir, '.env'), 'utf8');
183
+ expect(dotenvContent).toContain('BYAN_API_TOKEN=byan_tok');
184
+ const raw = await fs.readFile(path.join(dir, '.mcp.json'), 'utf8');
185
+ expect(raw).not.toContain('byan_tok');
186
+ await fs.remove(dir);
187
+ });
188
+
189
+ test('extracts clear token from .mcp.json into .env and removes it from .mcp.json (security migration)', async () => {
190
+ const dir = await makeTmp();
191
+ await writeMcp(dir, {
192
+ mcpServers: {
193
+ byan: {
194
+ command: 'node',
195
+ args: ['_byan/mcp/byan-mcp-server/server.js'],
196
+ env: { BYAN_API_URL: 'https://api.byan.io', BYAN_API_TOKEN: 'byan_leaked_in_clear' },
197
+ },
198
+ },
199
+ });
200
+
201
+ const result = await runMigration(dir);
202
+ expect(result.migrated).toBe(true);
203
+ expect(result.reason).toBe('healed');
204
+ expect(result.changes.some((c) => /extracted/i.test(c))).toBe(true);
205
+
206
+ const written = await readMcp(dir);
207
+ expect(written.mcpServers.byan.env.BYAN_API_TOKEN).toBeUndefined();
208
+
209
+ const raw = await fs.readFile(path.join(dir, '.mcp.json'), 'utf8');
210
+ expect(raw).not.toContain('byan_leaked_in_clear');
211
+
212
+ const dotenvContent = await fs.readFile(path.join(dir, '.env'), 'utf8');
213
+ expect(dotenvContent).toContain('BYAN_API_TOKEN=byan_leaked_in_clear');
214
+ await fs.remove(dir);
215
+ });
216
+
217
+ test('removes ${BYAN_API_TOKEN} placeholder from .mcp.json (no value to extract)', async () => {
218
+ const dir = await makeTmp();
219
+ await writeMcp(dir, {
220
+ mcpServers: {
221
+ byan: {
222
+ command: 'node',
223
+ args: ['_byan/mcp/byan-mcp-server/server.js'],
224
+ env: { BYAN_API_URL: 'https://api.byan.io', BYAN_API_TOKEN: TOKEN_PLACEHOLDER },
225
+ },
226
+ },
227
+ });
228
+
229
+ const result = await runMigration(dir);
230
+ expect(result.migrated).toBe(true);
231
+ expect(result.reason).toBe('healed');
232
+
233
+ const written = await readMcp(dir);
234
+ expect(written.mcpServers.byan.env.BYAN_API_TOKEN).toBeUndefined();
188
235
  await fs.remove(dir);
189
236
  });
190
237
 
@@ -215,6 +262,9 @@ test('dry-run: does not write .mcp.json but returns changes', async () => {
215
262
  const afterMcp = await readMcp(dir);
216
263
  expect(afterMcp.mcpServers.byan.env.BYAN_API_URL).toBe('https://api.byan.io/api');
217
264
  expect(afterMcp.mcpServers.byan.env.BYAN_API_TOKEN).toBeUndefined();
265
+ // .env must NOT have been touched on dry-run either
266
+ const dotenvAfter = await fs.readFile(path.join(dir, '.env'), 'utf8');
267
+ expect(dotenvAfter).toBe('BYAN_API_TOKEN=byan_dryrun_tok\n');
218
268
  await fs.remove(dir);
219
269
  });
220
270
 
@@ -1,16 +1,20 @@
1
1
  /**
2
2
  * migrate-mcp-config — self-healing migration for pre-fix BYAN installs.
3
3
  *
4
- * Injects BYAN_API_TOKEN into .mcp.json env block if absent, and strips the
5
- * legacy /api suffix from BYAN_API_URL. Uses byan-platform-config primitives
6
- * exclusively; no duplicated logic here.
4
+ * Heals three drift cases on .mcp.json:
5
+ * 1. token missing → injects ${BYAN_API_TOKEN} placeholder
6
+ * 2. url has /api suffix → strips it
7
+ * 3. token in clear → extracts to .env and replaces with placeholder
8
+ * (security fix from BYAN >=2.16.0)
9
+ *
10
+ * Uses byan-platform-config primitives exclusively; no duplicated logic here.
7
11
  */
8
12
 
9
13
  const chalk = require('chalk');
10
14
  const ora = require('ora');
11
15
  const {
12
- mcpConfig: { readMcpConfig, ensureMcpConfig },
13
- envConfig: { readEnvToken },
16
+ mcpConfig: { readMcpConfig, ensureMcpConfig, TOKEN_PLACEHOLDER },
17
+ envConfig: { readEnvToken, updateDotenv },
14
18
  urlUtils: { stripApiSuffix },
15
19
  } = require('byan-platform-config');
16
20
 
@@ -65,32 +69,27 @@ async function runMigration(projectRoot, { dryRun = false, verbose = false } = {
65
69
  stopSpinner(chalk.gray('.mcp.json lu'), true);
66
70
 
67
71
  // 3. Diagnose
68
- const tokenMissing = !byan.env || !byan.env.BYAN_API_TOKEN;
69
- const urlHasApiSuffix = /\/api\/?$/.test(byan.env && byan.env.BYAN_API_URL || '');
72
+ const currentToken = (byan.env && byan.env.BYAN_API_TOKEN) || '';
73
+ const tokenIsPlaceholder = currentToken === TOKEN_PLACEHOLDER;
74
+ const tokenInClear = !!currentToken && !tokenIsPlaceholder;
75
+ const tokenIsLegacy = !!currentToken; // any non-empty value is legacy: token must not live in .mcp.json
76
+ const urlHasApiSuffix = /\/api\/?$/.test(byan.env && byan.env.BYAN_API_URL || '');
70
77
 
71
78
  // 4. Nothing to do?
72
- if (!tokenMissing && !urlHasApiSuffix) {
73
- log(chalk.green(' .mcp.json est deja a jour (token present, URL correcte)'));
79
+ if (!tokenIsLegacy && !urlHasApiSuffix) {
80
+ log(chalk.green(' .mcp.json est deja a jour (token absent du fichier, URL correcte)'));
74
81
  return { migrated: false, reason: 'already-ok' };
75
82
  }
76
83
 
77
84
  const changes = [];
78
-
79
- // 5. Resolve token
80
- let token = byan.env && byan.env.BYAN_API_TOKEN; // may already exist (url-only fix)
81
- if (tokenMissing) {
82
- startSpinner('Recherche BYAN_API_TOKEN (.env / settings.local.json)...');
83
- token = await readEnvToken(projectRoot);
84
- if (!token) {
85
- stopSpinner(chalk.yellow('Token introuvable'), false);
86
- return {
87
- migrated: false,
88
- reason: 'no-token-available',
89
- hint: 'Re-run npx create-byan-agent to prompt for a token',
90
- };
91
- }
92
- stopSpinner(chalk.green('Token trouve'), true);
93
- changes.push('BYAN_API_TOKEN injected into .mcp.json env');
85
+ let extractedClearToken = null;
86
+
87
+ // 5. Detect a clear-text token to relocate to .env
88
+ if (tokenInClear) {
89
+ extractedClearToken = currentToken;
90
+ changes.push('BYAN_API_TOKEN extracted from .mcp.json (clear) into .env, removed from .mcp.json');
91
+ } else if (tokenIsPlaceholder) {
92
+ changes.push('BYAN_API_TOKEN placeholder removed from .mcp.json (token now resolved via settings.local.json env)');
94
93
  }
95
94
 
96
95
  // 6. Resolve URL
@@ -108,9 +107,16 @@ async function runMigration(projectRoot, { dryRun = false, verbose = false } = {
108
107
  return { migrated: false, reason: 'dry-run', changes };
109
108
  }
110
109
 
111
- // 8. Apply via ensureMcpConfig
110
+ // 8. Apply
112
111
  startSpinner('Application de la migration...');
113
- await ensureMcpConfig(projectRoot, { apiUrl: cleanUrl || existingUrl, token });
112
+ if (extractedClearToken) {
113
+ const existingDotenvToken = await readEnvToken(projectRoot);
114
+ if (!existingDotenvToken || existingDotenvToken !== extractedClearToken) {
115
+ await updateDotenv(projectRoot, { BYAN_API_TOKEN: extractedClearToken });
116
+ }
117
+ }
118
+ // ensureMcpConfig always strips BYAN_API_TOKEN from .mcp.json (security)
119
+ await ensureMcpConfig(projectRoot, { apiUrl: cleanUrl || existingUrl });
114
120
  stopSpinner(chalk.green('.mcp.json migre avec succes'), true);
115
121
 
116
122
  // 9. Return result