@autotask/atools-tool 0.1.8 → 0.1.10

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
@@ -31,6 +31,8 @@ Selection UX:
31
31
  - TTY terminal: use `↑/↓` and `Enter`
32
32
  - Non-TTY: falls back to number input
33
33
 
34
+ If proxy startup fails during `atools-tool openclaw`, the CLI now prints environment-aware troubleshooting hints (systemd user bus, permissions, port readiness) and does not silently fail.
35
+
34
36
  Rollback to codex-compatible defaults:
35
37
 
36
38
  ```bash
@@ -42,7 +44,7 @@ Recommended selections:
42
44
  1. `gpt-5.3-codex`
43
45
  2. `off`
44
46
  3. `low`
45
- 4. Input your sub2api-compatible key
47
+ 4. Input your ATools key (same as ATOOLS_OAI_KEY)
46
48
 
47
49
  ## Install (npm)
48
50
 
@@ -53,18 +55,18 @@ npm i -g @autotask/atools-tool
53
55
  Default full install (recommended):
54
56
 
55
57
  ```bash
56
- npm i -g @autotask/atools-tool && atools-tool-install && atools-tool openclaw
58
+ npm i -g @autotask/atools-tool && atools-tool install-proxy && atools-tool openclaw
57
59
  ```
58
60
 
59
61
  Optional installer command:
60
62
 
61
63
  ```bash
62
- atools-tool-install --help
64
+ atools-tool install-proxy --help
63
65
  ```
64
66
 
65
- `atools-tool-install` service behavior by platform:
67
+ `atools-tool install-proxy` service behavior by platform (legacy alias: `atools-tool-install`):
66
68
 
67
- - Linux: systemd user service
69
+ - Linux: systemd user service (fallback to detached process if `systemctl --user` is unavailable)
68
70
  - macOS: launchd user agent (`~/Library/LaunchAgents`)
69
71
  - Windows: Task Scheduler user task (ONLOGON + start now)
70
72
 
@@ -98,6 +100,7 @@ curl -fsSL https://raw.githubusercontent.com/aak1247/sub2api-openclaw-proxy/main
98
100
  - patches `~/.openclaw/agents/main/agent/auth-profiles.json` (if API key is available)
99
101
  - service install by platform:
100
102
  - Linux: creates/enables `~/.config/systemd/user/openclaw-atools-proxy.service`
103
+ - if user bus/systemd is unavailable (for example container/root without login bus), installer auto-falls back to detached proxy process
101
104
  - macOS: creates/enables `~/Library/LaunchAgents/openclaw-atools-proxy.plist`
102
105
  - Windows: creates/updates scheduled task `openclaw-atools-proxy`
103
106
  - restarts `openclaw-gateway.service` only on Linux (unless `--no-restart`)
package/bin/cli.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { createProxyServer } from '../lib/proxy-server.mjs';
3
3
  import { runConfigOpenclaw } from '../lib/config-openclaw.mjs';
4
+ import { runConfigCodex } from '../lib/config-codex.mjs';
5
+ import { runInstall } from '../lib/install.mjs';
4
6
 
5
7
  function parseArgs(argv) {
6
8
  const out = {
@@ -47,6 +49,8 @@ Commands:
47
49
  serve Run proxy service
48
50
  openclaw Interactive OpenClaw setup (sub2api proxy check + model/reasoning/thinking/key)
49
51
  config-openclaw Alias of 'openclaw'
52
+ install-proxy Install background proxy service for OpenClaw
53
+ codex Auto-configure Codex CLI to use sub2api (direct upstream)
50
54
 
51
55
  Options:
52
56
  --openclaw-home <path> OpenClaw home dir (default: ~/.openclaw)
@@ -62,13 +66,25 @@ Options:
62
66
  }
63
67
 
64
68
  async function main() {
65
- const args = parseArgs(process.argv.slice(2));
69
+ const argv = process.argv.slice(2);
70
+
71
+ if (argv[0] === 'install-proxy') {
72
+ await runInstall(argv.slice(1));
73
+ return;
74
+ }
75
+
76
+ const args = parseArgs(argv);
66
77
 
67
78
  if (args.help) {
68
79
  printHelp();
69
80
  return;
70
81
  }
71
82
 
83
+ if (args.cmd === 'codex') {
84
+ await runConfigCodex();
85
+ return;
86
+ }
87
+
72
88
  if (args.cmd === 'config-openclaw' || args.cmd === 'openclaw') {
73
89
  await runConfigOpenclaw({ openclawHome: args.openclawHome });
74
90
  return;
@@ -0,0 +1,429 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import readlinePromises from 'node:readline/promises';
5
+ import readline from 'node:readline';
6
+ import { spawnSync } from 'node:child_process';
7
+
8
+ const PROVIDER = 'atools';
9
+ const MODEL_OPTIONS = ['gpt-5.2', 'gpt-5.3-codex', 'gpt-5.4'];
10
+ const DEFAULT_MODEL_ID = 'gpt-5.3-codex';
11
+ const UPSTREAM_BASE_URL = 'https://sub2api.atools.live/v1';
12
+ const ENV_KEY = 'ATOOLS_OAI_KEY';
13
+
14
+ const USE_COLOR = Boolean(process.stdout.isTTY && process.env.NO_COLOR !== '1');
15
+ const ANSI = {
16
+ reset: '\x1b[0m',
17
+ bold: '\x1b[1m',
18
+ cyan: '\x1b[36m',
19
+ green: '\x1b[32m',
20
+ yellow: '\x1b[33m'
21
+ };
22
+
23
+ function withColor(text, ...codes) {
24
+ if (!USE_COLOR || !codes.length) return text;
25
+ const prefix = codes.join('');
26
+ return `${prefix}${text}${ANSI.reset}`;
27
+ }
28
+
29
+ function sectionTitle(text) {
30
+ return withColor(text, ANSI.cyan, ANSI.bold);
31
+ }
32
+
33
+ function ok(text) {
34
+ return withColor(text, ANSI.green);
35
+ }
36
+
37
+ function warn(text) {
38
+ return withColor(text, ANSI.yellow);
39
+ }
40
+
41
+ function bold(text) {
42
+ return withColor(text, ANSI.bold);
43
+ }
44
+
45
+ function nowStamp() {
46
+ const d = new Date();
47
+ const p = (n) => String(n).padStart(2, '0');
48
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
49
+ }
50
+
51
+ function backup(filePath) {
52
+ if (!fs.existsSync(filePath)) return null;
53
+ const dest = `${filePath}.bak.${nowStamp()}`;
54
+ fs.copyFileSync(filePath, dest);
55
+ return dest;
56
+ }
57
+
58
+ function ensureDir(dirPath) {
59
+ fs.mkdirSync(dirPath, { recursive: true });
60
+ }
61
+
62
+ function patchCodexConfigText(original, { modelId } = {}) {
63
+ const provider = PROVIDER;
64
+ const model = modelId || DEFAULT_MODEL_ID;
65
+ const baseUrl = UPSTREAM_BASE_URL;
66
+
67
+ if (!original || !original.trim()) {
68
+ return [
69
+ '# Codex config generated by atools-tool',
70
+ `model_provider = "${provider}"`,
71
+ `model = "${model}"`,
72
+ '',
73
+ `[model_providers.${provider}]`,
74
+ 'name = "ATools"',
75
+ `base_url = "${baseUrl}"`,
76
+ 'wire_api = "responses"',
77
+ 'requires_openai_auth = false',
78
+ `env_key = "${ENV_KEY}"`,
79
+ ''
80
+ ].join('\n');
81
+ }
82
+
83
+ const lines = original.replace(/\r\n/g, '\n').split('\n');
84
+ let currentSection = '';
85
+ let inProviderSection = false;
86
+ let sawProviderSection = false;
87
+ let sawModelProviderTop = false;
88
+ let sawModelTop = false;
89
+
90
+ const updated = [];
91
+
92
+ for (const rawLine of lines) {
93
+ let line = rawLine;
94
+ const trimmed = rawLine.trim();
95
+
96
+ const sectionMatch = trimmed.match(/^\[(.+?)\]\s*$/);
97
+ if (sectionMatch) {
98
+ currentSection = sectionMatch[1];
99
+ inProviderSection = currentSection === `model_providers.${provider}`;
100
+ if (inProviderSection) sawProviderSection = true;
101
+ updated.push(line);
102
+ continue;
103
+ }
104
+
105
+ if (!currentSection) {
106
+ if (/^model_provider\s*=/.test(trimmed)) {
107
+ line = `model_provider = "${provider}"`;
108
+ sawModelProviderTop = true;
109
+ } else if (/^model\s*=/.test(trimmed)) {
110
+ line = `model = "${model}"`;
111
+ sawModelTop = true;
112
+ }
113
+ updated.push(line);
114
+ continue;
115
+ }
116
+
117
+ if (inProviderSection) {
118
+ if (/^name\s*=/.test(trimmed)) {
119
+ line = 'name = "ATools"';
120
+ } else if (/^base_url\s*=/.test(trimmed)) {
121
+ line = `base_url = "${baseUrl}"`;
122
+ } else if (/^wire_api\s*=/.test(trimmed)) {
123
+ line = 'wire_api = "responses"';
124
+ } else if (/^requires_openai_auth\s*=/.test(trimmed)) {
125
+ line = 'requires_openai_auth = false';
126
+ } else if (/^env_key\s*=/.test(trimmed)) {
127
+ line = `env_key = "${ENV_KEY}"`;
128
+ }
129
+ }
130
+
131
+ updated.push(line);
132
+ }
133
+
134
+ let resultLines = updated;
135
+
136
+ const topInsert = [];
137
+ if (!sawModelProviderTop) topInsert.push(`model_provider = "${provider}"`);
138
+ if (!sawModelTop) topInsert.push(`model = "${model}"`);
139
+
140
+ if (topInsert.length > 0) {
141
+ const withTop = [];
142
+ let inserted = false;
143
+ for (let i = 0; i < resultLines.length; i += 1) {
144
+ if (!inserted && resultLines[i].trim() && !resultLines[i].trim().startsWith('#')) {
145
+ withTop.push(...topInsert, '');
146
+ inserted = true;
147
+ }
148
+ withTop.push(resultLines[i]);
149
+ }
150
+ if (!inserted) {
151
+ resultLines = [...topInsert, '', ...resultLines];
152
+ } else {
153
+ resultLines = withTop;
154
+ }
155
+ }
156
+
157
+ if (!sawProviderSection) {
158
+ if (resultLines.length && resultLines[resultLines.length - 1].trim() !== '') {
159
+ resultLines.push('');
160
+ }
161
+ resultLines.push(`[model_providers.${provider}]`);
162
+ resultLines.push('name = "ATools"');
163
+ resultLines.push(`base_url = "${baseUrl}"`);
164
+ resultLines.push('wire_api = "responses"');
165
+ resultLines.push('requires_openai_auth = false');
166
+ resultLines.push(`env_key = "${ENV_KEY}"`);
167
+ resultLines.push('');
168
+ }
169
+
170
+ return resultLines.join('\n');
171
+ }
172
+
173
+ function upsertExportLine(content, key, value) {
174
+ const line = `export ${key}=${value}`;
175
+ const re = new RegExp(`^\s*export\s+${key}=.*$`, 'm');
176
+ if (re.test(content)) {
177
+ return content.replace(re, line);
178
+ }
179
+ if (!content || !content.trim()) {
180
+ return `${line}\n`;
181
+ }
182
+ return `${content.replace(/\s*$/, '')}\n${line}\n`;
183
+ }
184
+
185
+ function detectPosixProfile() {
186
+ const shell = process.env.SHELL || '';
187
+ const home = os.homedir();
188
+ if (shell.includes('zsh')) return path.join(home, '.zshrc');
189
+ if (shell.includes('bash')) return path.join(home, '.bashrc');
190
+ return path.join(home, '.bashrc');
191
+ }
192
+
193
+ function persistEnvVar(key, value) {
194
+ if (!value) {
195
+ return { changed: false, reason: 'empty', type: '' };
196
+ }
197
+
198
+ if (process.platform === 'win32') {
199
+ try {
200
+ const out = spawnSync('setx', [key, value], { encoding: 'utf8' });
201
+ if (out.status !== 0) {
202
+ return {
203
+ changed: false,
204
+ type: 'windows',
205
+ reason: String(out.stderr || out.stdout || 'setx failed')
206
+ };
207
+ }
208
+ return { changed: true, type: 'windows', reason: '' };
209
+ } catch (err) {
210
+ return { changed: false, type: 'windows', reason: String(err) };
211
+ }
212
+ }
213
+
214
+ const profilePath = detectPosixProfile();
215
+ let content = '';
216
+ if (fs.existsSync(profilePath)) {
217
+ content = fs.readFileSync(profilePath, 'utf8');
218
+ }
219
+ const next = upsertExportLine(content, key, value);
220
+ if (next === content) {
221
+ return { changed: false, type: 'posix', path: profilePath };
222
+ }
223
+
224
+ if (content) {
225
+ backup(profilePath);
226
+ }
227
+ ensureDir(path.dirname(profilePath));
228
+ fs.writeFileSync(profilePath, next, 'utf8');
229
+ return { changed: true, type: 'posix', path: profilePath };
230
+ }
231
+
232
+ function canUseArrowMenu() {
233
+ return Boolean(
234
+ process.stdin.isTTY
235
+ && process.stdout.isTTY
236
+ && typeof process.stdin.setRawMode === 'function'
237
+ );
238
+ }
239
+
240
+ function askChoiceArrow(question, options, defaultIndex = 0) {
241
+ return new Promise((resolve, reject) => {
242
+ let index = defaultIndex;
243
+
244
+ const draw = () => {
245
+ readline.clearLine(process.stdout, 0);
246
+ readline.cursorTo(process.stdout, 0);
247
+ const label = `${index + 1}. ${options[index]}`;
248
+ const line = withColor(`> ${label}`, ANSI.cyan, ANSI.bold);
249
+ process.stdout.write(`${line} (↑/↓, Enter; 数字直接跳转)`);
250
+ };
251
+
252
+ const cleanup = () => {
253
+ process.stdin.off('keypress', onKeyPress);
254
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === 'function') {
255
+ process.stdin.setRawMode(false);
256
+ }
257
+ };
258
+
259
+ const onKeyPress = (str, key = {}) => {
260
+ if (key.ctrl && key.name === 'c') {
261
+ cleanup();
262
+ reject(new Error('aborted by user'));
263
+ return;
264
+ }
265
+ if (key.name === 'up') {
266
+ index = (index - 1 + options.length) % options.length;
267
+ draw();
268
+ return;
269
+ }
270
+ if (key.name === 'down') {
271
+ index = (index + 1) % options.length;
272
+ draw();
273
+ return;
274
+ }
275
+ if (key.name === 'return' || key.name === 'enter') {
276
+ cleanup();
277
+ readline.clearLine(process.stdout, 0);
278
+ readline.cursorTo(process.stdout, 0);
279
+ process.stdout.write(`已选择: ${options[index]}\n`);
280
+ resolve(options[index]);
281
+ return;
282
+ }
283
+ const n = Number(str);
284
+ if (Number.isInteger(n) && n >= 1 && n <= options.length) {
285
+ index = n - 1;
286
+ draw();
287
+ }
288
+ };
289
+
290
+ try {
291
+ process.stdout.write(`\n${question}\n`);
292
+ readline.emitKeypressEvents(process.stdin);
293
+ process.stdin.setRawMode(true);
294
+ process.stdin.resume();
295
+ process.stdin.on('keypress', onKeyPress);
296
+ draw();
297
+ } catch (err) {
298
+ cleanup();
299
+ reject(err);
300
+ }
301
+ });
302
+ }
303
+
304
+ async function askModel(rl, currentModel = DEFAULT_MODEL_ID) {
305
+ const defaultIndex = Math.max(0, MODEL_OPTIONS.indexOf(currentModel));
306
+ if (canUseArrowMenu()) {
307
+ return askChoiceArrow('1) 选择默认模型', MODEL_OPTIONS, defaultIndex);
308
+ }
309
+
310
+ while (true) {
311
+ process.stdout.write('\n1) 选择默认模型\n');
312
+ MODEL_OPTIONS.forEach((m, idx) => {
313
+ const mark = idx === defaultIndex ? ' (default)' : '';
314
+ process.stdout.write(` ${idx + 1}. ${m}${mark}\n`);
315
+ });
316
+ const input = (await rl.question('请选择编号: ')).trim();
317
+ if (!input) return MODEL_OPTIONS[defaultIndex];
318
+ const n = Number(input);
319
+ if (Number.isInteger(n) && n >= 1 && n <= MODEL_OPTIONS.length) {
320
+ return MODEL_OPTIONS[n - 1];
321
+ }
322
+ process.stdout.write('输入无效,请重新输入。\n');
323
+ }
324
+ }
325
+
326
+ async function askKey(rl, currentKey) {
327
+ const masked = currentKey ? `${currentKey.slice(0, 6)}...${currentKey.slice(-4)}` : '未设置';
328
+ process.stdout.write(`\n2) 输入 ATools API Key(当前: ${masked})\n`);
329
+ process.stdout.write('留空表示保持当前 key\n');
330
+ const input = (await rl.question('请输入 key: ')).trim();
331
+ return input || currentKey || '';
332
+ }
333
+
334
+ async function askKeyWithGuard(rl, currentKey) {
335
+ let selectedKey = await askKey(rl, currentKey);
336
+ while (/^sk-or-v1-/i.test(selectedKey)) {
337
+ process.stdout.write(`\n${warn('检测到你输入的是 OpenRouter key(sk-or-v1- 开头),这不适用于 ATools /v1 模式。')}\n`);
338
+ const choice = (await rl.question('是否重新输入 ATools key? [Y/n]: ')).trim().toLowerCase();
339
+ if (choice === 'n') break;
340
+ selectedKey = await askKey(rl, selectedKey);
341
+ }
342
+ return selectedKey;
343
+ }
344
+
345
+ export async function runConfigCodex({ codexHome } = {}) {
346
+ const home = codexHome || process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
347
+ const configPath = path.join(home, 'config.toml');
348
+
349
+ const existing = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : '';
350
+
351
+ process.stdout.write(`${sectionTitle('Codex 配置')}:\n`);
352
+ process.stdout.write(`- Home: ${bold(home)}\n`);
353
+ process.stdout.write(`- Provider: ${bold(PROVIDER)}\n`);
354
+ process.stdout.write(`- Upstream Base URL: ${bold(UPSTREAM_BASE_URL)}\n`);
355
+ process.stdout.write(`- Env Var: ${bold(ENV_KEY)}\n`);
356
+
357
+ const initialKey = process.env[ENV_KEY] || process.env.SUB_OAI_KEY || '';
358
+
359
+ let selectedModel = DEFAULT_MODEL_ID;
360
+ let selectedKey = initialKey;
361
+
362
+ const rl = readlinePromises.createInterface({
363
+ input: process.stdin,
364
+ output: process.stdout
365
+ });
366
+
367
+ try {
368
+ selectedModel = await askModel(rl, DEFAULT_MODEL_ID);
369
+ selectedKey = await askKeyWithGuard(rl, initialKey);
370
+ } finally {
371
+ rl.close();
372
+ }
373
+
374
+ const next = patchCodexConfigText(existing, { modelId: selectedModel });
375
+
376
+ ensureDir(path.dirname(configPath));
377
+ if (existing && next !== existing) {
378
+ const bak = backup(configPath);
379
+ if (bak) {
380
+ process.stdout.write(`${ok(`[atools-tool] backup created: ${bak}`)}\n`);
381
+ }
382
+ }
383
+
384
+ if (next !== existing) {
385
+ fs.writeFileSync(configPath, `${next.trimEnd()}\n`, 'utf8');
386
+ }
387
+
388
+ let envResult = { changed: false, type: '', reason: '' };
389
+ if (selectedKey) {
390
+ envResult = persistEnvVar(ENV_KEY, selectedKey);
391
+ }
392
+
393
+ process.stdout.write(`\n${sectionTitle('[atools-tool] Codex 配置结果')}:\n`);
394
+ process.stdout.write(`- Config: ${bold(configPath)}\n`);
395
+ process.stdout.write(`- model_provider = "${PROVIDER}"\n`);
396
+ process.stdout.write(`- model = "${selectedModel}"\n`);
397
+ process.stdout.write(`- base_url = "${UPSTREAM_BASE_URL}"\n`);
398
+
399
+ process.stdout.write(`\n${sectionTitle('Environment (持久化)')}:\n`);
400
+ if (!selectedKey) {
401
+ process.stdout.write(`${warn('- ATools key 未提供,未更新环境变量。')}\n`);
402
+ } else if (envResult.type === 'windows') {
403
+ if (envResult.changed) {
404
+ process.stdout.write(`${ok(`- Persisted ${ENV_KEY} to Windows user env (setx).`)}\n`);
405
+ process.stdout.write('- 请重新打开终端,使新的环境变量生效。\n');
406
+ } else {
407
+ process.stdout.write(`${warn(`- Failed to persist ${ENV_KEY} via setx: ${envResult.reason}`)}\n`);
408
+ }
409
+ } else if (envResult.type === 'posix') {
410
+ if (envResult.changed) {
411
+ process.stdout.write(`${ok(`- Persisted ${ENV_KEY} to shell profile: ${envResult.path}`)}\n`);
412
+ process.stdout.write('- 请重新打开终端,或手动 source 该文件以生效。\n');
413
+ } else {
414
+ process.stdout.write(`- ${ENV_KEY} 已在 profile 中存在(${envResult.path}),未做修改。\n`);
415
+ }
416
+ } else {
417
+ process.stdout.write(`- Env var not persisted (reason: ${envResult.reason || 'unknown'}).\n`);
418
+ }
419
+
420
+ if (selectedKey) {
421
+ process.stdout.write(`\n${sectionTitle('Environment (当前会话手动生效命令)')}:\n`);
422
+ if (process.platform === 'win32') {
423
+ process.stdout.write(`- CMD: ${bold(`set ${ENV_KEY}=${selectedKey}`)}\n`);
424
+ process.stdout.write(`- PowerShell: ${bold(`$env:${ENV_KEY}="${selectedKey}"`)}\n`);
425
+ } else {
426
+ process.stdout.write(`- bash/zsh: ${bold(`export ${ENV_KEY}=${selectedKey}`)}\n`);
427
+ }
428
+ }
429
+ }
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
8
8
  import readlinePromises from 'node:readline/promises';
9
9
  import readline from 'node:readline';
10
10
 
11
- const PROVIDER = 'sub2api';
11
+ const PROVIDER = 'atools';
12
12
  const MODEL_OPTIONS = ['gpt-5.2', 'gpt-5.3-codex', 'gpt-5.4'];
13
13
  const THINKING_OPTIONS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh'];
14
14
 
@@ -19,6 +19,38 @@ const PROXY_UPSTREAM = 'https://sub2api.atools.live';
19
19
  const PROXY_LOG_FILE = path.join(os.tmpdir(), 'openclaw', 'atools-compat-proxy.log');
20
20
  const PROXY_SERVICE = 'openclaw-atools-proxy.service';
21
21
 
22
+ const USE_COLOR = Boolean(process.stdout.isTTY && process.env.NO_COLOR !== '1');
23
+ const ANSI = {
24
+ reset: '\x1b[0m',
25
+ bold: '\x1b[1m',
26
+ cyan: '\x1b[36m',
27
+ green: '\x1b[32m',
28
+ yellow: '\x1b[33m'
29
+ };
30
+
31
+ function withColor(text, ...codes) {
32
+ if (!USE_COLOR || !codes.length) return text;
33
+ const prefix = codes.join("");
34
+ return prefix + text + ANSI.reset;
35
+ }
36
+
37
+ function sectionTitle(text) {
38
+ return withColor(text, ANSI.cyan, ANSI.bold);
39
+ }
40
+
41
+ function ok(text) {
42
+ return withColor(text, ANSI.green);
43
+ }
44
+
45
+ function warn(text) {
46
+ return withColor(text, ANSI.yellow);
47
+ }
48
+
49
+ function bold(text) {
50
+ return withColor(text, ANSI.bold);
51
+ }
52
+
53
+
22
54
  function readJson(filePath) {
23
55
  if (!fs.existsSync(filePath)) {
24
56
  throw new Error(`missing file: ${filePath}`);
@@ -71,59 +103,64 @@ async function askChoiceNumber(rl, question, options, defaultIndex = 0) {
71
103
  async function askChoiceArrow(question, options, defaultIndex = 0) {
72
104
  return new Promise((resolve, reject) => {
73
105
  let index = defaultIndex;
74
- process.stdout.write(`\n${question}\n`);
75
-
106
+ process.stdout.write("\n" + question + "\n");
107
+ let firstDraw = true;
76
108
  const draw = () => {
77
- readline.clearLine(process.stdout, 0);
78
- readline.cursorTo(process.stdout, 0);
79
- process.stdout.write(`> ${options[index]} (↑/↓, Enter)`);
109
+ if (!firstDraw) {
110
+ readline.moveCursor(process.stdout, 0, -options.length);
111
+ } else {
112
+ firstDraw = false;
113
+ }
114
+ for (let i = 0; i < options.length; i += 1) {
115
+ readline.clearLine(process.stdout, 0);
116
+ const prefix = i === index ? '> ' : ' ';
117
+ const label = String(i + 1) + ". " + String(options[i]);
118
+ let line = prefix + label;
119
+ if (i === index) {
120
+ line = withColor(line, ANSI.cyan, ANSI.bold) + ' (↑/↓, Enter; 数字直接跳转)';
121
+ }
122
+ process.stdout.write(line + "\n");
123
+ }
80
124
  };
81
-
82
125
  const cleanup = () => {
83
- process.stdin.off('keypress', onKeyPress);
84
- if (process.stdin.isTTY && typeof process.stdin.setRawMode === 'function') {
126
+ process.stdin.off("keypress", onKeyPress);
127
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
85
128
  process.stdin.setRawMode(false);
86
129
  }
87
130
  };
88
-
89
131
  const onKeyPress = (str, key = {}) => {
90
- if (key.ctrl && key.name === 'c') {
132
+ if (key.ctrl && key.name === "c") {
91
133
  cleanup();
92
- reject(new Error('aborted by user'));
134
+ reject(new Error("aborted by user"));
93
135
  return;
94
136
  }
95
-
96
- if (key.name === 'up') {
137
+ if (key.name === "up") {
97
138
  index = (index - 1 + options.length) % options.length;
98
139
  draw();
99
140
  return;
100
141
  }
101
- if (key.name === 'down') {
142
+ if (key.name === "down") {
102
143
  index = (index + 1) % options.length;
103
144
  draw();
104
145
  return;
105
146
  }
106
- if (key.name === 'return' || key.name === 'enter') {
147
+ if (key.name === "return" || key.name === "enter") {
107
148
  cleanup();
108
- readline.clearLine(process.stdout, 0);
109
- readline.cursorTo(process.stdout, 0);
110
- process.stdout.write(`已选择: ${options[index]}\n`);
149
+ process.stdout.write("已选择: " + String(options[index]) + "\n");
111
150
  resolve(options[index]);
112
151
  return;
113
152
  }
114
-
115
153
  const n = Number(str);
116
154
  if (Number.isInteger(n) && n >= 1 && n <= options.length) {
117
155
  index = n - 1;
118
156
  draw();
119
157
  }
120
158
  };
121
-
122
159
  try {
123
160
  readline.emitKeypressEvents(process.stdin);
124
161
  process.stdin.setRawMode(true);
125
162
  process.stdin.resume();
126
- process.stdin.on('keypress', onKeyPress);
163
+ process.stdin.on("keypress", onKeyPress);
127
164
  draw();
128
165
  } catch (err) {
129
166
  cleanup();
@@ -207,7 +244,7 @@ async function askKey(rl, currentKey) {
207
244
  async function askKeyWithGuard(rl, currentKey) {
208
245
  let selectedKey = await askKey(rl, currentKey);
209
246
  while (/^sk-or-v1-/i.test(selectedKey)) {
210
- process.stdout.write('\n检测到你输入的是 OpenRouter key(sk-or-v1- 开头),这不适用于 sub2api /v1 代理模式。\n');
247
+ process.stdout.write('\n' + warn('检测到你输入的是 OpenRouter key(sk-or-v1- 开头),这不适用于 ATools /v1 模式。') + '\n');
211
248
  const choice = await askChoice(rl, '是否重新输入 key?', ['重新输入 key(推荐)', '继续使用当前 key'], 0);
212
249
  if (choice === '继续使用当前 key') break;
213
250
  selectedKey = await askKey(rl, selectedKey);
@@ -296,9 +333,22 @@ function isLinuxProxyServiceActive() {
296
333
 
297
334
  function startLinuxProxyService() {
298
335
  const reload = runSystemctl(['daemon-reload']);
299
- if (reload.status !== 0) return false;
336
+ if (reload.status !== 0) {
337
+ return {
338
+ ok: false,
339
+ step: 'daemon-reload',
340
+ detail: String(reload.stderr || reload.stdout || '').trim()
341
+ };
342
+ }
300
343
  const start = runSystemctl(['enable', '--now', PROXY_SERVICE]);
301
- return start.status === 0;
344
+ if (start.status !== 0) {
345
+ return {
346
+ ok: false,
347
+ step: 'enable-start',
348
+ detail: String(start.stderr || start.stdout || '').trim()
349
+ };
350
+ }
351
+ return { ok: true, step: 'enable-start', detail: '' };
302
352
  }
303
353
 
304
354
  function startDetachedProxy() {
@@ -350,6 +400,31 @@ async function isPortOpen(host, port, timeoutMs = 900) {
350
400
  });
351
401
  }
352
402
 
403
+ function buildProxyTroubleshootingHints({ linuxSystemdError } = {}) {
404
+ const hints = [];
405
+ const err = String(linuxSystemdError || '');
406
+
407
+ if (/Failed to connect to bus|No medium found/i.test(err)) {
408
+ hints.push('当前环境没有可用的 systemd 用户会话(常见于容器、root、无登录会话)。');
409
+ hints.push('建议:直接重新执行 `atools-tool install-proxy`(或兼容别名 `atools-tool-install`),新版会自动降级到非-systemd进程模式。');
410
+ hints.push('如果是容器环境,请确保进程管理策略允许后台常驻进程。');
411
+ return hints;
412
+ }
413
+
414
+ if (/permission denied|access denied/i.test(err)) {
415
+ hints.push('权限不足,无法通过 systemd --user 启动服务。');
416
+ hints.push('建议:使用普通登录用户执行安装与配置,不要混用 root 与普通用户的 OpenClaw 目录。');
417
+ return hints;
418
+ }
419
+
420
+ if (err) {
421
+ hints.push(`systemd 启动失败:${err}`);
422
+ }
423
+ hints.push('建议:检查 OpenClaw Home 路径与 Node 可执行路径是否存在且可访问。');
424
+ hints.push(`建议:确认端口 ${PROXY_PORT} 未被防火墙或策略阻断。`);
425
+ return hints;
426
+ }
427
+
353
428
  async function ensureProxyReady() {
354
429
  if (process.platform === 'linux') {
355
430
  const service = configureLinuxProxyService();
@@ -360,13 +435,13 @@ async function ensureProxyReady() {
360
435
  }
361
436
  }
362
437
 
363
- if (await isPortOpen(PROXY_HOST, PROXY_PORT)) return true;
438
+ if (await isPortOpen(PROXY_HOST, PROXY_PORT)) return { ok: true, mode: 'already-running' };
364
439
 
365
440
  // Linux retry: service may exist but not running.
366
441
  if (process.platform === 'linux') {
367
442
  const service = configureLinuxProxyService();
368
443
  if (service.exists && isLinuxProxyServiceActive()) {
369
- if (await isPortOpen(PROXY_HOST, PROXY_PORT)) return true;
444
+ if (await isPortOpen(PROXY_HOST, PROXY_PORT)) return { ok: true, mode: 'already-running' };
370
445
  }
371
446
  }
372
447
 
@@ -388,26 +463,45 @@ async function ensureProxyReady() {
388
463
  }
389
464
 
390
465
  if (action === '取消并退出') {
391
- return false;
466
+ return { ok: false, reason: 'user-cancelled', hints: [] };
392
467
  }
393
468
 
394
469
  let started = false;
470
+ let linuxSystemdError = '';
395
471
  if (process.platform === 'linux') {
396
- started = startLinuxProxyService();
472
+ const systemdStart = startLinuxProxyService();
473
+ started = systemdStart.ok;
474
+ linuxSystemdError = systemdStart.detail || '';
397
475
  }
476
+ let mode = 'systemd';
398
477
  if (!started) {
399
478
  started = startDetachedProxy();
479
+ mode = 'detached';
400
480
  }
401
481
 
402
- if (!started) return false;
482
+ if (!started) {
483
+ return {
484
+ ok: false,
485
+ reason: 'start-failed',
486
+ hints: buildProxyTroubleshootingHints({ linuxSystemdError })
487
+ };
488
+ }
403
489
 
404
490
  for (let i = 0; i < 5; i += 1) {
405
491
  await sleep(500);
406
492
  if (await isPortOpen(PROXY_HOST, PROXY_PORT)) {
407
- return true;
493
+ return { ok: true, mode };
408
494
  }
409
495
  }
410
- return false;
496
+ return {
497
+ ok: false,
498
+ reason: 'port-not-open',
499
+ hints: [
500
+ '代理进程启动后端口仍未就绪。',
501
+ `请检查日志:${PROXY_LOG_FILE}`,
502
+ '如为受限环境(容器/最小系统),请确认允许本地监听 127.0.0.1。'
503
+ ]
504
+ };
411
505
  }
412
506
 
413
507
  function printRestartHint() {
@@ -438,15 +532,23 @@ export async function runConfigOpenclaw({ openclawHome } = {}) {
438
532
  let rl = null;
439
533
 
440
534
  try {
441
- process.stdout.write(`OpenClaw Home: ${home}\n`);
442
- process.stdout.write(`固定代理目标: ${PROXY_UPSTREAM}\n`);
443
- process.stdout.write(`固定 Provider Base URL: ${PROXY_BASE_URL}\n`);
444
-
445
- const proxyReady = await ensureProxyReady();
446
- if (!proxyReady) {
447
- process.stdout.write('\n已取消:本地代理未就绪,未修改 OpenClaw 模型配置。\n');
535
+ process.stdout.write(sectionTitle('OpenClaw / ATools 配置') + ':\n');
536
+ process.stdout.write('- OpenClaw Home: ' + bold(home) + '\n');
537
+ process.stdout.write('- 固定代理目标: ' + bold(PROXY_UPSTREAM) + '\n');
538
+ process.stdout.write('- 固定 Provider Base URL: ' + bold(PROXY_BASE_URL) + '\n');
539
+
540
+ const proxyStatus = await ensureProxyReady();
541
+ if (!proxyStatus.ok) {
542
+ process.stdout.write('\n本地代理未就绪,未修改 OpenClaw 模型配置。\n');
543
+ if (Array.isArray(proxyStatus.hints) && proxyStatus.hints.length > 0) {
544
+ process.stdout.write('可行解决方案:\n');
545
+ proxyStatus.hints.forEach((hint) => process.stdout.write(`- ${hint}\n`));
546
+ }
448
547
  return;
449
548
  }
549
+ if (proxyStatus.mode === 'detached') {
550
+ process.stdout.write('\n已通过非-systemd模式启动代理(兼容无 user bus 环境)。\n');
551
+ }
450
552
  rl = readlinePromises.createInterface({
451
553
  input: process.stdin,
452
554
  output: process.stdout
@@ -457,10 +559,13 @@ export async function runConfigOpenclaw({ openclawHome } = {}) {
457
559
  const selectedThinking = await askChoice(rl, '3) 思考强度', THINKING_OPTIONS, 2);
458
560
  const reasoning = reasoningSwitch === 'on';
459
561
  const currentKey =
460
- openclawCfg?.models?.providers?.[PROVIDER]?.apiKey ||
461
- modelsCfg?.providers?.[PROVIDER]?.apiKey ||
462
- authCfg?.profiles?.[`${PROVIDER}:default`]?.key ||
463
- '';
562
+ openclawCfg?.models?.providers?.[PROVIDER]?.apiKey
563
+ || openclawCfg?.models?.providers?.sub2api?.apiKey
564
+ || modelsCfg?.providers?.[PROVIDER]?.apiKey
565
+ || modelsCfg?.providers?.sub2api?.apiKey
566
+ || authCfg?.profiles?.[`${PROVIDER}:default`]?.key
567
+ || authCfg?.profiles?.['sub2api:default']?.key
568
+ || '';
464
569
  const selectedKey = await askKeyWithGuard(rl, currentKey);
465
570
 
466
571
  openclawCfg.models = openclawCfg.models || {};
@@ -489,8 +594,10 @@ export async function runConfigOpenclaw({ openclawHome } = {}) {
489
594
  if (Array.isArray(openclawCfg.agents.list)) {
490
595
  openclawCfg.agents.list = openclawCfg.agents.list.map((agent) => {
491
596
  if (!agent || typeof agent !== 'object') return agent;
492
- if (typeof agent.model === 'string' && agent.model.startsWith(`${PROVIDER}/`)) {
493
- return { ...agent, model: `${PROVIDER}/${selectedModel}` };
597
+ if (typeof agent.model === 'string') {
598
+ if (agent.model.startsWith(`${PROVIDER}/`) || agent.model.startsWith('sub2api/')) {
599
+ return { ...agent, model: `${PROVIDER}/${selectedModel}` };
600
+ }
494
601
  }
495
602
  return agent;
496
603
  });
package/lib/install.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
- import { execFileSync } from 'node:child_process';
4
+ import { execFileSync, spawn } from 'node:child_process';
5
5
  import { fileURLToPath } from 'node:url';
6
6
 
7
- const DEFAULT_PROVIDER = 'sub2api';
7
+ const DEFAULT_PROVIDER = 'atools';
8
8
  const DEFAULT_MODEL = 'gpt-5.3-codex';
9
9
  const DEFAULT_FORCE_MODEL = '';
10
10
  const DEFAULT_PORT = 18888;
@@ -95,7 +95,7 @@ function parseArgs(argv) {
95
95
  maxReqBytes: 68000,
96
96
  logFile: DEFAULT_LOG_FILE,
97
97
  serviceName: DEFAULT_SERVICE_NAME,
98
- apiKey: process.env.SUB_OAI_KEY || '',
98
+ apiKey: process.env.ATOOLS_OAI_KEY || process.env.SUB_OAI_KEY || '',
99
99
  dryRun: false,
100
100
  noRestart: false
101
101
  };
@@ -193,7 +193,7 @@ function detectApiKey(openclawHome, explicitKey) {
193
193
  if (explicitKey) return explicitKey;
194
194
  const authPath = path.join(openclawHome, 'agents/main/agent/auth-profiles.json');
195
195
  const auth = readJsonIfExists(authPath);
196
- return auth?.profiles?.['sub2api:default']?.key || '';
196
+ return (auth?.profiles?.['atools:default']?.key || auth?.profiles?.['sub2api:default']?.key || '');
197
197
  }
198
198
 
199
199
  function patchOpenclawConfig(openclawHome, provider, model, port, dryRun) {
@@ -350,7 +350,56 @@ function runSystemctl(args, { dryRun } = {}) {
350
350
  execFileSync('systemctl', ['--user', ...args], { stdio: 'inherit' });
351
351
  }
352
352
 
353
- function installLinuxService({ serviceName, content, dryRun }) {
353
+ function printSystemdFallbackHints(detail) {
354
+ const text = String(detail || '');
355
+ if (/Failed to connect to bus|No medium found/i.test(text)) {
356
+ info('detected: no systemd user bus in current environment');
357
+ info('hint: this is common in containers/root/non-login sessions');
358
+ info('hint: installer will continue with detached process mode');
359
+ return;
360
+ }
361
+ if (/permission denied|access denied/i.test(text)) {
362
+ info('detected: permission issue for systemd --user');
363
+ info('hint: run install under the same normal user as OpenClaw');
364
+ info('hint: installer will continue with detached process mode');
365
+ return;
366
+ }
367
+ info('detected: systemd --user is not usable in current environment');
368
+ info('hint: installer will continue with detached process mode');
369
+ }
370
+
371
+ function startDetachedProxyProcess({ nodeBin, cliPath, serveArgs, dryRun }) {
372
+ if (dryRun) {
373
+ info(`dry-run: detached start ${[nodeBin, cliPath, ...serveArgs].join(' ')}`);
374
+ return true;
375
+ }
376
+ try {
377
+ const child = spawn(nodeBin, [cliPath, ...serveArgs], {
378
+ detached: true,
379
+ stdio: 'ignore',
380
+ cwd: os.homedir(),
381
+ env: {
382
+ ...process.env,
383
+ HOME: process.env.HOME || os.homedir(),
384
+ TMPDIR: process.env.TMPDIR || '/tmp',
385
+ SUB2API_COMPAT_USER_AGENT: 'codex_cli_rs/0.101.0 (Ubuntu 24.4.0; x86_64) WindowsTerminal'
386
+ }
387
+ });
388
+ child.unref();
389
+ return true;
390
+ } catch {
391
+ return false;
392
+ }
393
+ }
394
+
395
+ function installLinuxService({
396
+ serviceName,
397
+ content,
398
+ nodeBin,
399
+ cliPath,
400
+ serveArgs,
401
+ dryRun
402
+ }) {
354
403
  const serviceDir = path.join(os.homedir(), '.config/systemd/user');
355
404
  const servicePath = path.join(serviceDir, serviceName);
356
405
  ensureDir(serviceDir);
@@ -366,8 +415,22 @@ function installLinuxService({ serviceName, content, dryRun }) {
366
415
  info(`wrote: ${servicePath}`);
367
416
  }
368
417
 
369
- runSystemctl(['daemon-reload'], { dryRun });
370
- runSystemctl(['enable', '--now', serviceName], { dryRun });
418
+ try {
419
+ runSystemctl(['daemon-reload'], { dryRun });
420
+ runSystemctl(['enable', '--now', serviceName], { dryRun });
421
+ info(`service started with systemd --user: ${serviceName}`);
422
+ return;
423
+ } catch (err) {
424
+ const detail = String(err?.message || err);
425
+ info(`systemd --user unavailable, fallback to detached process (${detail})`);
426
+ printSystemdFallbackHints(detail);
427
+ }
428
+
429
+ const started = startDetachedProxyProcess({ nodeBin, cliPath, serveArgs, dryRun });
430
+ if (!started) {
431
+ throw new Error(`failed to start proxy with both systemd --user and detached mode`);
432
+ }
433
+ info('proxy started in detached mode (non-systemd fallback)');
371
434
  }
372
435
 
373
436
  function runLaunchctl(args, { dryRun, allowFailure = false } = {}) {
@@ -444,7 +507,14 @@ function installService({
444
507
  dryRun
445
508
  }) {
446
509
  if (process.platform === 'linux') {
447
- installLinuxService({ serviceName, content: linuxServiceContent, dryRun });
510
+ installLinuxService({
511
+ serviceName,
512
+ content: linuxServiceContent,
513
+ nodeBin,
514
+ cliPath,
515
+ serveArgs,
516
+ dryRun
517
+ });
448
518
  return;
449
519
  }
450
520
  if (process.platform === 'darwin') {
@@ -477,14 +547,14 @@ function restartOpenclawGateway({ noRestart, dryRun }) {
477
547
  }
478
548
 
479
549
  function printHelp() {
480
- process.stdout.write(`atools-tool-install
550
+ process.stdout.write(`atools-tool install-proxy
481
551
 
482
552
  Usage:
483
- atools-tool-install [options]
553
+ atools-tool install-proxy [options]
484
554
 
485
555
  Options:
486
556
  --openclaw-home <path> Default: ~/.openclaw
487
- --provider <name> Default: sub2api
557
+ --provider <name> Default: atools
488
558
  --model <id> Default: gpt-5.3-codex
489
559
  --force-model <id> Default: disabled (optional hard rewrite)
490
560
  --port <number> Default: 18888
@@ -492,7 +562,7 @@ Options:
492
562
  --max-req-bytes <number> Default: 68000
493
563
  --log-file <path> Default: <tmp>/openclaw/atools-compat-proxy.log
494
564
  --service-name <name> Linux: *.service; macOS/Windows: service id/task name
495
- --api-key <key> Prefer explicit key; otherwise use SUB_OAI_KEY or auth-profiles
565
+ --api-key <key> Prefer explicit key; otherwise use ATOOLS_OAI_KEY (or legacy SUB_OAI_KEY) or auth-profiles
496
566
  --no-restart Do not restart openclaw-gateway.service
497
567
  --dry-run Print actions without changing files
498
568
  -h, --help Show help
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autotask/atools-tool",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "ATools CLI for OpenClaw proxy compatibility and interactive model configuration",
5
5
  "type": "module",
6
6
  "license": "MIT",