converse-mcp-server 2.8.5 → 2.9.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "converse-mcp-server",
3
- "version": "2.8.5",
3
+ "version": "2.9.1",
4
4
  "description": "Converse MCP Server - Converse with other LLMs with chat and consensus tools",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -624,7 +624,11 @@ export const anthropicProvider = {
624
624
  }
625
625
 
626
626
  // Add effort parameter for Opus 4.5 (uses output_config)
627
- if (modelConfig.supportsEffort && reasoning_effort && reasoning_effort !== 'none') {
627
+ if (
628
+ modelConfig.supportsEffort &&
629
+ reasoning_effort &&
630
+ reasoning_effort !== 'none'
631
+ ) {
628
632
  const effortValue = EFFORT_MAP[reasoning_effort];
629
633
  if (effortValue) {
630
634
  requestPayload.output_config = {
@@ -852,7 +856,8 @@ export const anthropicProvider = {
852
856
  const streamingPayload = { ...requestPayload, stream: true };
853
857
 
854
858
  // Create the streaming request - use beta endpoint when beta features are enabled
855
- const hasBetaFeatures = requestPayload.betas && requestPayload.betas.length > 0;
859
+ const hasBetaFeatures =
860
+ requestPayload.betas && requestPayload.betas.length > 0;
856
861
  const stream = hasBetaFeatures
857
862
  ? await anthropic.beta.messages.create(streamingPayload)
858
863
  : await anthropic.messages.create(streamingPayload);
@@ -28,8 +28,7 @@ const SUPPORTED_MODELS = {
28
28
  supportsTemperature: false, // SDK manages temperature internally
29
29
  supportsWebSearch: false, // SDK accesses files directly, not web
30
30
  timeout: 120000, // 2 minutes
31
- description:
32
- 'Claude via Agent SDK - requires claude login authentication',
31
+ description: 'Claude via Agent SDK - requires claude login authentication',
33
32
  aliases: ['claude-sdk', 'claude-code'],
34
33
  },
35
34
  };
@@ -320,10 +319,10 @@ async function* createStreamingGenerator(
320
319
  input_tokens: message.usage.input_tokens || 0,
321
320
  output_tokens: message.usage.output_tokens || 0,
322
321
  total_tokens:
323
- (message.usage.input_tokens || 0) +
324
- (message.usage.output_tokens || 0),
322
+ (message.usage.input_tokens || 0) +
323
+ (message.usage.output_tokens || 0),
325
324
  cached_input_tokens:
326
- message.usage.cache_read_input_tokens || 0,
325
+ message.usage.cache_read_input_tokens || 0,
327
326
  },
328
327
  };
329
328
  }
@@ -336,7 +335,7 @@ async function* createStreamingGenerator(
336
335
  };
337
336
  } else if (
338
337
  message.subtype === 'error_max_turns' ||
339
- message.subtype === 'error_during_execution'
338
+ message.subtype === 'error_during_execution'
340
339
  ) {
341
340
  throw new ClaudeProviderError(
342
341
  `Claude SDK execution failed: ${message.subtype}`,
@@ -359,11 +358,11 @@ async function* createStreamingGenerator(
359
358
  */
360
359
  export const claudeProvider = {
361
360
  /**
362
- * Invoke Claude SDK with messages and options
363
- * @param {Array} messages - Message array (Converse format)
364
- * @param {Object} options - Invocation options
365
- * @returns {Promise<Object>|AsyncGenerator} Response or stream generator
366
- */
361
+ * Invoke Claude SDK with messages and options
362
+ * @param {Array} messages - Message array (Converse format)
363
+ * @param {Object} options - Invocation options
364
+ * @returns {Promise<Object>|AsyncGenerator} Response or stream generator
365
+ */
367
366
  async invoke(messages, options = {}) {
368
367
  const {
369
368
  model = 'claude',
@@ -408,7 +407,8 @@ export const claudeProvider = {
408
407
  // Returns { prompt, sdkMessage, hasImages }
409
408
  // - prompt: string for single message mode (text-only)
410
409
  // - sdkMessage: SDK user message for streaming input mode (with images)
411
- const { prompt, sdkMessage, hasImages } = convertMessagesToSdkInput(messages);
410
+ const { prompt, sdkMessage, hasImages } =
411
+ convertMessagesToSdkInput(messages);
412
412
 
413
413
  if (hasImages) {
414
414
  debugLog('[Claude SDK] Using streaming input mode for image support');
@@ -465,7 +465,7 @@ export const claudeProvider = {
465
465
  input_tokens: usage.input_tokens || 0,
466
466
  output_tokens: usage.output_tokens || 0,
467
467
  total_tokens:
468
- (usage.input_tokens || 0) + (usage.output_tokens || 0),
468
+ (usage.input_tokens || 0) + (usage.output_tokens || 0),
469
469
  cached_input_tokens: usage.cached_input_tokens || 0,
470
470
  }
471
471
  : null,
@@ -479,8 +479,8 @@ export const claudeProvider = {
479
479
  // Map common errors to standard error codes
480
480
  if (
481
481
  error.message?.includes('authentication') ||
482
- error.message?.includes('login') ||
483
- error.message?.includes('not authenticated')
482
+ error.message?.includes('login') ||
483
+ error.message?.includes('not authenticated')
484
484
  ) {
485
485
  throw new ClaudeProviderError(
486
486
  'Claude SDK authentication failed. Run: claude login',
@@ -519,10 +519,10 @@ export const claudeProvider = {
519
519
  },
520
520
 
521
521
  /**
522
- * Validate Claude SDK configuration
523
- * Claude SDK uses CLI authentication (NOT API keys)
524
- * Returns true optimistically - authentication errors handled at runtime
525
- */
522
+ * Validate Claude SDK configuration
523
+ * Claude SDK uses CLI authentication (NOT API keys)
524
+ * Returns true optimistically - authentication errors handled at runtime
525
+ */
526
526
  validateConfig(_config) {
527
527
  // Claude SDK uses CLI authentication, not API keys
528
528
  // We can't reliably check auth status, so return true optimistically
@@ -531,22 +531,22 @@ export const claudeProvider = {
531
531
  },
532
532
 
533
533
  /**
534
- * Check if Claude SDK provider is available
535
- */
534
+ * Check if Claude SDK provider is available
535
+ */
536
536
  isAvailable(config) {
537
537
  return this.validateConfig(config);
538
538
  },
539
539
 
540
540
  /**
541
- * Get supported Claude SDK models
542
- */
541
+ * Get supported Claude SDK models
542
+ */
543
543
  getSupportedModels() {
544
544
  return SUPPORTED_MODELS;
545
545
  },
546
546
 
547
547
  /**
548
- * Get model configuration for specific model
549
- */
548
+ * Get model configuration for specific model
549
+ */
550
550
  getModelConfig(modelName) {
551
551
  const modelNameLower = modelName.toLowerCase();
552
552
 
@@ -559,7 +559,7 @@ export const claudeProvider = {
559
559
  for (const [_name, config] of Object.entries(SUPPORTED_MODELS)) {
560
560
  if (
561
561
  config.aliases &&
562
- config.aliases.some((alias) => alias.toLowerCase() === modelNameLower)
562
+ config.aliases.some((alias) => alias.toLowerCase() === modelNameLower)
563
563
  ) {
564
564
  return config;
565
565
  }
@@ -36,7 +36,7 @@ const SUPPORTED_MODELS = {
36
36
  supportsWebSearch: true,
37
37
  timeout: 300000, // 5 minutes
38
38
  description:
39
- 'Gemini 3.0 Pro Preview via OAuth - requires Gemini CLI authentication',
39
+ 'Gemini 3.0 Pro Preview via OAuth - requires Gemini CLI authentication',
40
40
  aliases: ['gemini-cli'],
41
41
  // Internal SDK model name (user-facing "gemini" maps to SDK's "gemini-3-pro-preview")
42
42
  sdkModelName: 'gemini-3-pro-preview',
@@ -272,11 +272,11 @@ function convertToModelMessages(messages) {
272
272
  */
273
273
  export const geminiCliProvider = {
274
274
  /**
275
- * Invoke Gemini CLI with messages and options
276
- * @param {Array} messages - Message array (Converse format)
277
- * @param {Object} options - Invocation options
278
- * @returns {Promise<Object>|AsyncGenerator} Response or stream generator
279
- */
275
+ * Invoke Gemini CLI with messages and options
276
+ * @param {Array} messages - Message array (Converse format)
277
+ * @param {Object} options - Invocation options
278
+ * @returns {Promise<Object>|AsyncGenerator} Response or stream generator
279
+ */
280
280
  async invoke(messages, options = {}) {
281
281
  const {
282
282
  model = 'gemini',
@@ -405,8 +405,8 @@ export const geminiCliProvider = {
405
405
  // Map common errors to standard error codes
406
406
  if (
407
407
  error.message?.includes('authentication') ||
408
- error.message?.includes('oauth') ||
409
- error.message?.includes('credentials')
408
+ error.message?.includes('oauth') ||
409
+ error.message?.includes('credentials')
410
410
  ) {
411
411
  throw new GeminiCliProviderError(
412
412
  'Gemini CLI authentication failed. Run: gemini (interactive CLI) to authenticate',
@@ -441,31 +441,31 @@ export const geminiCliProvider = {
441
441
  },
442
442
 
443
443
  /**
444
- * Validate Gemini CLI configuration
445
- * Gemini CLI uses OAuth authentication (no API keys needed)
446
- */
444
+ * Validate Gemini CLI configuration
445
+ * Gemini CLI uses OAuth authentication (no API keys needed)
446
+ */
447
447
  validateConfig(_config) {
448
448
  // Check if OAuth credentials file exists
449
449
  return hasOAuthCredentials();
450
450
  },
451
451
 
452
452
  /**
453
- * Check if Gemini CLI provider is available
454
- */
453
+ * Check if Gemini CLI provider is available
454
+ */
455
455
  isAvailable(config) {
456
456
  return this.validateConfig(config);
457
457
  },
458
458
 
459
459
  /**
460
- * Get supported Gemini CLI models
461
- */
460
+ * Get supported Gemini CLI models
461
+ */
462
462
  getSupportedModels() {
463
463
  return SUPPORTED_MODELS;
464
464
  },
465
465
 
466
466
  /**
467
- * Get model configuration for specific model
468
- */
467
+ * Get model configuration for specific model
468
+ */
469
469
  getModelConfig(modelName) {
470
470
  const modelNameLower = modelName.toLowerCase();
471
471
 
@@ -119,11 +119,7 @@ const SUPPORTED_MODELS = {
119
119
  timeout: 300000,
120
120
  description:
121
121
  'Deep reasoning + thinking mode (1M context) - Complex problems, architecture, deep analysis',
122
- aliases: [
123
- 'pro 2.5',
124
- 'gemini pro 2.5',
125
- 'gemini-2.5-pro-latest',
126
- ],
122
+ aliases: ['pro 2.5', 'gemini pro 2.5', 'gemini-2.5-pro-latest'],
127
123
  },
128
124
  'gemini-3-pro-preview': {
129
125
  modelName: 'gemini-3-pro-preview',
package/src/tools/chat.js CHANGED
@@ -375,23 +375,26 @@ export async function chatTool(args, dependencies) {
375
375
 
376
376
  // Export conversation if requested
377
377
  if (shouldExport) {
378
- await exportConversation({
379
- messages: updatedMessages,
380
- provider: providerName,
381
- model,
382
- lastUpdated: Date.now(),
383
- codexThreadId: response.metadata?.threadId,
384
- }, {
385
- clientCwd: config.server?.client_cwd,
386
- continuation_id: continuationId,
387
- model,
388
- temperature,
389
- reasoning_effort,
390
- verbosity,
391
- use_websearch,
392
- files,
393
- images,
394
- });
378
+ await exportConversation(
379
+ {
380
+ messages: updatedMessages,
381
+ provider: providerName,
382
+ model,
383
+ lastUpdated: Date.now(),
384
+ codexThreadId: response.metadata?.threadId,
385
+ },
386
+ {
387
+ clientCwd: config.server?.client_cwd,
388
+ continuation_id: continuationId,
389
+ model,
390
+ temperature,
391
+ reasoning_effort,
392
+ verbosity,
393
+ use_websearch,
394
+ files,
395
+ images,
396
+ },
397
+ );
395
398
  }
396
399
 
397
400
  // Create unified status line (similar to async status display)
@@ -495,7 +498,11 @@ export function mapModelToProvider(model, providers) {
495
498
  }
496
499
 
497
500
  // Check Claude SDK (exact match only - routes to SDK provider instead of Anthropic API)
498
- if (modelLower === 'claude' || modelLower === 'claude-sdk' || modelLower === 'claude-code') {
501
+ if (
502
+ modelLower === 'claude' ||
503
+ modelLower === 'claude-sdk' ||
504
+ modelLower === 'claude-code'
505
+ ) {
499
506
  return 'claude';
500
507
  }
501
508
 
@@ -963,23 +970,26 @@ async function executeChatWithStreaming(args, dependencies, context) {
963
970
 
964
971
  // Export conversation if requested
965
972
  if (shouldExport) {
966
- await exportConversation({
967
- messages: updatedMessages,
968
- provider: providerName,
969
- model,
970
- lastUpdated: Date.now(),
971
- codexThreadId: response.metadata?.threadId,
972
- }, {
973
- clientCwd: config.server?.client_cwd,
974
- continuation_id: continuationId,
975
- model,
976
- temperature,
977
- reasoning_effort,
978
- verbosity,
979
- use_websearch,
980
- files,
981
- images,
982
- });
973
+ await exportConversation(
974
+ {
975
+ messages: updatedMessages,
976
+ provider: providerName,
977
+ model,
978
+ lastUpdated: Date.now(),
979
+ codexThreadId: response.metadata?.threadId,
980
+ },
981
+ {
982
+ clientCwd: config.server?.client_cwd,
983
+ continuation_id: continuationId,
984
+ model,
985
+ temperature,
986
+ reasoning_effort,
987
+ verbosity,
988
+ use_websearch,
989
+ files,
990
+ images,
991
+ },
992
+ );
983
993
  }
984
994
 
985
995
  // Return complete result for job completion
@@ -1019,7 +1029,7 @@ chatTool.inputSchema = {
1019
1029
  type: 'array',
1020
1030
  items: { type: 'string' },
1021
1031
  description:
1022
- 'File paths to include as context (absolute or relative paths). Example: ["C:\\Users\\username\\project\\src\\auth.js", "./config.json"]',
1032
+ 'File paths to include as context (absolute or relative paths). Supports line ranges: file.txt{10:50}, file.txt{100:}. Example: ["./src/utils/auth.js{50:100}", "./config.json"]',
1023
1033
  },
1024
1034
  images: {
1025
1035
  type: 'array',
@@ -542,7 +542,9 @@ Please provide your refined response:`;
542
542
  // Add summary at the end
543
543
  formattedContent += `\n**Summary:** Consensus completed with ${initialPhase.successful.length} successful initial responses`;
544
544
  if (refinedPhase) {
545
- const successfulRefinements = refinedPhase.filter(r => r.status === 'success').length;
545
+ const successfulRefinements = refinedPhase.filter(
546
+ (r) => r.status === 'success',
547
+ ).length;
546
548
  formattedContent += ` and ${successfulRefinements} successful refined responses`;
547
549
  }
548
550
  formattedContent += '.';
@@ -752,7 +754,11 @@ function mapModelToProvider(model, providers) {
752
754
  }
753
755
 
754
756
  // Check Claude SDK (exact match only - routes to SDK provider instead of Anthropic API)
755
- if (modelLower === 'claude' || modelLower === 'claude-sdk' || modelLower === 'claude-code') {
757
+ if (
758
+ modelLower === 'claude' ||
759
+ modelLower === 'claude-sdk' ||
760
+ modelLower === 'claude-code'
761
+ ) {
756
762
  return 'claude';
757
763
  }
758
764
 
@@ -1251,7 +1257,9 @@ Please provide your refined response:`;
1251
1257
  // Add summary at the end
1252
1258
  formattedContent += `\n**Summary:** Consensus completed with ${initialPhase.successful.length} successful initial responses`;
1253
1259
  if (refinedPhase) {
1254
- const successfulRefinements = refinedPhase.filter(r => r.status === 'success').length;
1260
+ const successfulRefinements = refinedPhase.filter(
1261
+ (r) => r.status === 'success',
1262
+ ).length;
1255
1263
  formattedContent += ` and ${successfulRefinements} successful refined responses`;
1256
1264
  }
1257
1265
  formattedContent += '.';
@@ -1623,7 +1631,7 @@ consensusTool.inputSchema = {
1623
1631
  type: 'array',
1624
1632
  items: { type: 'string' },
1625
1633
  description:
1626
- 'File paths for additional context (absolute or relative paths). Example: ["C:\\Users\\username\\project\\architecture.md", "./requirements.txt"]',
1634
+ 'File paths for additional context (absolute or relative paths). Supports line ranges: file.txt{10:50}, file.txt{100:}. Example: ["./docs/architecture.md{1:100}", "./requirements.txt"]',
1627
1635
  },
1628
1636
  images: {
1629
1637
  type: 'array',
@@ -11,6 +11,11 @@ import { extname, resolve, isAbsolute } from 'path';
11
11
  import { constants } from 'fs';
12
12
  import { fileURLToPath } from 'url';
13
13
  import { dirname } from 'path';
14
+ import {
15
+ parseFilePathWithRange,
16
+ extractLineRange,
17
+ validateRange,
18
+ } from './pathParser.js';
14
19
 
15
20
  // Security: Define allowed directories for file access
16
21
  const __filename = fileURLToPath(import.meta.url);
@@ -103,8 +108,6 @@ async function validateFilePath(filePath, options = {}) {
103
108
  * @param {string} filePath - Absolute or relative path to the file
104
109
  * @param {object} options - Processing options
105
110
  * @param {string[]} options.allowedDirectories - Allowed directories for security
106
- * @param {number} options.maxTextSize - Maximum text file size in bytes
107
- * @param {number} options.maxImageSize - Maximum image file size in bytes
108
111
  * @param {boolean} options.skipSecurityCheck - Skip security validation (for testing)
109
112
  * @returns {Promise<object>} Processed content with metadata
110
113
  */
@@ -137,8 +140,21 @@ export async function processFileContent(filePath, options = {}) {
137
140
  };
138
141
  }
139
142
 
140
- // Security validation for file paths
141
- const validatedPath = await validateFilePath(filePath, options);
143
+ // Parse line range specifier from file path (e.g., "file.txt{10:50}")
144
+ const { filePath: actualPath, range } = parseFilePathWithRange(filePath);
145
+
146
+ // Validate range early (before expensive file operations)
147
+ const rangeValidation = validateRange(range);
148
+ if (!rangeValidation.valid) {
149
+ throw new ContextProcessorError(
150
+ rangeValidation.error,
151
+ rangeValidation.code,
152
+ { path: filePath },
153
+ );
154
+ }
155
+
156
+ // Security validation for file paths (without range specifier)
157
+ const validatedPath = await validateFilePath(actualPath, options);
142
158
 
143
159
  const fileStats = await stat(validatedPath);
144
160
  const extension = extname(validatedPath).toLowerCase();
@@ -162,18 +178,9 @@ export async function processFileContent(filePath, options = {}) {
162
178
  encoding: null,
163
179
  };
164
180
 
165
- // Check file size limits
166
- const maxTextSize = options.maxTextSize || 1024 * 1024; // 1MB default
167
- const maxImageSize = options.maxImageSize || 10 * 1024 * 1024; // 10MB default
168
-
169
181
  if (SUPPORTED_IMAGE_EXTENSIONS.includes(extension)) {
170
182
  result.type = 'image';
171
183
 
172
- if (fileStats.size > maxImageSize) {
173
- result.error = `Image too large (${fileStats.size} bytes, max ${maxImageSize})`;
174
- return result;
175
- }
176
-
177
184
  // For images, read as base64 for AI processing
178
185
  const buffer = await readFile(validatedPath);
179
186
  result.content = buffer.toString('base64');
@@ -183,16 +190,24 @@ export async function processFileContent(filePath, options = {}) {
183
190
  // Read everything else as text
184
191
  result.type = 'text';
185
192
 
186
- if (fileStats.size > maxTextSize) {
187
- result.error = `File too large (${fileStats.size} bytes, max ${maxTextSize})`;
188
- return result;
193
+ const rawContent = await readFile(validatedPath, 'utf8');
194
+ const allLines = rawContent.split(/\r?\n/);
195
+ result.totalLineCount = allLines.length;
196
+
197
+ // Apply line range extraction if specified
198
+ if (range) {
199
+ const extraction = extractLineRange(allLines, range);
200
+ result.content = extraction.lines.join('\n');
201
+ result.lineCount = extraction.lines.length;
202
+ result.rangeStart = extraction.actualStart;
203
+ result.rangeEnd = extraction.actualEnd;
204
+ } else {
205
+ result.content = rawContent;
206
+ result.lineCount = allLines.length;
189
207
  }
190
208
 
191
- const content = await readFile(validatedPath, 'utf8');
192
- result.content = content;
193
- result.lineCount = content.split(/\r?\n/).length;
194
209
  result.encoding = 'utf8';
195
- result.charCount = content.length;
210
+ result.charCount = result.content.length;
196
211
  }
197
212
 
198
213
  return result;
@@ -350,7 +365,12 @@ export function createFileContext(processedFiles, options = {}) {
350
365
  if (textFiles.length > 0) {
351
366
  contextText += '=== FILE CONTEXT ===\n\n';
352
367
  for (const file of textFiles) {
353
- contextText += `--- ${file.originalPath || file.path} ---\n`;
368
+ // Show range info if this is a partial file extraction
369
+ let header = file.originalPath || file.path;
370
+ if (file.rangeStart !== undefined && file.totalLineCount !== undefined) {
371
+ header += ` (lines ${file.rangeStart}-${file.rangeEnd} of ${file.totalLineCount})`;
372
+ }
373
+ contextText += `--- ${header} ---\n`;
354
374
  if (options.includeMetadata) {
355
375
  contextText += `Size: ${file.size} bytes, Lines: ${file.lineCount || 'N/A'}\n`;
356
376
  contextText += `Last Modified: ${file.lastModified || 'N/A'}\n`;
@@ -150,7 +150,9 @@ function generateMetadata(conversationState, totalTurns, params) {
150
150
  created_at: conversationState.createdAt
151
151
  ? new Date(conversationState.createdAt).toISOString()
152
152
  : new Date().toISOString(),
153
- last_updated: new Date(conversationState.lastUpdated || Date.now()).toISOString(),
153
+ last_updated: new Date(
154
+ conversationState.lastUpdated || Date.now(),
155
+ ).toISOString(),
154
156
  };
155
157
 
156
158
  // Add optional parameters if present
@@ -168,8 +170,8 @@ function generateMetadata(conversationState, totalTurns, params) {
168
170
  }
169
171
  if (params.images && params.images.length > 0) {
170
172
  // Don't store base64 data, just file paths or indicators
171
- metadata.images = params.images.map(img =>
172
- img.startsWith('data:') ? '[base64 image]' : img
173
+ metadata.images = params.images.map((img) =>
174
+ img.startsWith('data:') ? '[base64 image]' : img,
173
175
  );
174
176
  }
175
177
  // Consensus-specific metadata
@@ -8,6 +8,7 @@
8
8
  import { access, constants } from 'fs/promises';
9
9
  import { resolve, isAbsolute } from 'path';
10
10
  import { createToolError } from '../tools/index.js';
11
+ import { parseFilePathWithRange } from './pathParser.js';
11
12
 
12
13
  /**
13
14
  * Validate that all provided file paths exist
@@ -38,11 +39,15 @@ export async function validateFilePaths(
38
39
  continue;
39
40
  }
40
41
 
42
+ // Parse and strip line range specifier before validation
43
+ // e.g., "file.txt{5:10}" -> "file.txt"
44
+ const { filePath: actualPath } = parseFilePathWithRange(filePath);
45
+
41
46
  // Convert to absolute path if needed
42
47
  // Use clientCwd if provided (for auto-detected client working directory), otherwise fall back to process.cwd()
43
- const absolutePath = isAbsolute(filePath)
44
- ? filePath
45
- : resolve(options.clientCwd || process.cwd(), filePath);
48
+ const absolutePath = isAbsolute(actualPath)
49
+ ? actualPath
50
+ : resolve(options.clientCwd || process.cwd(), actualPath);
46
51
 
47
52
  try {
48
53
  // Check if file exists and is readable
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Path Parser Utility
3
+ *
4
+ * Parses file paths with optional line range specifiers.
5
+ * Syntax: path/to/file{start:end} where start and end are 1-based line numbers.
6
+ *
7
+ * Examples:
8
+ * - file.txt{10:50} - Lines 10-50 inclusive
9
+ * - file.txt{:20} - Lines 1-20
10
+ * - file.txt{100:} - Lines 100 to end
11
+ * - file.txt - Entire file (no range)
12
+ */
13
+
14
+ /**
15
+ * Regex to match line range specifier at end of path.
16
+ * Captures: (filePath)(startLine)(endLine)
17
+ * Pattern: ^(.*)\{(\d*):(\d*)\}$
18
+ */
19
+ const RANGE_PATTERN = /^(.*)\{(\d*):(\d*)\}$/;
20
+
21
+ /**
22
+ * Parse a file path that may contain a line range specifier.
23
+ *
24
+ * @param {string} filePath - File path potentially with range specifier
25
+ * @returns {{filePath: string, range: {start: number|null, end: number|null, isEmpty: boolean}|null}}
26
+ *
27
+ * @example
28
+ * parseFilePathWithRange('file.txt{10:50}')
29
+ * // => { filePath: 'file.txt', range: { start: 10, end: 50, isEmpty: false } }
30
+ *
31
+ * @example
32
+ * parseFilePathWithRange('file.txt{:20}')
33
+ * // => { filePath: 'file.txt', range: { start: null, end: 20, isEmpty: false } }
34
+ *
35
+ * @example
36
+ * parseFilePathWithRange('file.txt')
37
+ * // => { filePath: 'file.txt', range: null }
38
+ */
39
+ export function parseFilePathWithRange(filePath) {
40
+ if (!filePath || typeof filePath !== 'string') {
41
+ return { filePath, range: null };
42
+ }
43
+
44
+ const match = filePath.match(RANGE_PATTERN);
45
+
46
+ if (!match) {
47
+ // No range specifier found, return original path
48
+ return { filePath, range: null };
49
+ }
50
+
51
+ const [, extractedPath, startStr, endStr] = match;
52
+
53
+ // Check for empty range {:}
54
+ if (startStr === '' && endStr === '') {
55
+ return {
56
+ filePath: extractedPath,
57
+ range: { start: null, end: null, isEmpty: true },
58
+ };
59
+ }
60
+
61
+ // Parse start line (treat 0 as 1)
62
+ let start = null;
63
+ if (startStr !== '') {
64
+ start = parseInt(startStr, 10);
65
+ if (start === 0) {
66
+ start = 1;
67
+ }
68
+ }
69
+
70
+ // Parse end line
71
+ let end = null;
72
+ if (endStr !== '') {
73
+ end = parseInt(endStr, 10);
74
+ }
75
+
76
+ return {
77
+ filePath: extractedPath,
78
+ range: { start, end, isEmpty: false },
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Extract a range of lines from an array of lines.
84
+ * Both start and end are 1-indexed and inclusive.
85
+ *
86
+ * @param {string[]} lines - Array of lines
87
+ * @param {{start: number|null, end: number|null, isEmpty: boolean}} range - Range specification
88
+ * @returns {{lines: string[], actualStart: number, actualEnd: number}}
89
+ *
90
+ * @example
91
+ * extractLineRange(['a', 'b', 'c', 'd', 'e'], { start: 2, end: 4, isEmpty: false })
92
+ * // => { lines: ['b', 'c', 'd'], actualStart: 2, actualEnd: 4 }
93
+ */
94
+ export function extractLineRange(lines, range) {
95
+ if (!range || !Array.isArray(lines)) {
96
+ return {
97
+ lines: lines || [],
98
+ actualStart: 1,
99
+ actualEnd: lines ? lines.length : 0,
100
+ };
101
+ }
102
+
103
+ const totalLines = lines.length;
104
+
105
+ // Determine start index (1-indexed to 0-indexed conversion)
106
+ // Default to 1 if not specified
107
+ let startLine = range.start !== null ? range.start : 1;
108
+
109
+ // Clamp start to valid range (at least 1)
110
+ if (startLine < 1) {
111
+ startLine = 1;
112
+ }
113
+
114
+ // Determine end index (inclusive)
115
+ // Default to total lines if not specified
116
+ let endLine = range.end !== null ? range.end : totalLines;
117
+
118
+ // Clamp end to actual file bounds
119
+ if (endLine > totalLines) {
120
+ endLine = totalLines;
121
+ }
122
+
123
+ // Handle case where start > total lines (return empty)
124
+ if (startLine > totalLines) {
125
+ return {
126
+ lines: [],
127
+ actualStart: startLine,
128
+ actualEnd: endLine,
129
+ };
130
+ }
131
+
132
+ // Convert to 0-indexed for slice
133
+ // slice(start, end) is exclusive of end, so we use endLine (not endLine - 1)
134
+ const extractedLines = lines.slice(startLine - 1, endLine);
135
+
136
+ return {
137
+ lines: extractedLines,
138
+ actualStart: startLine,
139
+ actualEnd: Math.min(endLine, totalLines),
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Validate a line range.
145
+ *
146
+ * @param {{start: number|null, end: number|null, isEmpty: boolean}} range - Range to validate
147
+ * @returns {{valid: boolean, error: string|null, code: string|null}}
148
+ */
149
+ export function validateRange(range) {
150
+ if (!range) {
151
+ return { valid: true, error: null, code: null };
152
+ }
153
+
154
+ // Empty range {:} is invalid
155
+ if (range.isEmpty) {
156
+ return {
157
+ valid: false,
158
+ error: 'Empty range specifier {:} is not allowed',
159
+ code: 'EMPTY_RANGE',
160
+ };
161
+ }
162
+
163
+ // Check if start > end (when both are specified)
164
+ if (range.start !== null && range.end !== null && range.start > range.end) {
165
+ return {
166
+ valid: false,
167
+ error: `Invalid range: start (${range.start}) is greater than end (${range.end})`,
168
+ code: 'INVALID_RANGE',
169
+ };
170
+ }
171
+
172
+ return { valid: true, error: null, code: null };
173
+ }