autoclaw 1.3.1 → 1.3.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.
package/README.md CHANGED
@@ -78,7 +78,7 @@ npm install -g autoclaw
78
78
 
79
79
  ## Quick Start
80
80
 
81
- 1. **Setup**: Run the interactive setup wizard to configure your API keys and integrations.
81
+ 1. **Setup**: Run the interactive setup wizard to configure your API keys and integrations. The wizard runs a live connection test (failures map to the likely wrong field: 401 = key, 404 = base URL, 400 = model name) and can list the provider's models for you to pick from.
82
82
  ```bash
83
83
  autoclaw setup
84
84
  ```
package/README.zh-CN.md CHANGED
@@ -79,7 +79,7 @@ npm install -g autoclaw
79
79
 
80
80
  ## 快速上手
81
81
 
82
- 1. **配置**: 运行交互式设置向导以配置您的 API 密钥和集成插件。
82
+ 1. **配置**: 运行交互式设置向导以配置您的 API 密钥和集成插件。向导会现场做连接测试(失败映射到最可能出错的字段:401=key 错、404=URL 错、400=模型名错),并能拉取服务商的模型列表供你直接选择。
83
83
  ```bash
84
84
  autoclaw setup
85
85
  ```
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import dotenv from 'dotenv';
6
6
  import { Agent } from './agent.js';
7
7
  import { parseManifest, runBatch } from './batch.js';
8
8
  import { PROVIDER_PRESETS, providerNames, resolveProvider } from './providers.js';
9
+ import { fetchModelIds, normalizeBaseUrl, testConnection } from './setup.js';
9
10
  import * as fs from 'fs';
10
11
  import * as path from 'path';
11
12
  import * as os from 'os';
@@ -44,7 +45,7 @@ dotenv.config({ path: GLOBAL_ENV_FILE });
44
45
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
45
46
  // In dist/index.js, package.json is usually up one level in the root
46
47
  const pkgPath = path.join(__dirname, '..', 'package.json');
47
- let version = '1.3.1';
48
+ let version = '1.3.2';
48
49
  try {
49
50
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
50
51
  version = pkg.version;
@@ -109,7 +110,7 @@ async function runSetup(options = {}) {
109
110
  }
110
111
  const providerAnswer = await inquirer.prompt([
111
112
  {
112
- type: 'list',
113
+ type: 'select',
113
114
  name: 'provider',
114
115
  message: 'Select your LLM provider:',
115
116
  choices: [
@@ -121,34 +122,101 @@ async function runSetup(options = {}) {
121
122
  ]);
122
123
  const provider = providerAnswer.provider;
123
124
  const preset = resolveProvider(provider === 'custom' ? undefined : provider);
124
- const answers = await inquirer.prompt([
125
- {
126
- type: 'password',
127
- name: 'apiKey',
128
- message: currentConfig.apiKey
129
- ? `Enter OpenAI API Key (Leave empty to keep ${maskSecret(currentConfig.apiKey)}):`
130
- : 'Enter OpenAI API Key:',
131
- mask: '*',
132
- validate: (input) => {
133
- if (input.length > 0)
134
- return true;
135
- if (currentConfig.apiKey)
136
- return true;
137
- return 'API Key cannot be empty.';
125
+ // The connection part (key / Base URL / model) is asked in a loop with a
126
+ // live test, so typos never make it into the saved config.
127
+ const askConnection = async () => {
128
+ const defaults = {
129
+ apiKey: currentConfig.apiKey,
130
+ baseUrl: currentConfig.baseUrl || preset?.baseUrl || 'https://api.openai.com/v1',
131
+ model: currentConfig.model || preset?.defaultModel || 'gpt-5.6'
132
+ };
133
+ const core = await inquirer.prompt([
134
+ {
135
+ type: 'password',
136
+ name: 'apiKey',
137
+ message: defaults.apiKey
138
+ ? `Enter API Key (Leave empty to keep ${maskSecret(defaults.apiKey)}):`
139
+ : `Enter API Key${preset?.apiKeyEnv ? ` (or set ${preset.apiKeyEnv} in your environment)` : ''}:`,
140
+ mask: '*',
141
+ validate: (input) => {
142
+ if (input.length > 0)
143
+ return true;
144
+ if (defaults.apiKey)
145
+ return true;
146
+ return 'API Key cannot be empty.';
147
+ }
148
+ },
149
+ {
150
+ type: 'input',
151
+ name: 'baseUrl',
152
+ message: 'Enter API Base URL:',
153
+ default: defaults.baseUrl
138
154
  }
139
- },
140
- {
141
- type: 'input',
142
- name: 'baseUrl',
143
- message: 'Enter API Base URL:',
144
- default: currentConfig.baseUrl || preset?.baseUrl || 'https://api.openai.com/v1'
145
- },
146
- {
147
- type: 'input',
148
- name: 'model',
149
- message: 'Enter default Model:',
150
- default: currentConfig.model || preset?.defaultModel || 'gpt-5.6'
151
- },
155
+ ]);
156
+ const apiKey = core.apiKey || defaults.apiKey || '';
157
+ const baseUrl = normalizeBaseUrl(core.baseUrl || defaults.baseUrl);
158
+ // Prefer the provider's own catalog over guessing model names.
159
+ console.log(chalk.dim('Fetching available models...'));
160
+ const ids = await fetchModelIds(baseUrl, apiKey);
161
+ let model;
162
+ if (ids) {
163
+ console.log(chalk.dim(`Found ${ids.length} models.`));
164
+ const picked = await inquirer.prompt([
165
+ {
166
+ type: 'select',
167
+ name: 'model',
168
+ message: 'Select default Model:',
169
+ choices: [{ name: '✎ Enter manually', value: '__manual__' }, ...ids.map(id => ({ name: id, value: id }))],
170
+ default: ids.includes(defaults.model) ? defaults.model : undefined,
171
+ pageSize: 12
172
+ }
173
+ ]);
174
+ if (picked.model === '__manual__') {
175
+ const manual = await inquirer.prompt([
176
+ { type: 'input', name: 'model', message: 'Enter default Model:', default: defaults.model }
177
+ ]);
178
+ model = manual.model;
179
+ }
180
+ else {
181
+ model = picked.model;
182
+ }
183
+ }
184
+ else {
185
+ const manual = await inquirer.prompt([
186
+ { type: 'input', name: 'model', message: 'Enter default Model (catalog unavailable):', default: defaults.model }
187
+ ]);
188
+ model = manual.model;
189
+ }
190
+ return { apiKey, baseUrl, model };
191
+ };
192
+ let connection = await askConnection();
193
+ console.log(chalk.dim('Running connection test (sends one tiny prompt — normal provider billing applies)...'));
194
+ let test = await testConnection(connection.baseUrl, connection.apiKey, connection.model);
195
+ while (!test.ok) {
196
+ console.log(chalk.red(`\n✗ ${test.message}`));
197
+ const next = await inquirer.prompt([
198
+ {
199
+ type: 'select',
200
+ name: 'action',
201
+ message: 'Connection test failed. What next?',
202
+ choices: [
203
+ { name: 'Re-enter API key / Base URL / model', value: 'edit' },
204
+ { name: 'Test again', value: 'retry' },
205
+ { name: 'Save anyway (e.g. the provider is temporarily down)', value: 'save' }
206
+ ],
207
+ default: 'edit'
208
+ }
209
+ ]);
210
+ if (next.action === 'save')
211
+ break;
212
+ if (next.action === 'edit')
213
+ connection = await askConnection();
214
+ console.log(chalk.dim('Running connection test...'));
215
+ test = await testConnection(connection.baseUrl, connection.apiKey, connection.model);
216
+ }
217
+ if (test.ok)
218
+ console.log(chalk.green('✓ Connection test passed.'));
219
+ const answers = await inquirer.prompt([
152
220
  {
153
221
  type: 'confirm',
154
222
  name: 'configureImage',
@@ -183,7 +251,7 @@ async function runSetup(options = {}) {
183
251
  }
184
252
  ]);
185
253
  // Resolve sensitive values (Keep old if empty)
186
- const finalApiKey = answers.apiKey || currentConfig.apiKey;
254
+ const finalApiKey = connection.apiKey || currentConfig.apiKey;
187
255
  let imageConfig = {
188
256
  imageApiKey: currentConfig.imageApiKey,
189
257
  imageBaseUrl: currentConfig.imageBaseUrl,
@@ -346,8 +414,8 @@ async function runSetup(options = {}) {
346
414
  }
347
415
  const newConfig = {
348
416
  apiKey: finalApiKey,
349
- baseUrl: answers.baseUrl,
350
- model: answers.model,
417
+ baseUrl: connection.baseUrl,
418
+ model: connection.model,
351
419
  provider: provider === 'custom' ? undefined : provider,
352
420
  ...imageConfig,
353
421
  ...emailConfig,
@@ -360,6 +428,8 @@ async function runSetup(options = {}) {
360
428
  }
361
429
  fs.writeFileSync(targetFile, JSON.stringify(newConfig, null, 2), { mode: 0o600 });
362
430
  console.log(chalk.green(`\n✅ Configuration saved to ${targetFile}`));
431
+ console.log(chalk.dim(` provider: ${provider === 'custom' ? 'custom' : provider} | baseUrl: ${connection.baseUrl} | model: ${connection.model}`));
432
+ console.log(chalk.dim(` connection test: ${test.ok ? 'passed ✓' : 'skipped (saved without a passing test)'}`));
363
433
  console.log(chalk.cyan("You can now run 'autoclaw' to start using the agent."));
364
434
  }
365
435
  catch (error) {
package/dist/setup.js ADDED
@@ -0,0 +1,61 @@
1
+ // Pure helpers behind the setup wizard: URL normalization, a live
2
+ // connection test with actionable error mapping, and provider model
3
+ // catalog fetching. Kept free of inquirer so they are unit-testable.
4
+ export function normalizeBaseUrl(url) {
5
+ let u = String(url ?? '').trim();
6
+ if (u && !/^https?:\/\//i.test(u))
7
+ u = `https://${u}`;
8
+ return u.replace(/\/+$/, '');
9
+ }
10
+ // Sends one tiny real prompt through the exact endpoint the user
11
+ // configured, and maps failures to the field that is most likely wrong.
12
+ export async function testConnection(baseUrl, apiKey, model) {
13
+ const url = `${normalizeBaseUrl(baseUrl)}/chat/completions`;
14
+ let resp;
15
+ try {
16
+ resp = await fetch(url, {
17
+ method: 'POST',
18
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
19
+ body: JSON.stringify({ model, messages: [{ role: 'user', content: 'ping' }], stream: false }),
20
+ signal: AbortSignal.timeout(15000)
21
+ });
22
+ }
23
+ catch (err) {
24
+ return { ok: false, kind: 'network', message: `Cannot reach ${url} (${err?.message ?? err}). Check the Base URL and your network.` };
25
+ }
26
+ if (resp.ok) {
27
+ return { ok: true, kind: 'ok', message: `Connection OK — ${model} responded.` };
28
+ }
29
+ const detail = (await resp.text().catch(() => '')).slice(0, 300);
30
+ if (resp.status === 401 || resp.status === 403) {
31
+ return { ok: false, kind: 'auth', message: `API key rejected (${resp.status}). Double-check the key. ${detail}` };
32
+ }
33
+ if (resp.status === 404) {
34
+ return { ok: false, kind: 'not-found', message: `Endpoint not found (404). The Base URL is likely wrong. ${detail}` };
35
+ }
36
+ if (resp.status === 400) {
37
+ return { ok: false, kind: 'model', message: `Request rejected (400). The model name "${model}" is likely wrong for this endpoint. ${detail}` };
38
+ }
39
+ return { ok: false, kind: 'server', message: `Provider returned ${resp.status}. ${detail}` };
40
+ }
41
+ // Returns the provider's model IDs (sorted) or null when the endpoint does
42
+ // not offer a catalog — the wizard then falls back to manual entry.
43
+ export async function fetchModelIds(baseUrl, apiKey) {
44
+ try {
45
+ const resp = await fetch(`${normalizeBaseUrl(baseUrl)}/models`, {
46
+ headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
47
+ signal: AbortSignal.timeout(15000)
48
+ });
49
+ if (!resp.ok)
50
+ return null;
51
+ const data = await resp.json();
52
+ const ids = (Array.isArray(data?.data) ? data.data : [])
53
+ .map((m) => m?.id)
54
+ .filter((id) => typeof id === 'string' && id.length > 0);
55
+ ids.sort();
56
+ return ids.length > 0 ? ids : null;
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
@@ -5,6 +5,9 @@ import chalk from 'chalk';
5
5
  import { execShellCommand } from '../shell.js';
6
6
  const DEFAULT_SHELL_TIMEOUT_MS = 120000;
7
7
  const SHELL_MAX_BUFFER = 10 * 1024 * 1024;
8
+ // Bounded reads keep a huge file from exhausting memory or the model context;
9
+ // the agent-level truncation in truncate.ts applies on top of this.
10
+ const READ_FILE_MAX_BYTES = 1024 * 1024;
8
11
  export const ShellTool = {
9
12
  name: "Shell Execution",
10
13
  definition: {
@@ -78,13 +81,30 @@ export const ReadFileTool = {
78
81
  }
79
82
  },
80
83
  handler: async (args) => {
84
+ let fh;
81
85
  try {
82
- const content = await fs.readFile(args.path, 'utf-8');
86
+ fh = await fs.open(args.path, 'r');
87
+ const buf = Buffer.alloc(READ_FILE_MAX_BYTES);
88
+ const { bytesRead } = await fh.read(buf, 0, READ_FILE_MAX_BYTES, 0);
89
+ const slice = buf.subarray(0, bytesRead);
90
+ // NUL bytes are the reliable tell for binary content; returning them
91
+ // as "utf-8" would only hand the model mojibake.
92
+ if (slice.includes(0)) {
93
+ return `Error: ${args.path} looks like a binary file (${bytesRead} bytes read). Inspect it with execute_shell_command instead (e.g. strings, xxd, file).`;
94
+ }
95
+ const content = slice.toString('utf-8');
96
+ if (bytesRead === READ_FILE_MAX_BYTES) {
97
+ const { size } = await fh.stat();
98
+ return `${content}\n[AutoClaw] File truncated at ${READ_FILE_MAX_BYTES} bytes (file is ${size} bytes). Use execute_shell_command to read specific ranges.`;
99
+ }
83
100
  return content;
84
101
  }
85
102
  catch (error) {
86
103
  return `Error reading file: ${error.message}`;
87
104
  }
105
+ finally {
106
+ await fh?.close();
107
+ }
88
108
  }
89
109
  };
90
110
  export const WriteFileTool = {
@@ -34,6 +34,9 @@ export const EmailTool = {
34
34
  host: config.smtpHost,
35
35
  port: parseInt(config.smtpPort || '587'),
36
36
  secure: parseInt(config.smtpPort) === 465, // true for 465, false for other ports
37
+ connectionTimeout: 30000,
38
+ greetingTimeout: 30000,
39
+ socketTimeout: 120000,
37
40
  auth: {
38
41
  user: config.smtpUser,
39
42
  pass: config.smtpPass,
@@ -64,7 +64,7 @@ const toolDefinition = {
64
64
  }
65
65
  };
66
66
  async function downloadImage(url, destPath) {
67
- const response = await fetch(url);
67
+ const response = await fetch(url, { signal: AbortSignal.timeout(120000) });
68
68
  if (!response.ok)
69
69
  throw new Error(`Failed to download image: ${response.statusText}`);
70
70
  const buffer = await response.arrayBuffer();
@@ -80,7 +80,8 @@ export const NotifyTool = {
80
80
  const response = await fetch(webhookUrl, {
81
81
  method: "POST",
82
82
  headers: { "Content-Type": "application/json" },
83
- body: JSON.stringify(payload)
83
+ body: JSON.stringify(payload),
84
+ signal: AbortSignal.timeout(30000)
84
85
  });
85
86
  const result = await response.json();
86
87
  // Platform specific success checks
@@ -42,7 +42,8 @@ export const SearchTool = {
42
42
  include_answer: true,
43
43
  include_images: false,
44
44
  max_results: 5
45
- })
45
+ }),
46
+ signal: AbortSignal.timeout(30000)
46
47
  });
47
48
  if (!response.ok) {
48
49
  const errText = await response.text();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autoclaw",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {