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.
- package/dist/agents/claude.js +25 -21
- package/dist/agents/codex.js +8 -2
- package/dist/agents/continue.js +6 -3
- package/dist/agents/opencode.js +13 -6
- package/dist/agents/openhands.js +1 -1
- package/dist/api/client.js +41 -7
- package/dist/commands/agent-runner.js +1 -0
- package/dist/config/store.js +16 -9
- package/dist/i18n/index.js +6 -7
- package/dist/i18n/messages.js +12 -0
- package/dist/program.js +10 -1
- package/dist/utils/browser.js +14 -5
- package/dist/utils/fs.js +31 -0
- package/dist/utils/prompt.js +1 -1
- package/package.json +1 -1
package/dist/agents/claude.js
CHANGED
|
@@ -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
|
|
20
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
...
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
package/dist/agents/codex.js
CHANGED
|
@@ -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',
|
package/dist/agents/continue.js
CHANGED
|
@@ -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: [
|
package/dist/agents/opencode.js
CHANGED
|
@@ -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
|
|
26
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
96
|
+
await writeFileAtomic(filePath, JSON.stringify(existing, null, 2));
|
|
90
97
|
return { reverted: true, configPath: filePath };
|
|
91
98
|
}
|
|
92
99
|
export const OpenCodeAgent = {
|
package/dist/agents/openhands.js
CHANGED
|
@@ -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
|
},
|
package/dist/api/client.js
CHANGED
|
@@ -9,7 +9,10 @@ export class ApiError extends Error {
|
|
|
9
9
|
}
|
|
10
10
|
export function unwrap(resp) {
|
|
11
11
|
const body = resp.data;
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
|
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
|
});
|
package/dist/config/store.js
CHANGED
|
@@ -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
|
-
|
|
24
|
-
//
|
|
25
|
-
|
|
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();
|
package/dist/i18n/index.js
CHANGED
|
@@ -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
|
-
|
|
27
|
-
if (params)
|
|
28
|
-
|
|
29
|
-
|
|
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
|
}
|
package/dist/i18n/messages.js
CHANGED
|
@@ -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
|
-
|
|
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'))
|
package/dist/utils/browser.js
CHANGED
|
@@ -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
|
-
//
|
|
8
|
-
//
|
|
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
|
-
|
|
23
|
+
// best-effort — the URL is already shown above
|
|
15
24
|
}
|
|
16
25
|
}
|
package/dist/utils/fs.js
ADDED
|
@@ -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
|
+
}
|
package/dist/utils/prompt.js
CHANGED
|
@@ -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;
|