vigthoria-cli 1.6.28 → 1.6.30

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.
@@ -217,25 +217,39 @@ class AuthCommand {
217
217
  console.log();
218
218
  console.log(chalk_1.default.white('Capability Truth:'));
219
219
  console.log(chalk_1.default.gray(' Overall: ') + (capabilityStatus.overallOk ? chalk_1.default.green('Verified') : chalk_1.default.yellow('Partial')));
220
+ // V3 Agent — used by agent/chat commands
220
221
  console.log(chalk_1.default.gray(' V3 Agent: ') + (capabilityStatus.v3Agent.ok ? chalk_1.default.green('Reachable') : chalk_1.default.red('Unavailable')));
221
222
  if (capabilityStatus.v3Agent.error) {
222
223
  console.log(chalk_1.default.gray(' Error: ') + chalk_1.default.red(capabilityStatus.v3Agent.error));
223
224
  }
224
- console.log(chalk_1.default.gray(' Hyper Loop: ') + (capabilityStatus.hyperLoop.ok ? chalk_1.default.green('Reachable') : chalk_1.default.red('Unavailable')));
225
+ // Hyper Loop optional orchestration layer
226
+ console.log(chalk_1.default.gray(' Hyper Loop: ') + (capabilityStatus.hyperLoop.ok ? chalk_1.default.green('Reachable') : chalk_1.default.yellow('Unavailable (does not affect AI commands)')));
225
227
  if (capabilityStatus.hyperLoop.error) {
226
- console.log(chalk_1.default.gray(' Error: ') + chalk_1.default.red(capabilityStatus.hyperLoop.error));
228
+ console.log(chalk_1.default.gray(' Error: ') + chalk_1.default.yellow(capabilityStatus.hyperLoop.error));
227
229
  }
228
- console.log(chalk_1.default.gray(' Repo Memory: ') + (capabilityStatus.repoMemory.ok ? chalk_1.default.green('Active') : chalk_1.default.yellow('Unavailable')));
230
+ // Repo Memory separate auth scope, only affects repo commands
231
+ console.log(chalk_1.default.gray(' Repo Memory: ') + (capabilityStatus.repoMemory.ok ? chalk_1.default.green('Active') : chalk_1.default.yellow('Unavailable (does not affect AI commands)')));
229
232
  if (capabilityStatus.repoMemory.details?.compactContextLength !== undefined) {
230
233
  console.log(chalk_1.default.gray(' Compact Context: ') + chalk_1.default.cyan(`${capabilityStatus.repoMemory.details.compactContextLength} chars`));
231
234
  }
232
235
  if (capabilityStatus.repoMemory.error) {
233
236
  console.log(chalk_1.default.gray(' Error: ') + chalk_1.default.yellow(capabilityStatus.repoMemory.error));
234
237
  }
235
- console.log(chalk_1.default.gray(' DevTools Bridge: ') + (capabilityStatus.devtoolsBridge.ok ? chalk_1.default.green('Reachable') : chalk_1.default.gray('Not running')));
238
+ // DevTools Bridge local service, not required
239
+ console.log(chalk_1.default.gray(' DevTools Bridge: ') + (capabilityStatus.devtoolsBridge.ok ? chalk_1.default.green('Reachable') : chalk_1.default.gray('Not running (optional)')));
236
240
  if (capabilityStatus.devtoolsBridge.error) {
237
241
  console.log(chalk_1.default.gray(' Error: ') + chalk_1.default.gray(capabilityStatus.devtoolsBridge.error));
238
242
  }
243
+ // Auth scope summary — use a real server-side probe for Model Auth
244
+ const tokenValidation = await this.api.validateToken();
245
+ console.log();
246
+ console.log(chalk_1.default.white('Auth Scopes:'));
247
+ console.log(chalk_1.default.gray(' Model Auth: ') + (tokenValidation.valid ? chalk_1.default.green('Valid') : chalk_1.default.red('Invalid')) + chalk_1.default.gray(' (used by chat, agent, review, explain, generate, fix)'));
248
+ if (!tokenValidation.valid && tokenValidation.error) {
249
+ console.log(chalk_1.default.gray(' ') + chalk_1.default.red(tokenValidation.error));
250
+ }
251
+ console.log(chalk_1.default.gray(' Repo Auth: ') + (capabilityStatus.repoMemory.ok ? chalk_1.default.green('Active') : chalk_1.default.yellow('Inactive')) + chalk_1.default.gray(' (used by repo push/pull/list only)'));
252
+ console.log(chalk_1.default.gray(' Bridge Auth: ') + (capabilityStatus.devtoolsBridge.ok ? chalk_1.default.green('Connected') : chalk_1.default.gray('N/A')) + chalk_1.default.gray(' (used by --bridge flag only)'));
239
253
  console.log();
240
254
  }
241
255
  printLoginSuccess() {
@@ -846,7 +846,8 @@ class ChatCommand {
846
846
  if (spinner) {
847
847
  spinner.stop();
848
848
  }
849
- const errorMsg = error.message || 'Operator workflow failed with an unknown error.';
849
+ const cliErr = error instanceof api_js_1.CLIError ? error : (0, api_js_1.classifyError)(error);
850
+ const errorMsg = (0, api_js_1.formatCLIError)(cliErr);
850
851
  if (!this.jsonOutput) {
851
852
  this.logger.error('Operator workflow failed');
852
853
  }
@@ -857,6 +858,7 @@ class ChatCommand {
857
858
  mode: 'operator',
858
859
  content: '',
859
860
  error: errorMsg,
861
+ errorCategory: cliErr.category,
860
862
  }, null, 2));
861
863
  }
862
864
  else {
@@ -924,9 +926,8 @@ class ChatCommand {
924
926
  catch (error) {
925
927
  if (spinner)
926
928
  spinner.stop();
927
- if (!this.jsonOutput)
928
- this.logger.error('Failed to get response');
929
- const errorMsg = error.message;
929
+ const cliErr = error instanceof api_js_1.CLIError ? error : (0, api_js_1.classifyError)(error);
930
+ const errorMsg = (0, api_js_1.formatCLIError)(cliErr);
930
931
  if (this.jsonOutput) {
931
932
  process.exitCode = 1;
932
933
  console.log(JSON.stringify({
@@ -935,6 +936,7 @@ class ChatCommand {
935
936
  model: this.currentModel,
936
937
  content: '',
937
938
  error: errorMsg,
939
+ errorCategory: cliErr.category,
938
940
  }, null, 2));
939
941
  }
940
942
  else {
@@ -1046,8 +1048,8 @@ class ChatCommand {
1046
1048
  catch (error) {
1047
1049
  if (spinner)
1048
1050
  spinner.stop();
1049
- if (!this.jsonOutput)
1050
- this.logger.error('Agent request failed');
1051
+ const cliErr = error instanceof api_js_1.CLIError ? error : (0, api_js_1.classifyError)(error);
1052
+ const errorMsg = (0, api_js_1.formatCLIError)(cliErr);
1051
1053
  if (this.jsonOutput) {
1052
1054
  process.exitCode = 1;
1053
1055
  console.log(JSON.stringify({
@@ -1056,13 +1058,16 @@ class ChatCommand {
1056
1058
  model: this.currentModel,
1057
1059
  partial: false,
1058
1060
  content: '',
1059
- error: error.message,
1061
+ error: errorMsg,
1062
+ errorCategory: cliErr.category,
1060
1063
  metadata: {
1061
1064
  executionPath: 'local-agent-loop',
1062
1065
  },
1063
1066
  }, null, 2));
1064
1067
  }
1065
- this.logger.error(error.message);
1068
+ else {
1069
+ this.logger.error(errorMsg);
1070
+ }
1066
1071
  return;
1067
1072
  }
1068
1073
  }
@@ -29,6 +29,12 @@ class EditCommand {
29
29
  this.logger.error('Not authenticated. Run: vigthoria login');
30
30
  return;
31
31
  }
32
+ // Server-side token validation — fail fast instead of waiting for 401
33
+ const tokenCheck = await this.api.validateToken();
34
+ if (!tokenCheck.valid) {
35
+ this.logger.error(tokenCheck.error || 'Auth token is invalid. Run: vigthoria login');
36
+ return;
37
+ }
32
38
  // Read file
33
39
  const file = this.fileUtils.readFile(filePath);
34
40
  if (!file) {
@@ -46,6 +52,11 @@ class EditCommand {
46
52
  this.logger.error('The --apply flag requires --instruction. Example: vigthoria edit file.ts --apply --instruction "fix the bug"');
47
53
  return;
48
54
  }
55
+ // Non-TTY stdin cannot prompt interactively
56
+ if (!process.stdin.isTTY) {
57
+ this.logger.error('No --instruction provided and stdin is not interactive. Use: vigthoria edit file.ts --instruction "..."');
58
+ return;
59
+ }
49
60
  const answer = await inquirer_1.default.prompt([
50
61
  {
51
62
  type: 'input',
@@ -93,11 +104,14 @@ Return the complete modified file content:`,
93
104
  ], options.model);
94
105
  spinner.stop();
95
106
  // Extract code from response
96
- const modifiedCode = this.extractCode(response.message, file.language);
107
+ let modifiedCode = this.extractCode(response.message, file.language);
97
108
  if (!modifiedCode) {
98
109
  this.logger.error('Failed to generate valid code changes');
99
110
  return;
100
111
  }
112
+ // Always deduplicate — extractCode only dedupes on the no-fence
113
+ // fallback path, so fenced responses (the common case) need this.
114
+ modifiedCode = this.deduplicateCode(modifiedCode);
101
115
  // Show diff and apply
102
116
  if (options.apply) {
103
117
  await this.applyFix(file.path, file.content, modifiedCode);
@@ -108,7 +122,8 @@ Return the complete modified file content:`,
108
122
  }
109
123
  catch (error) {
110
124
  spinner.stop();
111
- this.logger.error('Edit failed:', error.message);
125
+ const cliErr = error instanceof api_js_1.CLIError ? error : (0, api_js_1.classifyError)(error);
126
+ this.logger.error((0, api_js_1.formatCLIError)(cliErr));
112
127
  }
113
128
  }
114
129
  async fix(filePath, options) {
@@ -117,6 +132,12 @@ Return the complete modified file content:`,
117
132
  this.logger.error('Not authenticated. Run: vigthoria login');
118
133
  return;
119
134
  }
135
+ // Server-side token validation — fail fast instead of waiting for 401
136
+ const tokenCheck = await this.api.validateToken();
137
+ if (!tokenCheck.valid) {
138
+ this.logger.error(tokenCheck.error || 'Auth token is invalid. Run: vigthoria login');
139
+ return;
140
+ }
120
141
  // Read file
121
142
  const file = this.fileUtils.readFile(filePath);
122
143
  if (!file) {
@@ -173,7 +194,8 @@ Return the complete modified file content:`,
173
194
  }
174
195
  catch (error) {
175
196
  spinner.stop();
176
- this.logger.error('Fix failed:', error.message);
197
+ const cliErr = error instanceof api_js_1.CLIError ? error : (0, api_js_1.classifyError)(error);
198
+ this.logger.error((0, api_js_1.formatCLIError)(cliErr));
177
199
  }
178
200
  }
179
201
  extractCode(response, language) {
@@ -216,15 +238,45 @@ Return the complete modified file content:`,
216
238
  const len = lines.length;
217
239
  if (len < 4)
218
240
  return code;
219
- // Pass 1: Remove adjacent duplicate non-empty lines (model stutter)
241
+ // Pass 1: Remove model stutter consecutive runs of 3+ identical
242
+ // non-empty lines are collapsed to one. A pair (exactly 2) is only
243
+ // collapsed when it occurs at the very end of the output (trailing
244
+ // stutter). Pairs in the middle are kept — they may be intentional
245
+ // (e.g. repeated data rows, CSS rules).
220
246
  const deduped = [];
221
- for (let i = 0; i < lines.length; i++) {
247
+ let i = 0;
248
+ while (i < lines.length) {
222
249
  const trimmed = lines[i].trim();
223
- const prevTrimmed = deduped.length > 0 ? deduped[deduped.length - 1].trim() : null;
224
- if (trimmed && trimmed === prevTrimmed) {
225
- continue; // skip consecutive duplicate
250
+ if (!trimmed) {
251
+ deduped.push(lines[i]);
252
+ i++;
253
+ continue;
254
+ }
255
+ // Count the run length of identical consecutive lines
256
+ let runEnd = i + 1;
257
+ while (runEnd < lines.length && lines[runEnd].trim() === trimmed) {
258
+ runEnd++;
226
259
  }
227
- deduped.push(lines[i]);
260
+ const runLen = runEnd - i;
261
+ // Check if this run is "effectively at end" — only trailing empty
262
+ // lines follow the duplicate pair.
263
+ const isAtEffectiveEnd = runEnd === lines.length
264
+ || lines.slice(runEnd).every(l => l.trim() === '');
265
+ if (runLen >= 3) {
266
+ // 3+ identical lines is almost certainly stutter — keep one
267
+ deduped.push(lines[i]);
268
+ }
269
+ else if (runLen === 2 && isAtEffectiveEnd) {
270
+ // Exactly 2 identical lines at the very end — trailing stutter
271
+ deduped.push(lines[i]);
272
+ }
273
+ else {
274
+ // 1 line, or a pair in the middle — keep all
275
+ for (let j = i; j < runEnd; j++) {
276
+ deduped.push(lines[j]);
277
+ }
278
+ }
279
+ i = runEnd;
228
280
  }
229
281
  if (deduped.length < lines.length) {
230
282
  return deduped.join('\n');
@@ -269,6 +321,11 @@ Return the complete modified file content:`,
269
321
  await this.applyFix(filePath, original, modified);
270
322
  return;
271
323
  }
324
+ // Non-TTY: show the diff but don't try to prompt — re-run with --apply
325
+ if (!process.stdin.isTTY) {
326
+ this.logger.info('Non-interactive mode. Re-run with --apply to apply changes.');
327
+ return;
328
+ }
272
329
  const { action } = await inquirer_1.default.prompt([
273
330
  {
274
331
  type: 'list',
@@ -33,6 +33,12 @@ class ExplainCommand {
33
33
  this.logger.error('Not authenticated. Run: vigthoria login');
34
34
  return;
35
35
  }
36
+ // Server-side token validation — fail fast instead of waiting for 401
37
+ const tokenCheck = await this.api.validateToken();
38
+ if (!tokenCheck.valid) {
39
+ this.logger.error(tokenCheck.error || 'Auth token is invalid. Run: vigthoria login');
40
+ return;
41
+ }
36
42
  // Read file
37
43
  const file = this.fileUtils.readFile(filePath);
38
44
  if (!file) {
@@ -75,7 +81,8 @@ class ExplainCommand {
75
81
  }
76
82
  catch (error) {
77
83
  spinner.stop();
78
- this.logger.error('Explanation failed:', error.message);
84
+ const cliErr = error instanceof api_js_1.CLIError ? error : (0, api_js_1.classifyError)(error);
85
+ this.logger.error((0, api_js_1.formatCLIError)(cliErr));
79
86
  }
80
87
  }
81
88
  formatExplanation(explanation, detail) {
@@ -31,6 +31,12 @@ class GenerateCommand {
31
31
  this.logger.error('Not authenticated. Run: vigthoria login');
32
32
  return;
33
33
  }
34
+ // Server-side token validation — fail fast instead of waiting for 401
35
+ const tokenCheck = await this.api.validateToken();
36
+ if (!tokenCheck.valid) {
37
+ this.logger.error(tokenCheck.error || 'Auth token is invalid. Run: vigthoria login');
38
+ return;
39
+ }
34
40
  // Determine mode
35
41
  const proMode = options.pro === true;
36
42
  // Auto-detect language from description if not explicitly specified
@@ -105,7 +111,8 @@ class GenerateCommand {
105
111
  }
106
112
  catch (error) {
107
113
  spinner.stop();
108
- this.logger.error('Generation failed:', error.message);
114
+ const cliErr = error instanceof api_js_1.CLIError ? error : (0, api_js_1.classifyError)(error);
115
+ this.logger.error((0, api_js_1.formatCLIError)(cliErr));
109
116
  }
110
117
  }
111
118
  /**
@@ -33,6 +33,12 @@ class ReviewCommand {
33
33
  this.logger.error('Not authenticated. Run: vigthoria login');
34
34
  return;
35
35
  }
36
+ // Server-side token validation — fail fast instead of waiting for 401
37
+ const tokenCheck = await this.api.validateToken();
38
+ if (!tokenCheck.valid) {
39
+ this.logger.error(tokenCheck.error || 'Auth token is invalid. Run: vigthoria login');
40
+ return;
41
+ }
36
42
  // Read file
37
43
  const file = this.fileUtils.readFile(filePath);
38
44
  if (!file) {
@@ -65,7 +71,8 @@ class ReviewCommand {
65
71
  }
66
72
  catch (error) {
67
73
  spinner.stop();
68
- this.logger.error('Review failed:', error.message);
74
+ const cliErr = error instanceof api_js_1.CLIError ? error : (0, api_js_1.classifyError)(error);
75
+ this.logger.error((0, api_js_1.formatCLIError)(cliErr));
69
76
  }
70
77
  }
71
78
  printTextReview(review) {
@@ -4,6 +4,21 @@
4
4
  */
5
5
  import { Config } from './config.js';
6
6
  import { Logger } from './logger.js';
7
+ export type CLIErrorCategory = 'auth' | 'repo_session' | 'model_backend' | 'bridge' | 'network' | 'timeout' | 'parsing' | 'tool_execution';
8
+ export declare class CLIError extends Error {
9
+ category: CLIErrorCategory;
10
+ statusCode?: number;
11
+ endpoint?: string;
12
+ constructor(message: string, category: CLIErrorCategory, opts?: {
13
+ statusCode?: number;
14
+ endpoint?: string;
15
+ cause?: Error;
16
+ });
17
+ }
18
+ /** Classify an axios or fetch error into a structured CLIError. */
19
+ export declare function classifyError(error: unknown, fallbackCategory?: CLIErrorCategory): CLIError;
20
+ /** Format a CLIError for user-facing display. */
21
+ export declare function formatCLIError(err: CLIError): string;
7
22
  export interface ChatMessage {
8
23
  role: 'user' | 'assistant' | 'system';
9
24
  content: string;
@@ -186,6 +201,17 @@ export declare class APIClient {
186
201
  private refreshToken;
187
202
  getSubscriptionStatus(): Promise<void>;
188
203
  private getAccessToken;
204
+ /**
205
+ * Validate the current auth token against the Coder API.
206
+ * Returns { valid: true } when the server accepts the token,
207
+ * { valid: false, error } when the token is rejected (401/403),
208
+ * and { valid: true } when the server is unreachable (network error)
209
+ * so that offline/degraded scenarios don't block the user.
210
+ */
211
+ validateToken(): Promise<{
212
+ valid: boolean;
213
+ error?: string;
214
+ }>;
189
215
  getV3AgentBaseUrls(preferLocal?: boolean): string[];
190
216
  getV3AgentRunUrl(baseUrl: string): string;
191
217
  getV3AgentContinueUrl(baseUrl: string): string;
@@ -395,6 +421,18 @@ export declare class APIClient {
395
421
  * Returns a human-readable description of obvious errors, or empty string.
396
422
  */
397
423
  private detectSyntaxErrors;
424
+ /**
425
+ * Strip comment lines that the model added during a fix but were not
426
+ * present in the original code. Used for syntax-only fixes where the
427
+ * model tends to annotate its changes with "// Fixed ..." comments.
428
+ */
429
+ private stripInjectedComments;
430
+ /**
431
+ * Ensure the fixed code hasn't lost closing delimiters relative to the
432
+ * original. Counts {, }, (, ), [, ] outside strings/comments and if
433
+ * the fix has fewer closers than the original, appends the missing ones.
434
+ */
435
+ private repairBracketBalance;
398
436
  private resolveModelId;
399
437
  private getCoderHealth;
400
438
  private getModelsHealth;
package/dist/utils/api.js CHANGED
@@ -7,7 +7,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
7
7
  return (mod && mod.__esModule) ? mod : { "default": mod };
8
8
  };
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
- exports.APIClient = void 0;
10
+ exports.APIClient = exports.CLIError = void 0;
11
+ exports.classifyError = classifyError;
12
+ exports.formatCLIError = formatCLIError;
11
13
  const axios_1 = __importDefault(require("axios"));
12
14
  const crypto_1 = require("crypto");
13
15
  const fs_1 = __importDefault(require("fs"));
@@ -15,6 +17,77 @@ const https_1 = __importDefault(require("https"));
15
17
  const net_1 = __importDefault(require("net"));
16
18
  const path_1 = __importDefault(require("path"));
17
19
  const ws_1 = __importDefault(require("ws"));
20
+ class CLIError extends Error {
21
+ category;
22
+ statusCode;
23
+ endpoint;
24
+ constructor(message, category, opts) {
25
+ super(message);
26
+ this.name = 'CLIError';
27
+ this.category = category;
28
+ this.statusCode = opts?.statusCode;
29
+ this.endpoint = opts?.endpoint;
30
+ if (opts?.cause)
31
+ this.cause = opts.cause;
32
+ }
33
+ }
34
+ exports.CLIError = CLIError;
35
+ /** Classify an axios or fetch error into a structured CLIError. */
36
+ function classifyError(error, fallbackCategory = 'network') {
37
+ if (error instanceof CLIError)
38
+ return error;
39
+ const axErr = error;
40
+ const status = axErr?.response?.status;
41
+ const endpoint = axErr?.config?.url || axErr?.config?.baseURL || '';
42
+ const message = axErr?.response?.data
43
+ ? typeof axErr.response.data.error === 'string'
44
+ ? axErr.response.data.error
45
+ : typeof axErr.response.data.message === 'string'
46
+ ? axErr.response.data.message
47
+ : error.message
48
+ : error.message || String(error);
49
+ if (status === 401 || status === 403) {
50
+ // Distinguish repo/community auth from model auth
51
+ if (/community|repo/i.test(endpoint)) {
52
+ return new CLIError(message, 'repo_session', { statusCode: status, endpoint });
53
+ }
54
+ return new CLIError(message, 'auth', { statusCode: status, endpoint });
55
+ }
56
+ if (status && status >= 500) {
57
+ return new CLIError(message, 'model_backend', { statusCode: status, endpoint });
58
+ }
59
+ if (/timeout|ETIMEDOUT|ESOCKETTIMEDOUT|aborted/i.test(message)) {
60
+ return new CLIError(message, 'timeout', { endpoint });
61
+ }
62
+ if (/ECONNREFUSED|ENOTFOUND|ENETUNREACH|EAI_AGAIN|fetch failed/i.test(message)) {
63
+ return new CLIError(message, 'network', { endpoint });
64
+ }
65
+ return new CLIError(message, fallbackCategory, { statusCode: status, endpoint, cause: error instanceof Error ? error : undefined });
66
+ }
67
+ /** Format a CLIError for user-facing display. */
68
+ function formatCLIError(err) {
69
+ const tag = `[${err.category}]`;
70
+ switch (err.category) {
71
+ case 'auth':
72
+ return `${tag} Authentication failed${err.statusCode ? ` (${err.statusCode})` : ''}. Run: vigthoria login`;
73
+ case 'repo_session':
74
+ return `${tag} Repository session expired or missing${err.statusCode ? ` (${err.statusCode})` : ''}. This does not affect AI commands. Re-authenticate repo with: vigthoria repo list`;
75
+ case 'model_backend':
76
+ return `${tag} Model backend error${err.statusCode ? ` (${err.statusCode})` : ''}: ${err.message}`;
77
+ case 'bridge':
78
+ return `${tag} Bridge connection error: ${err.message}`;
79
+ case 'network':
80
+ return `${tag} Network error: ${err.message}. Check your internet connection.`;
81
+ case 'timeout':
82
+ return `${tag} Request timed out: ${err.message}`;
83
+ case 'parsing':
84
+ return `${tag} Response parsing error: ${err.message}`;
85
+ case 'tool_execution':
86
+ return `${tag} Tool execution error: ${err.message}`;
87
+ default:
88
+ return `${tag} ${err.message}`;
89
+ }
90
+ }
18
91
  const DEFAULT_V3_AGENT_TIMEOUT_MS = (() => {
19
92
  const rawValue = process.env.VIGTHORIA_AGENT_TIMEOUT_MS || process.env.V3_AGENT_TIMEOUT_MS || '1200000';
20
93
  const parsed = Number.parseInt(rawValue, 10);
@@ -26,9 +99,9 @@ const DEFAULT_V3_AGENT_IDLE_TIMEOUT_MS = (() => {
26
99
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 90000;
27
100
  })();
28
101
  const DEFAULT_OPERATOR_TIMEOUT_MS = (() => {
29
- const rawValue = process.env.VIGTHORIA_OPERATOR_TIMEOUT_MS || process.env.OPERATOR_TIMEOUT_MS || '3900000';
102
+ const rawValue = process.env.VIGTHORIA_OPERATOR_TIMEOUT_MS || process.env.OPERATOR_TIMEOUT_MS || '300000';
30
103
  const parsed = Number.parseInt(rawValue, 10);
31
- return Number.isFinite(parsed) && parsed > 0 ? parsed : 3900000;
104
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 300000;
32
105
  })();
33
106
  class APIClient {
34
107
  client;
@@ -102,16 +175,24 @@ class APIClient {
102
175
  }
103
176
  return req;
104
177
  });
105
- // Add response interceptor for token refresh
106
- this.client.interceptors.response.use((res) => res, async (error) => {
107
- if (error.response?.status === 401) {
108
- const refreshed = await this.refreshToken();
109
- if (refreshed && error.config) {
110
- return this.client.request(error.config);
178
+ // Add response interceptors for token refresh + structured errors
179
+ const createAuthRetryInterceptor = (client) => {
180
+ client.interceptors.response.use((res) => res, async (error) => {
181
+ if (error.response?.status === 401) {
182
+ const refreshed = await this.refreshToken();
183
+ if (refreshed && error.config) {
184
+ return client.request(error.config);
185
+ }
186
+ throw classifyError(error, 'auth');
111
187
  }
112
- }
113
- throw error;
114
- });
188
+ throw classifyError(error);
189
+ });
190
+ };
191
+ createAuthRetryInterceptor(this.client);
192
+ createAuthRetryInterceptor(this.modelRouterClient);
193
+ if (this.selfHostedModelRouterClient) {
194
+ createAuthRetryInterceptor(this.selfHostedModelRouterClient);
195
+ }
115
196
  }
116
197
  getSelfHostedModelsApiUrl() {
117
198
  const configuredUrl = process.env.VIGTHORIA_SELF_HOSTED_MODELS_API_URL
@@ -259,6 +340,34 @@ class APIClient {
259
340
  || this.config.get('authToken')
260
341
  || null;
261
342
  }
343
+ /**
344
+ * Validate the current auth token against the Coder API.
345
+ * Returns { valid: true } when the server accepts the token,
346
+ * { valid: false, error } when the token is rejected (401/403),
347
+ * and { valid: true } when the server is unreachable (network error)
348
+ * so that offline/degraded scenarios don't block the user.
349
+ */
350
+ async validateToken() {
351
+ const token = this.getAccessToken();
352
+ if (!token) {
353
+ return { valid: false, error: 'No auth token configured. Run: vigthoria login' };
354
+ }
355
+ try {
356
+ await this.client.get('/api/user/profile', { timeout: 10000 });
357
+ return { valid: true };
358
+ }
359
+ catch (error) {
360
+ if (error instanceof CLIError && error.category === 'auth') {
361
+ return { valid: false, error: 'Auth token expired or invalid. Run: vigthoria login' };
362
+ }
363
+ const axErr = error;
364
+ if (axErr.response?.status === 401 || axErr.response?.status === 403) {
365
+ return { valid: false, error: 'Auth token expired or invalid. Run: vigthoria login' };
366
+ }
367
+ // Network/timeout errors — don't assume token is bad
368
+ return { valid: true };
369
+ }
370
+ }
262
371
  getV3AgentBaseUrls(preferLocal = false) {
263
372
  const configuredApiUrl = String(this.config.get('apiUrl') || 'https://coder.vigthoria.io').replace(/\/$/, '');
264
373
  const allowLocalV3Agent = process.env.VIGTHORIA_ALLOW_LOCAL_V3_AGENT === '1' || preferLocal;
@@ -2932,13 +3041,18 @@ document.addEventListener('DOMContentLoaded', () => {
2932
3041
  };
2933
3042
  }
2934
3043
  catch (error) {
3044
+ const isAbort = error?.name === 'AbortError' || error?.code === 'ABORT_ERR';
3045
+ if (isAbort) {
3046
+ const mins = Math.round(timeoutMs / 60000);
3047
+ throw new CLIError(`Operator workflow timed out after ${mins} minute(s). You can increase the timeout with VIGTHORIA_OPERATOR_TIMEOUT_MS.`, 'timeout', error);
3048
+ }
2935
3049
  errors.push(`${baseUrl}: ${error?.message || String(error)}`);
2936
3050
  }
2937
3051
  finally {
2938
3052
  clearTimeout(timeoutId);
2939
3053
  }
2940
3054
  }
2941
- throw new Error(errors.join(' | '));
3055
+ throw new CLIError(`Operator workflow failed on all endpoints: ${errors.join(' | ')}`, 'model_backend');
2942
3056
  }
2943
3057
  /**
2944
3058
  * Chat API - Direct Vigthoria Models API Architecture
@@ -2970,7 +3084,7 @@ document.addEventListener('DOMContentLoaded', () => {
2970
3084
  }
2971
3085
  }
2972
3086
  // No more localhost fallbacks - CLI is for external users!
2973
- throw new Error('AI service unavailable. Please check your internet connection or try again later.');
3087
+ throw new CLIError('AI service unavailable. Please check your internet connection or try again later.', 'model_backend');
2974
3088
  }
2975
3089
  shouldSkipCloudRoutes(resolvedModel) {
2976
3090
  return this.shouldSimulateCloudFailure() && this.isCloudModelId(resolvedModel);
@@ -3602,9 +3716,19 @@ document.addEventListener('DOMContentLoaded', () => {
3602
3716
  .map(i => `Line ${i.line}: [${i.type}] ${i.message}`)
3603
3717
  .join('\n// ');
3604
3718
  const allHints = [syntaxHints, logicHints].filter(Boolean).join('\n// ');
3605
- const augmentedCode = allHints
3606
- ? `// BUGS DETECTED BY STATIC ANALYSIS — YOU MUST FIX THESE:\n// ${allHints}\n// IMPORTANT: Fix ONLY these specific bugs. Do not add comments, do not restructure the code, do not add or remove lines beyond the minimal fix.\n\n${code}`
3607
- : code;
3719
+ // Build a constraint preamble that's stricter for syntax-only mode.
3720
+ let preamble;
3721
+ if (fixType === 'syntax') {
3722
+ preamble = allHints
3723
+ ? `// SYNTAX ERRORS DETECTED BY CLIENT:\n// ${allHints}\n// RULES: Fix ONLY bracket/parenthesis/brace mismatches and keyword typos. Do NOT rename functions, do NOT add comments, do NOT restructure or reformat. Output the corrected code ONLY.\n\n`
3724
+ : '';
3725
+ }
3726
+ else {
3727
+ preamble = allHints
3728
+ ? `// BUGS DETECTED BY STATIC ANALYSIS — YOU MUST FIX THESE:\n// ${allHints}\n// IMPORTANT: Fix ONLY these specific bugs. Do not add comments, do not restructure the code, do not add or remove lines beyond the minimal fix.\n\n`
3729
+ : '';
3730
+ }
3731
+ const augmentedCode = preamble ? `${preamble}${code}` : code;
3608
3732
  const response = await this.client.post('/api/ai/fix', {
3609
3733
  code: augmentedCode,
3610
3734
  language,
@@ -3616,16 +3740,27 @@ document.addEventListener('DOMContentLoaded', () => {
3616
3740
  // If server returned no changes but we found issues, strip
3617
3741
  // our injected comment prefix from the returned code and attempt
3618
3742
  // a basic client-side repair.
3619
- if (changes.length === 0 && allHints && fixed === augmentedCode) {
3743
+ if (changes.length === 0 && preamble && fixed === augmentedCode) {
3620
3744
  fixed = code; // restore original
3621
3745
  }
3622
3746
  // Strip the injected comment block if it leaked into the output
3623
- if (fixed.startsWith('// BUGS DETECTED BY STATIC ANALYSIS') || fixed.startsWith('// SYNTAX ERRORS DETECTED BY CLIENT:')) {
3747
+ if (fixed.startsWith('// BUGS DETECTED BY STATIC ANALYSIS') || fixed.startsWith('// SYNTAX ERRORS DETECTED BY CLIENT')) {
3624
3748
  const idx = fixed.indexOf('\n\n');
3625
3749
  if (idx !== -1) {
3626
3750
  fixed = fixed.slice(idx + 2);
3627
3751
  }
3628
3752
  }
3753
+ // For syntax-only fixes, strip any comments the model added that
3754
+ // weren't in the original code (e.g. "// Fixed mismatched parentheses").
3755
+ if (fixType === 'syntax' && fixed !== code) {
3756
+ fixed = this.stripInjectedComments(code, fixed, language);
3757
+ }
3758
+ // Safety net: for syntax fixes, ensure the fix didn't make bracket
3759
+ // balance worse. If the original had more closing delimiters than
3760
+ // the fix, append the missing ones.
3761
+ if (fixType === 'syntax' && fixed !== code) {
3762
+ fixed = this.repairBracketBalance(code, fixed);
3763
+ }
3629
3764
  // If there are still no changes but the fixed code differs, compute
3630
3765
  // a semantic diff using LCS so inserted/removed lines don't cause
3631
3766
  // every subsequent line to appear as changed.
@@ -3806,6 +3941,125 @@ document.addEventListener('DOMContentLoaded', () => {
3806
3941
  }
3807
3942
  return errors.join('; ');
3808
3943
  }
3944
+ /**
3945
+ * Strip comment lines that the model added during a fix but were not
3946
+ * present in the original code. Used for syntax-only fixes where the
3947
+ * model tends to annotate its changes with "// Fixed ..." comments.
3948
+ */
3949
+ stripInjectedComments(original, fixed, language) {
3950
+ const lang = language.toLowerCase();
3951
+ // Only handle JS/TS/Python single-line comment patterns for now
3952
+ let wholeLineRe;
3953
+ let inlineRe;
3954
+ if (lang === 'python' || lang === 'py') {
3955
+ wholeLineRe = /^\s*#\s/;
3956
+ inlineRe = /\s+#\s.*$/;
3957
+ }
3958
+ else {
3959
+ wholeLineRe = /^\s*\/\/\s/;
3960
+ inlineRe = /\s+\/\/\s.*$/;
3961
+ }
3962
+ const origLines = original.split('\n');
3963
+ const origSet = new Set(origLines.map(l => l.trim()));
3964
+ const fixedLines = fixed.split('\n');
3965
+ const result = [];
3966
+ for (let idx = 0; idx < fixedLines.length; idx++) {
3967
+ let line = fixedLines[idx];
3968
+ // Remove whole-line comments not in original
3969
+ if (wholeLineRe.test(line) && !origSet.has(line.trim())) {
3970
+ continue;
3971
+ }
3972
+ // Strip inline trailing comments the model added.
3973
+ // Only strip if the corresponding original line at the same position
3974
+ // didn't have an inline comment at all.
3975
+ if (inlineRe.test(line)) {
3976
+ const origLine = idx < origLines.length ? origLines[idx] : '';
3977
+ if (!inlineRe.test(origLine)) {
3978
+ line = line.replace(inlineRe, '');
3979
+ }
3980
+ }
3981
+ result.push(line);
3982
+ }
3983
+ return result.join('\n');
3984
+ }
3985
+ /**
3986
+ * Ensure the fixed code hasn't lost closing delimiters relative to the
3987
+ * original. Counts {, }, (, ), [, ] outside strings/comments and if
3988
+ * the fix has fewer closers than the original, appends the missing ones.
3989
+ */
3990
+ repairBracketBalance(original, fixed) {
3991
+ const count = (src) => {
3992
+ let braces = 0, parens = 0, brackets = 0;
3993
+ let inStr = null;
3994
+ let inLine = false, inBlock = false;
3995
+ for (let i = 0; i < src.length; i++) {
3996
+ const ch = src[i], nx = src[i + 1] || '';
3997
+ if (inLine) {
3998
+ if (ch === '\n')
3999
+ inLine = false;
4000
+ continue;
4001
+ }
4002
+ if (inBlock) {
4003
+ if (ch === '*' && nx === '/') {
4004
+ inBlock = false;
4005
+ i++;
4006
+ }
4007
+ continue;
4008
+ }
4009
+ if (inStr) {
4010
+ if (ch === inStr && src[i - 1] !== '\\')
4011
+ inStr = null;
4012
+ continue;
4013
+ }
4014
+ if (ch === '/' && nx === '/') {
4015
+ inLine = true;
4016
+ continue;
4017
+ }
4018
+ if (ch === '/' && nx === '*') {
4019
+ inBlock = true;
4020
+ continue;
4021
+ }
4022
+ if (ch === '"' || ch === "'" || ch === '`') {
4023
+ inStr = ch;
4024
+ continue;
4025
+ }
4026
+ if (ch === '{')
4027
+ braces++;
4028
+ else if (ch === '}')
4029
+ braces--;
4030
+ else if (ch === '(')
4031
+ parens++;
4032
+ else if (ch === ')')
4033
+ parens--;
4034
+ else if (ch === '[')
4035
+ brackets++;
4036
+ else if (ch === ']')
4037
+ brackets--;
4038
+ }
4039
+ return { braces, parens, brackets };
4040
+ };
4041
+ const orig = count(original);
4042
+ const fix = count(fixed);
4043
+ const append = [];
4044
+ // If original was balanced (or close) but fix is missing closers,
4045
+ // append them. Only act when the fix is *more unbalanced* than original.
4046
+ if (fix.braces > orig.braces) {
4047
+ for (let i = 0; i < fix.braces - orig.braces; i++)
4048
+ append.push('}');
4049
+ }
4050
+ if (fix.parens > orig.parens) {
4051
+ for (let i = 0; i < fix.parens - orig.parens; i++)
4052
+ append.push(')');
4053
+ }
4054
+ if (fix.brackets > orig.brackets) {
4055
+ for (let i = 0; i < fix.brackets - orig.brackets; i++)
4056
+ append.push(']');
4057
+ }
4058
+ if (append.length > 0) {
4059
+ return fixed.trimEnd() + '\n' + append.join('\n');
4060
+ }
4061
+ return fixed;
4062
+ }
3809
4063
  // Model resolution - maps Vigthoria model names to internal IDs
3810
4064
  // INTERNAL USE ONLY - users see only Vigthoria branding
3811
4065
  resolveModelId(shortName) {
@@ -24,7 +24,12 @@ const ora_1 = __importDefault(require("ora"));
24
24
  */
25
25
  function createSpinner(textOrOpts) {
26
26
  const opts = typeof textOrOpts === 'string' ? { text: textOrOpts } : textOrOpts;
27
- return (0, ora_1.default)({ ...opts, stream: process.stderr });
27
+ // Suppress spinner animation when stderr is not a TTY (piped output,
28
+ // CI, non-interactive terminals). The spinner object still works — its
29
+ // .start()/.stop()/.succeed() methods are no-ops — so callers don't
30
+ // need conditional logic.
31
+ const isSilent = !process.stderr.isTTY;
32
+ return (0, ora_1.default)({ ...opts, stream: process.stderr, isSilent });
28
33
  }
29
34
  class Logger {
30
35
  verbose = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigthoria-cli",
3
- "version": "1.6.28",
3
+ "version": "1.6.30",
4
4
  "description": "Vigthoria Coder CLI - AI-powered terminal coding assistant",
5
5
  "main": "dist/index.js",
6
6
  "files": [