create-byan-agent 2.11.0 → 2.11.2

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.
Files changed (58) hide show
  1. package/install/templates/_byan/mcp/byan-mcp-server/server.js +122 -9
  2. package/node_modules/byan-platform-config/README.md +107 -0
  3. package/node_modules/byan-platform-config/index.js +16 -0
  4. package/node_modules/byan-platform-config/lib/env-config.js +128 -0
  5. package/node_modules/byan-platform-config/lib/mcp-config.js +95 -0
  6. package/node_modules/byan-platform-config/lib/token-prompt.js +61 -0
  7. package/node_modules/byan-platform-config/lib/url-utils.js +27 -0
  8. package/node_modules/byan-platform-config/lib/validate.js +44 -0
  9. package/node_modules/byan-platform-config/package.json +42 -0
  10. package/node_modules/fs-extra/LICENSE +15 -0
  11. package/node_modules/fs-extra/README.md +294 -0
  12. package/node_modules/fs-extra/lib/copy/copy-sync.js +176 -0
  13. package/node_modules/fs-extra/lib/copy/copy.js +180 -0
  14. package/node_modules/fs-extra/lib/copy/index.js +7 -0
  15. package/node_modules/fs-extra/lib/empty/index.js +39 -0
  16. package/node_modules/fs-extra/lib/ensure/file.js +66 -0
  17. package/node_modules/fs-extra/lib/ensure/index.js +23 -0
  18. package/node_modules/fs-extra/lib/ensure/link.js +64 -0
  19. package/node_modules/fs-extra/lib/ensure/symlink-paths.js +101 -0
  20. package/node_modules/fs-extra/lib/ensure/symlink-type.js +34 -0
  21. package/node_modules/fs-extra/lib/ensure/symlink.js +92 -0
  22. package/node_modules/fs-extra/lib/esm.mjs +68 -0
  23. package/node_modules/fs-extra/lib/fs/index.js +146 -0
  24. package/node_modules/fs-extra/lib/index.js +16 -0
  25. package/node_modules/fs-extra/lib/json/index.js +16 -0
  26. package/node_modules/fs-extra/lib/json/jsonfile.js +11 -0
  27. package/node_modules/fs-extra/lib/json/output-json-sync.js +12 -0
  28. package/node_modules/fs-extra/lib/json/output-json.js +12 -0
  29. package/node_modules/fs-extra/lib/mkdirs/index.js +14 -0
  30. package/node_modules/fs-extra/lib/mkdirs/make-dir.js +27 -0
  31. package/node_modules/fs-extra/lib/mkdirs/utils.js +21 -0
  32. package/node_modules/fs-extra/lib/move/index.js +7 -0
  33. package/node_modules/fs-extra/lib/move/move-sync.js +55 -0
  34. package/node_modules/fs-extra/lib/move/move.js +59 -0
  35. package/node_modules/fs-extra/lib/output-file/index.js +31 -0
  36. package/node_modules/fs-extra/lib/path-exists/index.js +12 -0
  37. package/node_modules/fs-extra/lib/remove/index.js +17 -0
  38. package/node_modules/fs-extra/lib/util/async.js +29 -0
  39. package/node_modules/fs-extra/lib/util/stat.js +159 -0
  40. package/node_modules/fs-extra/lib/util/utimes.js +36 -0
  41. package/node_modules/fs-extra/package.json +71 -0
  42. package/node_modules/graceful-fs/LICENSE +15 -0
  43. package/node_modules/graceful-fs/README.md +143 -0
  44. package/node_modules/graceful-fs/clone.js +23 -0
  45. package/node_modules/graceful-fs/graceful-fs.js +448 -0
  46. package/node_modules/graceful-fs/legacy-streams.js +118 -0
  47. package/node_modules/graceful-fs/package.json +53 -0
  48. package/node_modules/graceful-fs/polyfills.js +355 -0
  49. package/node_modules/jsonfile/LICENSE +15 -0
  50. package/node_modules/jsonfile/README.md +230 -0
  51. package/node_modules/jsonfile/index.js +88 -0
  52. package/node_modules/jsonfile/package.json +40 -0
  53. package/node_modules/jsonfile/utils.js +18 -0
  54. package/node_modules/universalify/LICENSE +20 -0
  55. package/node_modules/universalify/README.md +76 -0
  56. package/node_modules/universalify/index.js +24 -0
  57. package/node_modules/universalify/package.json +34 -0
  58. package/package.json +4 -1
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import fsSync from 'node:fs';
3
+ import fsPromises from 'node:fs/promises';
4
+ import nodePath from 'node:path';
2
5
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
7
  import {
@@ -94,6 +97,81 @@ async function apiRequest(path, options = {}) {
94
97
  return body;
95
98
  }
96
99
 
100
+ // Default filters — skip common build/vcs artifacts that pollute payload.
101
+ const DEFAULT_SKIP_DIRS = new Set([
102
+ '.git', 'node_modules', 'dist', 'build', '.next', 'coverage',
103
+ '__pycache__', '.venv', 'venv', '.pytest_cache', '.mypy_cache',
104
+ 'target', 'out', '.turbo', '.cache', '.DS_Store',
105
+ ]);
106
+ const DEFAULT_SKIP_FILE_PATTERNS = [
107
+ /\.log$/i, /\.sqlite$/i, /\.sqlite-journal$/i, /\.sqlite-wal$/i,
108
+ /\.lock$/i, /\.pid$/i,
109
+ ];
110
+ // Heuristic: treat as binary if content has NUL byte in first 8KB.
111
+ function looksBinary(buf) {
112
+ const sample = buf.subarray(0, Math.min(buf.length, 8192));
113
+ for (const b of sample) if (b === 0) return true;
114
+ return false;
115
+ }
116
+
117
+ // Hard limits — match W1's API guards so we fail fast client-side.
118
+ const MAX_FILES = 10000;
119
+ const MAX_TOTAL_BYTES = 100 * 1024 * 1024; // 100 MB
120
+
121
+ async function buildFilesPayload(absRoot, opts = {}) {
122
+ const skipDirs = opts.skipDirs || DEFAULT_SKIP_DIRS;
123
+ const skipPatterns = opts.skipPatterns || DEFAULT_SKIP_FILE_PATTERNS;
124
+ const maxFiles = opts.maxFiles || MAX_FILES;
125
+ const maxBytes = opts.maxBytes || MAX_TOTAL_BYTES;
126
+
127
+ const stat = await fsPromises.stat(absRoot);
128
+ if (!stat.isDirectory()) {
129
+ throw new Error(`Path is not a directory: ${absRoot}`);
130
+ }
131
+
132
+ const files = [];
133
+ let totalBytes = 0;
134
+
135
+ async function walk(dir) {
136
+ const entries = await fsPromises.readdir(dir, { withFileTypes: true });
137
+ for (const entry of entries) {
138
+ const full = nodePath.join(dir, entry.name);
139
+ if (entry.isDirectory()) {
140
+ if (skipDirs.has(entry.name)) continue;
141
+ await walk(full);
142
+ continue;
143
+ }
144
+ if (!entry.isFile()) continue;
145
+ if (skipPatterns.some((re) => re.test(entry.name))) continue;
146
+
147
+ const rel = nodePath.relative(absRoot, full).split(nodePath.sep).join('/');
148
+ const buf = await fsPromises.readFile(full);
149
+
150
+ totalBytes += buf.length;
151
+ if (files.length + 1 > maxFiles) {
152
+ throw new Error(
153
+ `Too many files (>${maxFiles}). Add to skipDirs or increase maxFiles.`
154
+ );
155
+ }
156
+ if (totalBytes > maxBytes) {
157
+ throw new Error(
158
+ `Total size exceeds ${(maxBytes / 1024 / 1024).toFixed(0)}MB. ` +
159
+ `Prune node_modules/dist/build dirs or increase maxBytes.`
160
+ );
161
+ }
162
+
163
+ if (looksBinary(buf)) {
164
+ files.push({ path: rel, content: buf.toString('base64'), encoding: 'base64' });
165
+ } else {
166
+ files.push({ path: rel, content: buf.toString('utf8'), encoding: 'utf8' });
167
+ }
168
+ }
169
+ }
170
+
171
+ await walk(absRoot);
172
+ return { files, count: files.length, totalBytes };
173
+ }
174
+
97
175
  const tools = [
98
176
  {
99
177
  name: 'byan_ping',
@@ -124,13 +202,13 @@ const tools = [
124
202
  {
125
203
  name: 'byan_import_project',
126
204
  description:
127
- 'Import a local project directory into byan_web. Scans BMAD artifacts (_bmad-output/, docs/, _bmad/_memory/). Requires auth.',
205
+ 'Import a local project directory into byan_web. Reads files from the local filesystem (client-side) and uploads them as a payload; works whether byan_web is local or remote. Skips .git, node_modules, dist, build, coverage, *.log, *.sqlite. Limits: 10000 files, 100MB total. Requires auth.',
128
206
  inputSchema: {
129
207
  type: 'object',
130
208
  properties: {
131
209
  path: {
132
210
  type: 'string',
133
- description: 'Absolute path to the project directory to import.',
211
+ description: 'Absolute path to the project directory on THIS machine (the MCP client). The API does not need filesystem access to this path.',
134
212
  },
135
213
  name: { type: 'string', description: 'Optional project name override.' },
136
214
  type: {
@@ -138,6 +216,14 @@ const tools = [
138
216
  enum: ['dev', 'training'],
139
217
  description: 'Project type. Default: dev.',
140
218
  },
219
+ maxFiles: {
220
+ type: 'number',
221
+ description: 'Override max file count (default 10000).',
222
+ },
223
+ maxBytes: {
224
+ type: 'number',
225
+ description: 'Override max total bytes (default 104857600 = 100MB).',
226
+ },
141
227
  },
142
228
  required: ['path'],
143
229
  additionalProperties: false,
@@ -820,10 +906,14 @@ const tools = [
820
906
  {
821
907
  name: 'byan_api_import_scan',
822
908
  description:
823
- 'Scan a local directory and report what would be imported. POST /api/import/scan. Requires BYAN_API_TOKEN.',
909
+ 'Scan a local directory and report what would be imported into byan_web. Reads files from the local filesystem (client-side) and uploads them as a payload; works whether byan_web is local or remote. Skips .git, node_modules, dist, build, coverage, *.log, *.sqlite. Limits: 10000 files, 100MB total. Requires auth.',
824
910
  inputSchema: {
825
911
  type: 'object',
826
- properties: { path: { type: 'string', description: 'Absolute path to scan.' } },
912
+ properties: {
913
+ path: { type: 'string', description: 'Absolute path to the directory on THIS machine (the MCP client). The API does not need filesystem access to this path.' },
914
+ maxFiles: { type: 'number', description: 'Override max file count (default 10000).' },
915
+ maxBytes: { type: 'number', description: 'Override max total bytes (default 104857600 = 100MB).' },
916
+ },
827
917
  required: ['path'],
828
918
  additionalProperties: false,
829
919
  },
@@ -831,10 +921,14 @@ const tools = [
831
921
  {
832
922
  name: 'byan_api_import_dry_run',
833
923
  description:
834
- 'Dry-run an import from a local directory (no writes). POST /api/import/dry-run. Requires BYAN_API_TOKEN.',
924
+ 'Dry-run an import from a local directory into byan_web (no writes). Reads files from the local filesystem (client-side) and uploads them as a payload; works whether byan_web is local or remote. Skips .git, node_modules, dist, build, coverage, *.log, *.sqlite. Limits: 10000 files, 100MB total. Requires auth.',
835
925
  inputSchema: {
836
926
  type: 'object',
837
- properties: { path: { type: 'string', description: 'Absolute path to dry-run.' } },
927
+ properties: {
928
+ path: { type: 'string', description: 'Absolute path to the directory on THIS machine (the MCP client). The API does not need filesystem access to this path.' },
929
+ maxFiles: { type: 'number', description: 'Override max file count (default 10000).' },
930
+ maxBytes: { type: 'number', description: 'Override max total bytes (default 104857600 = 100MB).' },
931
+ },
838
932
  required: ['path'],
839
933
  additionalProperties: false,
840
934
  },
@@ -899,10 +993,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
899
993
  if (!BYAN_API_TOKEN) {
900
994
  throw new Error('BYAN_API_TOKEN env var is required for this tool.');
901
995
  }
996
+ // Always upload files payload — works for both localhost and remote API.
997
+ // The API still accepts { path } for backward compat if caller insists,
998
+ // but the MCP client has no reason to use it (we can always read locally).
999
+ const { files } = await buildFilesPayload(args.path, {
1000
+ ...(args.maxFiles ? { maxFiles: args.maxFiles } : {}),
1001
+ ...(args.maxBytes ? { maxBytes: args.maxBytes } : {}),
1002
+ });
902
1003
  const body = await apiRequest('/api/import/project', {
903
1004
  method: 'POST',
904
1005
  body: JSON.stringify({
905
- path: args.path,
1006
+ files,
906
1007
  ...(args.name ? { name: args.name } : {}),
907
1008
  ...(args.type ? { type: args.type } : {}),
908
1009
  }),
@@ -1298,18 +1399,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1298
1399
 
1299
1400
  if (name === 'byan_api_import_scan') {
1300
1401
  requireToken();
1402
+ // Build files payload from client filesystem — works for remote byan_web.
1403
+ const { files } = await buildFilesPayload(args.path, {
1404
+ ...(args.maxFiles ? { maxFiles: args.maxFiles } : {}),
1405
+ ...(args.maxBytes ? { maxBytes: args.maxBytes } : {}),
1406
+ });
1301
1407
  const body = await apiRequest('/api/import/scan', {
1302
1408
  method: 'POST',
1303
- body: JSON.stringify({ path: args.path }),
1409
+ body: JSON.stringify({ files }),
1304
1410
  });
1305
1411
  return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }] };
1306
1412
  }
1307
1413
 
1308
1414
  if (name === 'byan_api_import_dry_run') {
1309
1415
  requireToken();
1416
+ // Build files payload from client filesystem — works for remote byan_web.
1417
+ const { files } = await buildFilesPayload(args.path, {
1418
+ ...(args.maxFiles ? { maxFiles: args.maxFiles } : {}),
1419
+ ...(args.maxBytes ? { maxBytes: args.maxBytes } : {}),
1420
+ });
1310
1421
  const body = await apiRequest('/api/import/dry-run', {
1311
1422
  method: 'POST',
1312
- body: JSON.stringify({ path: args.path }),
1423
+ body: JSON.stringify({ files }),
1313
1424
  });
1314
1425
  return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }] };
1315
1426
  }
@@ -1325,3 +1436,5 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1325
1436
 
1326
1437
  const transport = new StdioServerTransport();
1327
1438
  await server.connect(transport);
1439
+
1440
+ export { buildFilesPayload };
@@ -0,0 +1,107 @@
1
+ # byan-platform-config
2
+
3
+ Shared platform config primitives for BYAN. Single source of truth for
4
+ `.mcp.json`, `.env`, `.claude/settings.local.json`, token prompting,
5
+ byan_web reachability validation, and URL normalization.
6
+
7
+ Consumed by both `create-byan-agent` (installer) and `update-byan-agent`
8
+ (update CLI) so the two CLIs stay in sync.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install byan-platform-config
14
+ ```
15
+
16
+ ## Public API
17
+
18
+ ```js
19
+ const {
20
+ mcpConfig,
21
+ envConfig,
22
+ tokenPrompt,
23
+ validate,
24
+ urlUtils,
25
+ } = require('byan-platform-config');
26
+ ```
27
+
28
+ ### mcpConfig — `.mcp.json` management
29
+
30
+ | Function | Signature | Description |
31
+ |----------|-----------|-------------|
32
+ | `ensureMcpConfig` | `(projectRoot, { apiUrl, token }) → Promise<{ path }>` | READ-MERGE-WRITE. Strips `/api` suffix, preserves other mcpServers, preserves existing byan command/args. |
33
+ | `readMcpConfig` | `(projectRoot) → Promise<object\|null>` | Returns parsed `.mcp.json` or `null` if missing/malformed. |
34
+ | `mergeByanEntry` | `(existingConfig, { apiUrl, token }) → object` | Pure merge — no I/O. Returns a new config object. |
35
+
36
+ ```js
37
+ await mcpConfig.ensureMcpConfig('/path/to/proj', {
38
+ apiUrl: 'http://localhost:3737',
39
+ token: 'byan_abc123',
40
+ });
41
+ ```
42
+
43
+ ### envConfig — `.env` and `settings.local.json`
44
+
45
+ | Function | Signature | Description |
46
+ |----------|-----------|-------------|
47
+ | `updateSettingsLocal` | `(projectRoot, envVars) → Promise<{ path }>` | Merges vars into `.claude/settings.local.json`, preserves unrelated keys. |
48
+ | `updateDotenv` | `(projectRoot, envVars) → Promise<{ path }>` | Appends/updates `.env`, preserves comments and blank lines. |
49
+ | `readEnvToken` | `(projectRoot) → Promise<string\|null>` | Reads `BYAN_API_TOKEN` with fallback chain : `.env` then `settings.local.json`. |
50
+
51
+ ```js
52
+ await envConfig.updateDotenv('/path/to/proj', { BYAN_API_TOKEN: 'tok' });
53
+ const tok = await envConfig.readEnvToken('/path/to/proj');
54
+ ```
55
+
56
+ ### tokenPrompt — interactive prompt
57
+
58
+ | Symbol | Signature / Value | Description |
59
+ |--------|-------------------|-------------|
60
+ | `promptForToken` | `() → Promise<{ configured, apiUrl?, token? }>` | Inquirer prompt (confirm + URL + password). |
61
+ | `ENV_KEYS` | `['BYAN_API_TOKEN', 'BYAN_API_URL']` | Canonical env var names. |
62
+
63
+ ```js
64
+ const res = await tokenPrompt.promptForToken();
65
+ if (res.configured) { /* use res.apiUrl, res.token */ }
66
+ ```
67
+
68
+ ### validate — reachability probe
69
+
70
+ | Function | Signature | Description |
71
+ |----------|-----------|-------------|
72
+ | `validateByanWebReachability` | `({ apiUrl, token?, timeoutMs? }) → Promise<{ reachable, status?, latencyMs?, error? }>` | GET `/api/health`. Errors surface in the result object instead of throwing. Default timeout 5000 ms. |
73
+
74
+ ```js
75
+ const r = await validate.validateByanWebReachability({
76
+ apiUrl: 'http://localhost:3737',
77
+ token: 'byan_abc',
78
+ });
79
+ // → { reachable: true, status: 200, latencyMs: 12 }
80
+ ```
81
+
82
+ ### urlUtils — URL normalization
83
+
84
+ | Function | Signature | Description |
85
+ |----------|-----------|-------------|
86
+ | `stripApiSuffix` | `(url) → string` | Strips trailing `/api`, `/api/`, `/api/v1`, etc. |
87
+ | `buildAuthHeader` | `(token) → { Authorization } \| {}` | `ApiKey` scheme for `byan_*` tokens, `Bearer` otherwise. |
88
+
89
+ ```js
90
+ urlUtils.stripApiSuffix('http://x:1/api'); // → 'http://x:1'
91
+ urlUtils.buildAuthHeader('byan_abc'); // → { Authorization: 'ApiKey byan_abc' }
92
+ urlUtils.buildAuthHeader('eyJ...'); // → { Authorization: 'Bearer eyJ...' }
93
+ ```
94
+
95
+ ## Test
96
+
97
+ ```bash
98
+ npm test
99
+ ```
100
+
101
+ ## Design invariants
102
+
103
+ - READ-MERGE-WRITE on every file operation — unknown keys are preserved.
104
+ - `ensureMcpConfig` keeps `mcpServers.byan.command` and `.args` if already set.
105
+ - `validateByanWebReachability` resolves instead of rejecting — errors surface in the result object.
106
+ - `stripApiSuffix` is idempotent and leaves URLs without a `/api` suffix untouched.
107
+ - `buildAuthHeader` returns `{}` (not `{ Authorization: undefined }`) when token is missing.
@@ -0,0 +1,16 @@
1
+ /**
2
+ * byan-platform-config — shared platform config primitives.
3
+ *
4
+ * Single source of truth for .mcp.json management, .env/settings.local.json
5
+ * manipulation, token prompting, byan_web reachability validation, and URL
6
+ * normalization. Consumed by both install/ (create-byan-agent) and
7
+ * update-byan-agent/ so the two CLIs do not drift.
8
+ */
9
+
10
+ module.exports = {
11
+ mcpConfig: require('./lib/mcp-config'),
12
+ envConfig: require('./lib/env-config'),
13
+ tokenPrompt: require('./lib/token-prompt'),
14
+ validate: require('./lib/validate'),
15
+ urlUtils: require('./lib/url-utils'),
16
+ };
@@ -0,0 +1,128 @@
1
+ /**
2
+ * .env and .claude/settings.local.json management.
3
+ *
4
+ * updateSettingsLocal : merge env vars into .claude/settings.local.json,
5
+ * preserving unrelated keys (permissions, hooks, etc.).
6
+ * updateDotenv : append/update .env lines, preserving comments and
7
+ * blank lines, replacing (not duplicating) existing keys.
8
+ * readEnvToken : fallback chain to read BYAN_API_TOKEN for migrations.
9
+ */
10
+
11
+ const path = require('path');
12
+ const fs = require('fs-extra');
13
+
14
+ async function readJsonOrEmpty(filePath) {
15
+ if (await fs.pathExists(filePath)) {
16
+ try {
17
+ return await fs.readJson(filePath);
18
+ } catch {
19
+ return {};
20
+ }
21
+ }
22
+ return {};
23
+ }
24
+
25
+ /**
26
+ * @param {string} projectRoot
27
+ * @param {Record<string,string>} envVars
28
+ * @returns {Promise<{ path: string }>}
29
+ */
30
+ async function updateSettingsLocal(projectRoot, envVars) {
31
+ const filePath = path.join(projectRoot, '.claude', 'settings.local.json');
32
+ const current = await readJsonOrEmpty(filePath);
33
+ current.env = { ...(current.env || {}), ...envVars };
34
+ await fs.ensureDir(path.dirname(filePath));
35
+ await fs.writeJson(filePath, current, { spaces: 2 });
36
+ return { path: filePath };
37
+ }
38
+
39
+ /**
40
+ * @param {string} projectRoot
41
+ * @param {Record<string,string>} envVars
42
+ * @returns {Promise<{ path: string }>}
43
+ */
44
+ async function updateDotenv(projectRoot, envVars) {
45
+ const filePath = path.join(projectRoot, '.env');
46
+ let content = '';
47
+ if (await fs.pathExists(filePath)) {
48
+ content = await fs.readFile(filePath, 'utf8');
49
+ }
50
+
51
+ const lines = content ? content.split(/\r?\n/) : [];
52
+ const keys = Object.keys(envVars);
53
+ const kept = lines.filter((line) => {
54
+ const trimmed = line.trim();
55
+ if (!trimmed || trimmed.startsWith('#')) return true;
56
+ const eq = trimmed.indexOf('=');
57
+ if (eq < 0) return true;
58
+ const key = trimmed.slice(0, eq).trim();
59
+ return !keys.includes(key);
60
+ });
61
+
62
+ while (kept.length && kept[kept.length - 1] === '') kept.pop();
63
+ for (const key of keys) {
64
+ const val = envVars[key] ?? '';
65
+ kept.push(`${key}=${val}`);
66
+ }
67
+ kept.push('');
68
+
69
+ await fs.writeFile(filePath, kept.join('\n'), 'utf8');
70
+ return { path: filePath };
71
+ }
72
+
73
+ /**
74
+ * Parses a single `KEY=value` line, respecting surrounding double quotes.
75
+ * Returns null for comment/empty/malformed lines.
76
+ */
77
+ function parseDotenvLine(line) {
78
+ const trimmed = line.trim();
79
+ if (!trimmed || trimmed.startsWith('#')) return null;
80
+ const eq = trimmed.indexOf('=');
81
+ if (eq < 0) return null;
82
+ const key = trimmed.slice(0, eq).trim();
83
+ let val = trimmed.slice(eq + 1).trim();
84
+ if (val.length >= 2 && val.startsWith('"') && val.endsWith('"')) {
85
+ val = val.slice(1, -1);
86
+ }
87
+ return { key, value: val };
88
+ }
89
+
90
+ /**
91
+ * Reads BYAN_API_TOKEN from .env first, falls back to
92
+ * .claude/settings.local.json env.BYAN_API_TOKEN. Returns null if neither
93
+ * contains a non-empty value.
94
+ *
95
+ * @param {string} projectRoot
96
+ * @returns {Promise<string|null>}
97
+ */
98
+ async function readEnvToken(projectRoot) {
99
+ const dotenvPath = path.join(projectRoot, '.env');
100
+ if (await fs.pathExists(dotenvPath)) {
101
+ const content = await fs.readFile(dotenvPath, 'utf8');
102
+ for (const line of content.split(/\r?\n/)) {
103
+ const parsed = parseDotenvLine(line);
104
+ if (parsed && parsed.key === 'BYAN_API_TOKEN' && parsed.value) {
105
+ return parsed.value;
106
+ }
107
+ }
108
+ }
109
+
110
+ const settingsPath = path.join(projectRoot, '.claude', 'settings.local.json');
111
+ if (await fs.pathExists(settingsPath)) {
112
+ try {
113
+ const settings = await fs.readJson(settingsPath);
114
+ const tok = settings && settings.env && settings.env.BYAN_API_TOKEN;
115
+ if (typeof tok === 'string' && tok.length > 0) return tok;
116
+ } catch {
117
+ // ignore malformed json
118
+ }
119
+ }
120
+
121
+ return null;
122
+ }
123
+
124
+ module.exports = {
125
+ updateSettingsLocal,
126
+ updateDotenv,
127
+ readEnvToken,
128
+ };
@@ -0,0 +1,95 @@
1
+ /**
2
+ * .mcp.json management.
3
+ *
4
+ * READ-MERGE-WRITE semantics : preserves all existing mcpServers.* entries
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.
7
+ */
8
+
9
+ const path = require('path');
10
+ const fs = require('fs-extra');
11
+ const { stripApiSuffix } = require('./url-utils');
12
+
13
+ const MCP_SERVER_REL_PATH = '_byan/mcp/byan-mcp-server/server.js';
14
+
15
+ async function readJsonOrEmpty(filePath) {
16
+ if (await fs.pathExists(filePath)) {
17
+ try {
18
+ return await fs.readJson(filePath);
19
+ } catch {
20
+ return {};
21
+ }
22
+ }
23
+ return {};
24
+ }
25
+
26
+ /**
27
+ * Reads the project's .mcp.json.
28
+ *
29
+ * @param {string} projectRoot
30
+ * @returns {Promise<object|null>} parsed config or null if missing/malformed.
31
+ */
32
+ async function readMcpConfig(projectRoot) {
33
+ const filePath = path.join(projectRoot, '.mcp.json');
34
+ if (!(await fs.pathExists(filePath))) return null;
35
+ try {
36
+ return await fs.readJson(filePath);
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Pure merge — no I/O. Returns a new config object with byan entry merged.
44
+ * Useful for migrations that inspect the diff before writing.
45
+ *
46
+ * @param {object} existingConfig — current parsed config (may be {} or {mcpServers:{...}})
47
+ * @param {{ apiUrl: string, token?: string }} opts
48
+ * @returns {object} new merged config
49
+ */
50
+ function mergeByanEntry(existingConfig, { apiUrl, token } = {}) {
51
+ const cfg = existingConfig && typeof existingConfig === 'object' ? { ...existingConfig } : {};
52
+ cfg.mcpServers = { ...(cfg.mcpServers || {}) };
53
+
54
+ const existing = cfg.mcpServers.byan || {};
55
+ const cleanUrl = stripApiSuffix(apiUrl);
56
+
57
+ const env = { ...(existing.env || {}) };
58
+ 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
+ }
64
+
65
+ cfg.mcpServers.byan = {
66
+ command: 'node',
67
+ args: [MCP_SERVER_REL_PATH],
68
+ ...existing,
69
+ env,
70
+ };
71
+
72
+ return cfg;
73
+ }
74
+
75
+ /**
76
+ * Ensures .mcp.json exists with a valid byan entry. READ-MERGE-WRITE.
77
+ *
78
+ * @param {string} projectRoot
79
+ * @param {{ apiUrl: string, token?: string }} opts
80
+ * @returns {Promise<{ path: string }>}
81
+ */
82
+ async function ensureMcpConfig(projectRoot, { apiUrl, token } = {}) {
83
+ const filePath = path.join(projectRoot, '.mcp.json');
84
+ const current = await readJsonOrEmpty(filePath);
85
+ const merged = mergeByanEntry(current, { apiUrl, token });
86
+ await fs.writeJson(filePath, merged, { spaces: 2 });
87
+ return { path: filePath };
88
+ }
89
+
90
+ module.exports = {
91
+ ensureMcpConfig,
92
+ readMcpConfig,
93
+ mergeByanEntry,
94
+ MCP_SERVER_REL_PATH,
95
+ };
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Interactive prompt for byan_web API URL + JWT/ApiKey token.
3
+ *
4
+ * Extracted verbatim from install's byan-web-integration so the UX does
5
+ * not drift between create-byan-agent and update-byan-agent.
6
+ */
7
+
8
+ const inquirer = require('inquirer');
9
+ const chalk = require('chalk');
10
+
11
+ const DEFAULT_API_URL = 'http://localhost:3737';
12
+ const ENV_KEYS = ['BYAN_API_TOKEN', 'BYAN_API_URL'];
13
+
14
+ /**
15
+ * @returns {Promise<{ configured: boolean, apiUrl?: string, token?: string }>}
16
+ */
17
+ async function promptForToken() {
18
+ const { wantsToken } = await inquirer.prompt([
19
+ {
20
+ type: 'confirm',
21
+ name: 'wantsToken',
22
+ message:
23
+ 'Connect this project to your byan_web instance ? ' +
24
+ chalk.yellow('(service payant — requires a paid subscription to generate a token)'),
25
+ default: false,
26
+ },
27
+ ]);
28
+
29
+ if (!wantsToken) return { configured: false };
30
+
31
+ const answers = await inquirer.prompt([
32
+ {
33
+ type: 'input',
34
+ name: 'apiUrl',
35
+ message: 'byan_web API URL:',
36
+ default: DEFAULT_API_URL,
37
+ validate: (v) =>
38
+ /^https?:\/\//.test(v.trim()) || 'Must start with http:// or https://',
39
+ },
40
+ {
41
+ type: 'password',
42
+ name: 'token',
43
+ message: 'byan_web JWT token (from POST /api/auth/login):',
44
+ mask: '*',
45
+ validate: (v) =>
46
+ (typeof v === 'string' && v.trim().length > 0) || 'Token cannot be empty',
47
+ },
48
+ ]);
49
+
50
+ return {
51
+ configured: true,
52
+ apiUrl: answers.apiUrl.trim(),
53
+ token: answers.token.trim(),
54
+ };
55
+ }
56
+
57
+ module.exports = {
58
+ promptForToken,
59
+ ENV_KEYS,
60
+ DEFAULT_API_URL,
61
+ };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * URL utilities for BYAN platform config.
3
+ *
4
+ * stripApiSuffix : normalize apiUrl so the MCP server.js doesn't double the
5
+ * /api prefix (see install B5 bugfix).
6
+ * buildAuthHeader : ApiKey scheme for user-generated byan_ tokens,
7
+ * Bearer scheme for JWT tokens.
8
+ */
9
+
10
+ function stripApiSuffix(url) {
11
+ if (typeof url !== 'string' || url.length === 0) return url;
12
+ // Strip trailing /api, /api/, /api/v1, /api/v1/ etc. — keep protocol/host intact.
13
+ return url.replace(/\/api(?:\/v\d+)?\/?$/, '');
14
+ }
15
+
16
+ function buildAuthHeader(token) {
17
+ if (!token || typeof token !== 'string' || token.length === 0) {
18
+ return {};
19
+ }
20
+ const scheme = token.startsWith('byan_') ? 'ApiKey' : 'Bearer';
21
+ return { Authorization: `${scheme} ${token}` };
22
+ }
23
+
24
+ module.exports = {
25
+ stripApiSuffix,
26
+ buildAuthHeader,
27
+ };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * byan_web reachability probe.
3
+ *
4
+ * Never throws — always resolves with a plain result object. Uses
5
+ * AbortController to enforce a hard timeout (default 5s).
6
+ */
7
+
8
+ const { stripApiSuffix, buildAuthHeader } = require('./url-utils');
9
+
10
+ /**
11
+ * @param {{ apiUrl: string, token?: string, timeoutMs?: number }} opts
12
+ * @returns {Promise<{ reachable: boolean, status?: number, latencyMs?: number, error?: string }>}
13
+ */
14
+ async function validateByanWebReachability({ apiUrl, token, timeoutMs = 5000 }) {
15
+ const controller = new AbortController();
16
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
17
+
18
+ const headers = buildAuthHeader(token);
19
+ const url = `${stripApiSuffix(apiUrl)}/api/health`;
20
+ const t0 = Date.now();
21
+
22
+ try {
23
+ const res = await fetch(url, { method: 'GET', headers, signal: controller.signal });
24
+ const latencyMs = Date.now() - t0;
25
+ clearTimeout(timer);
26
+
27
+ if (res.status >= 200 && res.status < 400) {
28
+ return { reachable: true, status: res.status, latencyMs };
29
+ }
30
+ return {
31
+ reachable: true,
32
+ status: res.status,
33
+ latencyMs,
34
+ error: `HTTP ${res.status}`,
35
+ };
36
+ } catch (err) {
37
+ clearTimeout(timer);
38
+ return { reachable: false, error: err.message || String(err) };
39
+ }
40
+ }
41
+
42
+ module.exports = {
43
+ validateByanWebReachability,
44
+ };