tokenmix 1.5.1 → 1.5.3

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.
@@ -2,6 +2,7 @@ import os from 'os';
2
2
  import path from 'path';
3
3
  import fs from 'fs-extra';
4
4
  import { run } from '../utils/exec.js';
5
+ import { writeFileAtomic } from '../utils/fs.js';
5
6
  import { npmInstallCheck, npmInstallGlobal } from './helpers.js';
6
7
  import { t } from '../i18n/index.js';
7
8
  const CLAUDE_BIN = 'claude';
@@ -16,11 +17,16 @@ async function configure(apiKey, baseUrl, _defaultModel) {
16
17
  const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
17
18
  let existing = {};
18
19
  try {
19
- const raw = await fs.readFile(settingsPath, 'utf-8');
20
- existing = JSON.parse(raw);
20
+ const parsed = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
21
+ // Valid JSON that isn't a plain object (null, array, string) must be treated as
22
+ // "start fresh" — otherwise `existing.env` below throws (JSON.parse("null") →
23
+ // null → null.env → TypeError, which would block `tokenmix claude` entirely).
24
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
25
+ existing = parsed;
26
+ }
21
27
  }
22
28
  catch {
23
- // first run
29
+ // first run, or unreadable/corrupt — start fresh
24
30
  }
25
31
  const existingEnv = existing.env || {};
26
32
  // Detect that we're about to overwrite a user's OWN (non-tokenmix) Anthropic
@@ -42,11 +48,17 @@ async function configure(apiKey, baseUrl, _defaultModel) {
42
48
  // Stash the user's original Anthropic env creds on the FIRST overwrite, so
43
49
  // cleanup() can put them back instead of leaving Claude Code broken. Once the
44
50
  // stored key is ours, replacingForeign is false — so we never clobber the backup.
45
- const prevTm = existing.tokenmix;
46
- const alreadyBackedUp = !!(prevTm && typeof prevTm === 'object' && 'claudeEnvBackup' in prevTm);
51
+ // Guard: `existing.tokenmix` could be any JSON (a user or another tool might set
52
+ // it to a string/array). Only spread it when it's a real object otherwise a
53
+ // string would explode into numeric-index keys and pollute settings.json.
54
+ const prevTmRaw = existing.tokenmix;
55
+ const prevTm = prevTmRaw && typeof prevTmRaw === 'object' && !Array.isArray(prevTmRaw)
56
+ ? prevTmRaw
57
+ : {};
58
+ const alreadyBackedUp = 'claudeEnvBackup' in prevTm;
47
59
  if (replacingForeign && !alreadyBackedUp) {
48
60
  next.tokenmix = {
49
- ...(prevTm ?? {}),
61
+ ...prevTm,
50
62
  claudeEnvBackup: {
51
63
  ANTHROPIC_API_KEY: prevKey ?? null,
52
64
  ANTHROPIC_BASE_URL: prevBase ?? null,
@@ -54,13 +66,7 @@ async function configure(apiKey, baseUrl, _defaultModel) {
54
66
  };
55
67
  }
56
68
  await fs.ensureDir(path.dirname(settingsPath));
57
- await fs.writeFile(settingsPath, JSON.stringify(next, null, 2));
58
- try {
59
- await fs.chmod(settingsPath, 0o600);
60
- }
61
- catch {
62
- // ignore
63
- }
69
+ await writeFileAtomic(settingsPath, JSON.stringify(next, null, 2), 0o600);
64
70
  // Claude Pro/Max users sign in via OAuth (creds live in ~/.claude/.credentials.json
65
71
  // or the OS keychain — NOT in settings.json env). Claude Code prefers
66
72
  // ANTHROPIC_API_KEY over the OAuth subscription, so injecting our key silently
@@ -101,7 +107,11 @@ async function cleanup() {
101
107
  const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
102
108
  let existing;
103
109
  try {
104
- existing = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
110
+ const parsed = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
111
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
112
+ return { reverted: false, configPath: settingsPath };
113
+ }
114
+ existing = parsed;
105
115
  }
106
116
  catch {
107
117
  return { reverted: false };
@@ -148,13 +158,7 @@ async function cleanup() {
148
158
  else
149
159
  existing.tokenmix = tm;
150
160
  }
151
- await fs.writeFile(settingsPath, JSON.stringify(existing, null, 2));
152
- try {
153
- await fs.chmod(settingsPath, 0o600);
154
- }
155
- catch {
156
- // ignore
157
- }
161
+ await writeFileAtomic(settingsPath, JSON.stringify(existing, null, 2), 0o600);
158
162
  return {
159
163
  reverted: true,
160
164
  configPath: settingsPath,
@@ -29,16 +29,22 @@ async function configure(apiKey, baseUrl, defaultModel) {
29
29
  // OpenAI-compatible provider. wire_api MUST be "responses": Codex 0.135+ dropped
30
30
  // support for "chat" (openai/codex#7782), and tokenmix's gateway implements the
31
31
  // Responses API specifically for Codex clients (POST /v1/responses).
32
+ // Escape a value for a TOML double-quoted string (codex `--config key="value"`),
33
+ // so a user-set model/baseUrl containing " \ or a newline can't break the parse
34
+ // or inject extra config keys (e.g. overwrite base_url via an embedded newline).
35
+ function tomlStr(v) {
36
+ return v.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r/g, '\\r').replace(/\n/g, '\\n');
37
+ }
32
38
  export function providerOverrides(baseUrl, model) {
33
39
  return [
34
40
  '--config',
35
41
  `model_provider="${PROVIDER_ID}"`,
36
42
  '--config',
37
- `model="${model}"`,
43
+ `model="${tomlStr(model)}"`,
38
44
  '--config',
39
45
  `model_providers.${PROVIDER_ID}.name="TokenMix"`,
40
46
  '--config',
41
- `model_providers.${PROVIDER_ID}.base_url="${baseUrl}"`,
47
+ `model_providers.${PROVIDER_ID}.base_url="${tomlStr(baseUrl)}"`,
42
48
  '--config',
43
49
  `model_providers.${PROVIDER_ID}.env_key="${KEY_ENV}"`,
44
50
  '--config',
@@ -11,6 +11,9 @@ async function configure(apiKey, baseUrl, defaultModel) {
11
11
  // plus apiBase/apiKey for an OpenAI-compatible endpoint). We PRINT a ready-to-use
12
12
  // config rather than write the file, so we never clobber a user's existing
13
13
  // ~/.continue/config.yaml (and need no YAML dependency or cleanup step).
14
+ // Quote user-controlled scalars as YAML single-quoted strings so a model/baseUrl
15
+ // containing ':' '#' etc. can't break the structure when pasted into config.yaml.
16
+ const ys = (v) => `'${v.replace(/'/g, "''")}'`;
14
17
  const yaml = [
15
18
  'name: TokenMix',
16
19
  'version: 1.0.0',
@@ -18,9 +21,9 @@ async function configure(apiKey, baseUrl, defaultModel) {
18
21
  'models:',
19
22
  ' - name: TokenMix',
20
23
  ' provider: openai',
21
- ` model: ${defaultModel}`,
22
- ` apiBase: ${v1Url(baseUrl)}`,
23
- ` apiKey: ${apiKey}`,
24
+ ` model: ${ys(defaultModel)}`,
25
+ ` apiBase: ${ys(v1Url(baseUrl))}`,
26
+ ` apiKey: ${ys(apiKey)}`,
24
27
  ].join('\n');
25
28
  return {
26
29
  notes: [
@@ -4,6 +4,7 @@ import fs from 'fs-extra';
4
4
  import { run } from '../utils/exec.js';
5
5
  import { npmInstallCheck, npmInstallGlobal } from './helpers.js';
6
6
  import { v1Url } from '../config/store.js';
7
+ import { writeFileAtomic } from '../utils/fs.js';
7
8
  import { t } from '../i18n/index.js';
8
9
  const OPENCODE_BIN = 'opencode';
9
10
  const OPENCODE_NPM_PACKAGE = 'opencode-ai';
@@ -22,11 +23,13 @@ async function configure(apiKey, baseUrl, defaultModel) {
22
23
  const filePath = configPath();
23
24
  let existing = {};
24
25
  try {
25
- const raw = await fs.readFile(filePath, 'utf-8');
26
- existing = JSON.parse(raw);
26
+ const parsed = JSON.parse(await fs.readFile(filePath, 'utf-8'));
27
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
28
+ existing = parsed;
29
+ }
27
30
  }
28
31
  catch {
29
- // not present yet
32
+ // not present yet, or corrupt — start fresh
30
33
  }
31
34
  // Register tokenmix as an OpenAI-compatible provider.
32
35
  // OpenCode uses @ai-sdk/openai-compatible under the hood for custom providers.
@@ -52,7 +55,7 @@ async function configure(apiKey, baseUrl, defaultModel) {
52
55
  },
53
56
  };
54
57
  await fs.ensureDir(path.dirname(filePath));
55
- await fs.writeFile(filePath, JSON.stringify(next, null, 2));
58
+ await writeFileAtomic(filePath, JSON.stringify(next, null, 2));
56
59
  return {
57
60
  configPath: filePath,
58
61
  notes: [t('opencode.noteModel', { model: defaultModel }), t('opencode.noteSwitch')],
@@ -67,7 +70,11 @@ async function cleanup() {
67
70
  const filePath = configPath();
68
71
  let existing;
69
72
  try {
70
- existing = JSON.parse(await fs.readFile(filePath, 'utf-8'));
73
+ const parsed = JSON.parse(await fs.readFile(filePath, 'utf-8'));
74
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
75
+ return { reverted: false, configPath: filePath };
76
+ }
77
+ existing = parsed;
71
78
  }
72
79
  catch {
73
80
  return { reverted: false };
@@ -86,7 +93,7 @@ async function cleanup() {
86
93
  }
87
94
  if (!changed)
88
95
  return { reverted: false, configPath: filePath };
89
- await fs.writeFile(filePath, JSON.stringify(existing, null, 2));
96
+ await writeFileAtomic(filePath, JSON.stringify(existing, null, 2));
90
97
  return { reverted: true, configPath: filePath };
91
98
  }
92
99
  export const OpenCodeAgent = {
@@ -23,7 +23,7 @@ async function configure(apiKey, baseUrl, defaultModel) {
23
23
  return {
24
24
  envVars: {
25
25
  LLM_API_KEY: apiKey,
26
- LLM_MODEL: `openai/${defaultModel}`,
26
+ LLM_MODEL: defaultModel.includes('/') ? defaultModel : `openai/${defaultModel}`,
27
27
  LLM_BASE_URL: v1Url(baseUrl),
28
28
  OPENHANDS_SUPPRESS_BANNER: '1',
29
29
  },
@@ -9,7 +9,10 @@ export class ApiError extends Error {
9
9
  }
10
10
  export function unwrap(resp) {
11
11
  const body = resp.data;
12
- if (body && typeof body.code === 'number' && body.code !== 0) {
12
+ // Normalize `code` with Number() a backend/proxy could serialize it as a string
13
+ // ("1"), which a strict `typeof === 'number'` check would miss, silently swallowing
14
+ // a real error and passing the error body downstream as if it were data.
15
+ if (body && body.code != null && Number(body.code) !== 0) {
13
16
  throw new ApiError(0, body.message || 'API error');
14
17
  }
15
18
  // Standard success envelope → return the inner `data`. A body with no envelope
@@ -31,7 +34,8 @@ function handleAxios(err) {
31
34
  // timeout (override with TOKENMIX_TIMEOUT_MS) and automatic retry of transient
32
35
  // TRANSPORT failures (no HTTP response) with exponential backoff. HTTP errors
33
36
  // (4xx/5xx) are real answers and are NEVER retried.
34
- export const REQUEST_TIMEOUT_MS = Number(process.env.TOKENMIX_TIMEOUT_MS) || 20000;
37
+ const envTimeout = Number(process.env.TOKENMIX_TIMEOUT_MS);
38
+ export const REQUEST_TIMEOUT_MS = Number.isFinite(envTimeout) && envTimeout > 0 ? envTimeout : 20000;
35
39
  const MAX_RETRIES = 2;
36
40
  export async function withRetry(fn) {
37
41
  let lastErr;
@@ -78,9 +82,19 @@ export async function verifyApiKey(apiKey, baseUrl) {
78
82
  timeout: REQUEST_TIMEOUT_MS,
79
83
  validateStatus: () => true,
80
84
  }));
81
- return r.status === 200;
85
+ if (r.status === 200)
86
+ return true;
87
+ // 401/403 = a genuinely invalid/expired key → false. But 5xx/429 are server-side
88
+ // problems, NOT a bad key — throw so callers report "API unavailable" instead of
89
+ // falsely telling the user to re-create a perfectly good key.
90
+ if (r.status >= 500 || r.status === 429) {
91
+ throw new ApiError(r.status, `TokenMix API is temporarily unavailable (HTTP ${r.status}). Please try again in a moment.`);
92
+ }
93
+ return false;
82
94
  }
83
95
  catch (err) {
96
+ if (err instanceof ApiError)
97
+ throw err;
84
98
  handleAxios(err);
85
99
  }
86
100
  }
@@ -121,18 +135,38 @@ const DEFAULT_EXPIRES_IN_S = 900;
121
135
  // Throws DeviceFlowError with code ∈ {expired_token, access_denied, api_key_limit_reached, timeout} on terminal failures.
122
136
  // onTick is called once per polling iteration with the seconds remaining (for progress display).
123
137
  export async function pollDeviceToken(baseUrl, auth, onTick) {
124
- let intervalMs = Math.max(1, Number(auth.interval) || DEFAULT_POLL_INTERVAL_S) * 1000;
125
- const expiresIn = Number(auth.expires_in) > 0 ? Number(auth.expires_in) : DEFAULT_EXPIRES_IN_S;
138
+ // Clamp the server-provided interval/deadline so a malicious or buggy server can't
139
+ // drive a ~1ms busy-loop Infinity/NaN/huge values slip past a bare Math.max.
140
+ // Interval ∈ [1s, 60s]; the overall deadline is capped at 1h.
141
+ const clampIntervalMs = (s) => {
142
+ const ms = (Number.isFinite(s) && s > 0 ? s : DEFAULT_POLL_INTERVAL_S) * 1000;
143
+ return Math.min(60_000, Math.max(1000, ms));
144
+ };
145
+ let intervalMs = clampIntervalMs(Number(auth.interval));
146
+ const expiresInRaw = Number(auth.expires_in);
147
+ const expiresIn = Number.isFinite(expiresInRaw) && expiresInRaw > 0
148
+ ? Math.min(expiresInRaw, 3600)
149
+ : DEFAULT_EXPIRES_IN_S;
126
150
  const deadline = Date.now() + expiresIn * 1000;
127
151
  while (Date.now() < deadline) {
128
152
  await new Promise((r) => setTimeout(r, intervalMs));
129
153
  if (onTick) {
130
- onTick(Math.max(0, Math.round((deadline - Date.now()) / 1000)));
154
+ try {
155
+ onTick(Math.max(0, Math.round((deadline - Date.now()) / 1000)));
156
+ }
157
+ catch {
158
+ // a progress callback must never be able to abort the login flow
159
+ }
131
160
  }
132
161
  try {
133
162
  const r = await axios.post(`${baseUrl}/api/auth/device/token`, { device_code: auth.device_code }, { timeout: REQUEST_TIMEOUT_MS, validateStatus: () => true });
134
163
  if (r.status === 200 && r.data?.code === 0) {
135
164
  const body = r.data.data;
165
+ // A 200/code:0 with no usable token would otherwise be written to config as
166
+ // `apiKey: undefined` and look like a successful login. Treat it as an error.
167
+ if (!body || typeof body.access_token !== 'string') {
168
+ throw new DeviceFlowError('unknown', 'Server returned a success response without a token.');
169
+ }
136
170
  return {
137
171
  apiKey: body.access_token,
138
172
  apiKeyId: body.api_key_id,
@@ -147,7 +181,7 @@ export async function pollDeviceToken(baseUrl, auth, onTick) {
147
181
  case 'slow_down': {
148
182
  const ra = parseInt(String(r.headers['retry-after'] ?? '5'), 10);
149
183
  if (Number.isFinite(ra) && ra > 0)
150
- intervalMs = ra * 1000;
184
+ intervalMs = clampIntervalMs(ra);
151
185
  continue;
152
186
  }
153
187
  case 'expired_token':
@@ -38,6 +38,7 @@ export function registerAgentCommands(program, runner = runAgent) {
38
38
  .description(t('cmd.agent', { name: agent.displayName }))
39
39
  .allowUnknownOption(true)
40
40
  .passThroughOptions(true) // forward --version / --help / --any to the underlying agent
41
+ .helpOption(false) // so `tokenmix <agent> --help` reaches the agent, not commander's wrapper help
41
42
  .action(async (args = []) => {
42
43
  await runner(agent, args);
43
44
  });
@@ -1,5 +1,8 @@
1
1
  import fs from 'fs-extra';
2
2
  import { configDir, configFile } from './paths.js';
3
+ import { writeFileAtomic } from '../utils/fs.js';
4
+ import { logger } from '../utils/logger.js';
5
+ import { t } from '../i18n/index.js';
3
6
  export const DEFAULT_API_BASE = 'https://api.tokenmix.ai';
4
7
  // The model agents default to when the user hasn't chosen one (overridable via
5
8
  // the TOKENMIX_DEFAULT_MODEL env var or stored config). Single source of truth.
@@ -10,24 +13,28 @@ export function v1Url(baseUrl) {
10
13
  return `${baseUrl.replace(/\/+$/, '')}/v1`;
11
14
  }
12
15
  export async function readConfig() {
16
+ let raw;
17
+ try {
18
+ raw = await fs.readFile(configFile(), 'utf-8');
19
+ }
20
+ catch {
21
+ return {}; // not logged in yet — the config file simply doesn't exist
22
+ }
13
23
  try {
14
- const raw = await fs.readFile(configFile(), 'utf-8');
15
24
  return JSON.parse(raw);
16
25
  }
17
26
  catch {
27
+ // The file exists but is corrupt (e.g. a crash truncated it mid-write). Don't
28
+ // silently treat it as "logged out" — warn so the user knows to re-login.
29
+ logger.warn(t('config.corrupt'));
18
30
  return {};
19
31
  }
20
32
  }
21
33
  export async function writeConfig(cfg) {
22
34
  await fs.ensureDir(configDir());
23
- await fs.writeFile(configFile(), JSON.stringify(cfg, null, 2));
24
- // Restrict to owner read/write only (best-effort on Windows).
25
- try {
26
- await fs.chmod(configFile(), 0o600);
27
- }
28
- catch {
29
- // ignore on filesystems that don't support chmod
30
- }
35
+ // Atomic write so a crash or a concurrent writer can't truncate the config to
36
+ // 0 bytes and silently lose the apiKey. 0600 = owner read/write only.
37
+ await writeFileAtomic(configFile(), JSON.stringify(cfg, null, 2), 0o600);
31
38
  }
32
39
  export async function updateConfig(patch) {
33
40
  const current = await readConfig();
@@ -23,11 +23,10 @@ export function getLocale() {
23
23
  // Translate a key for the active locale, filling {placeholders} from params.
24
24
  // Falls back to English, then to the raw key, so a partial catalog never throws.
25
25
  export function t(key, params) {
26
- let s = catalogs[current]?.[key] ?? en[key] ?? key;
27
- if (params) {
28
- for (const [k, v] of Object.entries(params)) {
29
- s = s.split(`{${k}}`).join(String(v));
30
- }
31
- }
32
- return s;
26
+ const s = catalogs[current]?.[key] ?? en[key] ?? key;
27
+ if (!params)
28
+ return s;
29
+ // Single-pass replacement so a value that itself contains "{other}" is NOT
30
+ // re-substituted by a later param (order-independent and injection-safe).
31
+ return s.replace(/\{(\w+)\}/g, (match, name) => Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match);
33
32
  }
@@ -6,6 +6,8 @@
6
6
  // `{name}`-style placeholders are filled by t() at call time.
7
7
  export const en = {
8
8
  'common.notLoggedIn': 'Not logged in. Run `tokenmix login` first.',
9
+ 'cli.unknownCommand': 'Unknown command: {cmd}. Run `tokenmix --help` to see available commands.',
10
+ 'config.corrupt': 'Your TokenMix config file was corrupt and has been ignored — run `tokenmix login` to re-create it.',
9
11
  // welcome screen (bare `tokenmix`, no args — onboarding for first-time users)
10
12
  'welcome.tagline': 'one account, 160+ models, every open-source coding agent',
11
13
  'welcome.why': 'The model you pick is the model you get — no silent swaps, billed at real usage.',
@@ -184,6 +186,8 @@ export const en = {
184
186
  };
185
187
  export const zh = {
186
188
  'common.notLoggedIn': '未登录,请先运行 `tokenmix login`。',
189
+ 'cli.unknownCommand': '未知命令:{cmd}。运行 `tokenmix --help` 查看可用命令。',
190
+ 'config.corrupt': 'TokenMix 配置文件已损坏,已忽略 —— 请运行 `tokenmix login` 重新创建。',
187
191
  'welcome.tagline': '一个账户、160+ 模型、对接所有开源编程 agent',
188
192
  'welcome.why': '你选的模型就是你用的模型 —— 不偷换、不降级,按真实用量计费。',
189
193
  'welcome.start': '快速开始:',
@@ -354,6 +358,8 @@ export const zh = {
354
358
  };
355
359
  export const ja = {
356
360
  'common.notLoggedIn': 'ログインしていません。まず `tokenmix login` を実行してください。',
361
+ 'cli.unknownCommand': '不明なコマンド: {cmd}。`tokenmix --help` で利用可能なコマンドを確認してください。',
362
+ 'config.corrupt': 'TokenMix の設定ファイルが破損していたため無視しました —— `tokenmix login` を実行して再作成してください。',
357
363
  'welcome.tagline': '1 つのアカウントで 160+ モデル、あらゆるオープンソース・コーディング agent に対応',
358
364
  'welcome.why': '選んだモデルがそのまま使われます —— すり替えなし、実使用量で課金。',
359
365
  'welcome.start': 'はじめに:',
@@ -509,6 +515,8 @@ export const ja = {
509
515
  };
510
516
  export const ko = {
511
517
  'common.notLoggedIn': '로그인되어 있지 않습니다. 먼저 `tokenmix login`을 실행하세요.',
518
+ 'cli.unknownCommand': '알 수 없는 명령: {cmd}. `tokenmix --help`로 사용 가능한 명령을 확인하세요.',
519
+ 'config.corrupt': 'TokenMix 설정 파일이 손상되어 무시했습니다 —— `tokenmix login`을 실행해 다시 만드세요.',
512
520
  'welcome.tagline': '하나의 계정으로 160+ 모델, 모든 오픈소스 코딩 agent 지원',
513
521
  'welcome.why': '선택한 모델 그대로 실행됩니다 —— 몰래 바꾸지 않고 실사용량으로 과금합니다.',
514
522
  'welcome.start': '시작하기:',
@@ -664,6 +672,8 @@ export const ko = {
664
672
  };
665
673
  export const es = {
666
674
  'common.notLoggedIn': 'No has iniciado sesión. Ejecuta `tokenmix login` primero.',
675
+ 'cli.unknownCommand': 'Comando desconocido: {cmd}. Ejecuta `tokenmix --help` para ver los comandos disponibles.',
676
+ 'config.corrupt': 'Tu archivo de configuración de TokenMix estaba dañado y se ha ignorado: ejecuta `tokenmix login` para volver a crearlo.',
667
677
  'welcome.tagline': 'una cuenta, 160+ modelos, todos los agentes de programación de código abierto',
668
678
  'welcome.why': 'El modelo que eliges es el que usas: sin cambios ocultos, facturado por uso real.',
669
679
  'welcome.start': 'Empezar:',
@@ -819,6 +829,8 @@ export const es = {
819
829
  };
820
830
  export const fr = {
821
831
  'common.notLoggedIn': 'Non connecté. Exécutez d’abord `tokenmix login`.',
832
+ 'cli.unknownCommand': 'Commande inconnue : {cmd}. Exécutez `tokenmix --help` pour voir les commandes disponibles.',
833
+ 'config.corrupt': 'Votre fichier de configuration TokenMix était corrompu et a été ignoré — exécutez `tokenmix login` pour le recréer.',
822
834
  'welcome.tagline': 'un seul compte, 160+ modèles, tous les agents de codage open source',
823
835
  'welcome.why': 'Le modèle que vous choisissez est celui que vous utilisez — sans substitution, facturé à l’usage réel.',
824
836
  'welcome.start': 'Démarrer :',
package/dist/program.js CHANGED
@@ -9,6 +9,7 @@ import { listCommand } from './commands/list.js';
9
9
  import { doctorCommand } from './commands/doctor.js';
10
10
  import { welcomeCommand } from './commands/welcome.js';
11
11
  import { registerAgentCommands } from './commands/agent-runner.js';
12
+ import { logger } from './utils/logger.js';
12
13
  import { t } from './i18n/index.js';
13
14
  // Read version from package.json so we never have to bump it in two places.
14
15
  const pkg = createRequire(import.meta.url)('../package.json');
@@ -25,7 +26,15 @@ export function buildProgram(deps = {}) {
25
26
  .description(t('cmd.program'))
26
27
  .version(pkg.version)
27
28
  // Bare `tokenmix` (no command) shows a friendly onboarding screen, not raw help.
28
- .action(welcomeCommand);
29
+ // An unrecognized command would otherwise be swallowed into this default action
30
+ // (printing welcome + exiting 0), so reject it explicitly instead.
31
+ .action((_options, command) => {
32
+ if (command.args.length > 0) {
33
+ logger.error(t('cli.unknownCommand', { cmd: command.args.join(' ') }));
34
+ process.exit(1);
35
+ }
36
+ return welcomeCommand();
37
+ });
29
38
  program
30
39
  .command('login')
31
40
  .description(t('cmd.login'))
@@ -2,15 +2,24 @@ import open from 'open';
2
2
  import { logger } from './logger.js';
3
3
  import { t } from '../i18n/index.js';
4
4
  export async function openInBrowser(url) {
5
- await open(url);
5
+ const child = await open(url);
6
+ // open() spawns a detached, unref'd child; if the launcher binary is missing its
7
+ // async 'error' event would otherwise be uncaught (→ process crash). Swallow it —
8
+ // callers print the URL and treat a browser failure as non-fatal.
9
+ child.on('error', () => { });
6
10
  }
7
- // Try to open the browser; on failure (headless / SSH / no desktop environment),
8
- // print the URL so the user can open it manually instead of crashing.
11
+ // Print the URL, then best-effort open the browser. We print UNCONDITIONALLY and
12
+ // first: without `{ wait: true }`, open() resolves immediately even when no browser
13
+ // actually launches (headless / SSH / container / no desktop), so a catch-based
14
+ // fallback would never fire and the user would be left staring at nothing. Printing
15
+ // first guarantees they can always copy the link.
9
16
  export async function openOrHint(url) {
17
+ logger.info(t('browser.manual', { url }));
10
18
  try {
11
- await open(url);
19
+ const child = await open(url);
20
+ child.on('error', () => { }); // swallow async spawn errors; the URL is already shown
12
21
  }
13
22
  catch {
14
- logger.info(t('browser.manual', { url }));
23
+ // best-effort the URL is already shown above
15
24
  }
16
25
  }
@@ -0,0 +1,31 @@
1
+ import fs from 'fs-extra';
2
+ // Atomically write a file: write to a temp file alongside the target, then rename
3
+ // it over the target. Rename is atomic on the same filesystem, so a crash or a
4
+ // concurrent writer can never observe a half-written / 0-byte target — which is how
5
+ // a plain `fs.writeFile` (open with O_TRUNC) would otherwise corrupt user config
6
+ // (e.g. truncating ~/.claude/settings.json or our own config.json to 0 bytes).
7
+ export async function writeFileAtomic(filePath, data, mode) {
8
+ const tmp = `${filePath}.${process.pid}.tmp`;
9
+ try {
10
+ await fs.writeFile(tmp, data);
11
+ if (mode !== undefined) {
12
+ try {
13
+ await fs.chmod(tmp, mode);
14
+ }
15
+ catch {
16
+ // chmod unsupported (e.g. Windows) — non-fatal, keep going.
17
+ }
18
+ }
19
+ await fs.rename(tmp, filePath);
20
+ }
21
+ catch (err) {
22
+ // Best-effort cleanup of the temp file if the rename never happened.
23
+ try {
24
+ await fs.remove(tmp);
25
+ }
26
+ catch {
27
+ // ignore
28
+ }
29
+ throw err;
30
+ }
31
+ }
@@ -5,7 +5,7 @@ export async function promptApiKey() {
5
5
  type: 'password',
6
6
  name: 'apiKey',
7
7
  message: t('prompt.pasteKey'),
8
- validate: (v) => (v && v.startsWith('sk-tm-') ? true : t('login.keyMustStart')),
8
+ validate: (v) => (v && v.trim().startsWith('sk-tm-') ? true : t('login.keyMustStart')),
9
9
  });
10
10
  const key = r.apiKey?.trim();
11
11
  return key || null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmix",
3
- "version": "1.5.1",
3
+ "version": "1.5.3",
4
4
  "description": "Zero-config CLI to use any open-source coding agent with TokenMix as the unified LLM backend.",
5
5
  "type": "module",
6
6
  "bin": {