claude-connect 0.1.0
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/LICENSE +21 -0
- package/README.md +175 -0
- package/bin/claude-connect.js +40 -0
- package/package.json +43 -0
- package/src/data/catalog-store.js +482 -0
- package/src/gateway/constants.js +4 -0
- package/src/gateway/messages.js +362 -0
- package/src/gateway/server.js +481 -0
- package/src/gateway/state.js +104 -0
- package/src/index.js +61 -0
- package/src/lib/app-paths.js +269 -0
- package/src/lib/claude-settings.js +364 -0
- package/src/lib/oauth.js +485 -0
- package/src/lib/profile.js +104 -0
- package/src/lib/secrets.js +45 -0
- package/src/lib/terminal.js +350 -0
- package/src/lib/theme.js +44 -0
- package/src/wizard.js +887 -0
package/src/lib/oauth.js
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { resolveClaudeConnectPaths } from './app-paths.js';
|
|
7
|
+
import { navigation } from './terminal.js';
|
|
8
|
+
|
|
9
|
+
function encodeForm(data) {
|
|
10
|
+
return new URLSearchParams(data).toString();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isObject(value) {
|
|
14
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseJsonText(value) {
|
|
18
|
+
if (typeof value !== 'string') {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const trimmed = value.trim();
|
|
23
|
+
|
|
24
|
+
if (trimmed.length === 0) {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(trimmed);
|
|
30
|
+
} catch (_error) {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function describeRequestError(error) {
|
|
36
|
+
if (!(error instanceof Error)) {
|
|
37
|
+
return String(error);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const parts = [error.message];
|
|
41
|
+
const cause = error.cause;
|
|
42
|
+
|
|
43
|
+
if (cause && typeof cause === 'object') {
|
|
44
|
+
const code = 'code' in cause && typeof cause.code === 'string' ? cause.code : null;
|
|
45
|
+
const message = 'message' in cause && typeof cause.message === 'string' ? cause.message : null;
|
|
46
|
+
|
|
47
|
+
if (code) {
|
|
48
|
+
parts.push(`code=${code}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (message && message !== error.message) {
|
|
52
|
+
parts.push(message);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return parts.join(' · ');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildCurlCommand(url, { method = 'GET', headers = {}, body = null } = {}, platform = process.platform) {
|
|
60
|
+
const command = platform === 'win32' ? 'curl.exe' : 'curl';
|
|
61
|
+
const args = [
|
|
62
|
+
'--silent',
|
|
63
|
+
'--show-error',
|
|
64
|
+
'--location',
|
|
65
|
+
'--request',
|
|
66
|
+
method,
|
|
67
|
+
'--output',
|
|
68
|
+
'-',
|
|
69
|
+
'--write-out',
|
|
70
|
+
'\n%{http_code}',
|
|
71
|
+
url
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
75
|
+
args.push('--header', `${name}: ${value}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (typeof body === 'string') {
|
|
79
|
+
args.push('--data-raw', body);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
command,
|
|
84
|
+
args
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function runCommandJson({ command, args }) {
|
|
89
|
+
return await new Promise((resolve, reject) => {
|
|
90
|
+
const child = spawn(command, args, {
|
|
91
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
92
|
+
});
|
|
93
|
+
let stdout = '';
|
|
94
|
+
let stderr = '';
|
|
95
|
+
|
|
96
|
+
child.stdout.on('data', (chunk) => {
|
|
97
|
+
stdout += chunk.toString();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
child.stderr.on('data', (chunk) => {
|
|
101
|
+
stderr += chunk.toString();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
child.on('error', reject);
|
|
105
|
+
child.on('close', (code) => {
|
|
106
|
+
if (code !== 0) {
|
|
107
|
+
reject(new Error(stderr.trim() || `${command} termino con codigo ${code}.`));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
resolve({
|
|
112
|
+
stdout,
|
|
113
|
+
stderr
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function requestJson(url, { method = 'GET', headers = {}, body = null } = {}) {
|
|
120
|
+
try {
|
|
121
|
+
const response = await fetch(url, {
|
|
122
|
+
method,
|
|
123
|
+
headers,
|
|
124
|
+
body,
|
|
125
|
+
signal: AbortSignal.timeout(20000)
|
|
126
|
+
});
|
|
127
|
+
const raw = await response.text();
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
ok: response.ok,
|
|
131
|
+
status: response.status,
|
|
132
|
+
payload: parseJsonText(raw)
|
|
133
|
+
};
|
|
134
|
+
} catch (fetchError) {
|
|
135
|
+
if (process.platform !== 'win32') {
|
|
136
|
+
throw new Error(`No se pudo conectar con ${url}: ${describeRequestError(fetchError)}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const curlRequest = buildCurlCommand(url, { method, headers, body });
|
|
141
|
+
const result = await runCommandJson({
|
|
142
|
+
command: curlRequest.command,
|
|
143
|
+
args: curlRequest.args
|
|
144
|
+
});
|
|
145
|
+
const separatorIndex = result.stdout.lastIndexOf('\n');
|
|
146
|
+
const rawBody = separatorIndex === -1 ? result.stdout : result.stdout.slice(0, separatorIndex);
|
|
147
|
+
const rawStatus = separatorIndex === -1 ? '' : result.stdout.slice(separatorIndex + 1).trim();
|
|
148
|
+
const status = Number(rawStatus);
|
|
149
|
+
|
|
150
|
+
if (!Number.isFinite(status) || status <= 0) {
|
|
151
|
+
throw new Error('curl devolvio una respuesta sin codigo HTTP interpretable.');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
ok: status >= 200 && status < 300,
|
|
156
|
+
status,
|
|
157
|
+
payload: parseJsonText(rawBody)
|
|
158
|
+
};
|
|
159
|
+
} catch (curlError) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`No se pudo conectar con ${url}: fetch=${describeRequestError(fetchError)} · curl=${describeRequestError(curlError)}`
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function buildBrowserOpenCommands(url, platform = process.platform) {
|
|
168
|
+
if (platform === 'darwin') {
|
|
169
|
+
return [['open', [url]]];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (platform === 'win32') {
|
|
173
|
+
return [
|
|
174
|
+
['rundll32.exe', ['url.dll,FileProtocolHandler', url]],
|
|
175
|
+
['powershell.exe', ['-NoProfile', '-Command', `Start-Process '${url.replace(/'/g, "''")}'`]],
|
|
176
|
+
['cmd.exe', ['/d', '/s', '/c', `start "" "${url}"`]]
|
|
177
|
+
];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return [['xdg-open', [url]], ['gio', ['open', url]]];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function openBrowser(url) {
|
|
184
|
+
const commands = buildBrowserOpenCommands(url);
|
|
185
|
+
|
|
186
|
+
return new Promise((resolve) => {
|
|
187
|
+
let index = 0;
|
|
188
|
+
|
|
189
|
+
const tryNext = () => {
|
|
190
|
+
if (index >= commands.length) {
|
|
191
|
+
resolve(false);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const [command, args] = commands[index++];
|
|
196
|
+
const child = spawn(command, args, {
|
|
197
|
+
stdio: 'ignore',
|
|
198
|
+
detached: true
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
child.once('error', tryNext);
|
|
202
|
+
child.once('spawn', () => {
|
|
203
|
+
child.unref();
|
|
204
|
+
resolve(true);
|
|
205
|
+
});
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
tryNext();
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function generatePKCEPair() {
|
|
213
|
+
const codeVerifier = crypto.randomBytes(32).toString('base64url');
|
|
214
|
+
const codeChallenge = crypto
|
|
215
|
+
.createHash('sha256')
|
|
216
|
+
.update(codeVerifier)
|
|
217
|
+
.digest('base64url');
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
codeVerifier,
|
|
221
|
+
codeChallenge
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function requestDeviceCode(oauthConfig) {
|
|
226
|
+
const { codeVerifier, codeChallenge } = generatePKCEPair();
|
|
227
|
+
const response = await requestJson(oauthConfig.deviceCodeUrl, {
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers: {
|
|
230
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
231
|
+
accept: 'application/json'
|
|
232
|
+
},
|
|
233
|
+
body: encodeForm({
|
|
234
|
+
client_id: oauthConfig.clientId,
|
|
235
|
+
scope: oauthConfig.scope,
|
|
236
|
+
code_challenge: codeChallenge,
|
|
237
|
+
code_challenge_method: 'S256'
|
|
238
|
+
})
|
|
239
|
+
});
|
|
240
|
+
const payload = response.payload;
|
|
241
|
+
|
|
242
|
+
if (!response.ok) {
|
|
243
|
+
const message = payload.error_description || payload.error || `HTTP ${response.status}`;
|
|
244
|
+
throw new Error(`No se pudo iniciar OAuth con Qwen: ${message}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
codeVerifier,
|
|
249
|
+
deviceAuthorization: payload
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function pollForToken({
|
|
254
|
+
oauthConfig,
|
|
255
|
+
deviceCode,
|
|
256
|
+
codeVerifier,
|
|
257
|
+
expiresInSeconds,
|
|
258
|
+
statusRenderer,
|
|
259
|
+
authUrl,
|
|
260
|
+
userCode
|
|
261
|
+
}) {
|
|
262
|
+
const startedAt = Date.now();
|
|
263
|
+
let pollIntervalMs = 2000;
|
|
264
|
+
|
|
265
|
+
while (Date.now() - startedAt < expiresInSeconds * 1000) {
|
|
266
|
+
const response = await requestJson(oauthConfig.tokenUrl, {
|
|
267
|
+
method: 'POST',
|
|
268
|
+
headers: {
|
|
269
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
270
|
+
accept: 'application/json'
|
|
271
|
+
},
|
|
272
|
+
body: encodeForm({
|
|
273
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
274
|
+
client_id: oauthConfig.clientId,
|
|
275
|
+
device_code: deviceCode,
|
|
276
|
+
code_verifier: codeVerifier
|
|
277
|
+
})
|
|
278
|
+
});
|
|
279
|
+
const payload = response.payload;
|
|
280
|
+
|
|
281
|
+
if (response.ok && payload.access_token) {
|
|
282
|
+
return payload;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (response.status === 400 && payload.error === 'authorization_pending') {
|
|
286
|
+
statusRenderer({
|
|
287
|
+
title: 'Esperando autorizacion',
|
|
288
|
+
subtitle: 'Completa el login en qwen.ai. El CLI esta consultando el token.',
|
|
289
|
+
lines: [
|
|
290
|
+
'URL para copiar y pegar:',
|
|
291
|
+
authUrl,
|
|
292
|
+
'',
|
|
293
|
+
`User code: ${userCode}`,
|
|
294
|
+
'',
|
|
295
|
+
payload.error_description || 'La autorizacion sigue pendiente.',
|
|
296
|
+
'Esperando aprobacion...'
|
|
297
|
+
]
|
|
298
|
+
});
|
|
299
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (response.status === 429 && payload.error === 'slow_down') {
|
|
304
|
+
pollIntervalMs = Math.min(pollIntervalMs + 1000, 10000);
|
|
305
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const message = payload.error_description || payload.error || `HTTP ${response.status}`;
|
|
310
|
+
throw new Error(`Qwen OAuth devolvio un error al pedir el token: ${message}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
throw new Error('Se agoto el tiempo esperando la aprobacion de Qwen OAuth.');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export async function runOAuthAuthorization({ providerName, oauthConfig, statusRenderer, waitUntilReady }) {
|
|
317
|
+
statusRenderer({
|
|
318
|
+
title: 'Qwen OAuth',
|
|
319
|
+
subtitle: `Iniciando el login de ${providerName} con Qwen Code.`,
|
|
320
|
+
lines: [
|
|
321
|
+
`Device code URL: ${oauthConfig.deviceCodeUrl}`,
|
|
322
|
+
`Token URL: ${oauthConfig.tokenUrl}`,
|
|
323
|
+
'Solicitando codigo de autorizacion...'
|
|
324
|
+
]
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const { codeVerifier, deviceAuthorization } = await requestDeviceCode(oauthConfig);
|
|
328
|
+
const authUrl = deviceAuthorization.verification_uri_complete
|
|
329
|
+
|| `${oauthConfig.browserAuthUrl}?user_code=${encodeURIComponent(deviceAuthorization.user_code)}&client=qwen-code`;
|
|
330
|
+
|
|
331
|
+
const browserOpened = await openBrowser(authUrl);
|
|
332
|
+
|
|
333
|
+
statusRenderer({
|
|
334
|
+
title: 'Autoriza en qwen.ai',
|
|
335
|
+
subtitle: browserOpened
|
|
336
|
+
? 'Se abrio el navegador. Completa el login y vuelve a la terminal.'
|
|
337
|
+
: 'No pude abrir el navegador automaticamente. Abre esta URL manualmente.',
|
|
338
|
+
lines: [
|
|
339
|
+
'URL para copiar y pegar:',
|
|
340
|
+
authUrl,
|
|
341
|
+
'',
|
|
342
|
+
`User code: ${deviceAuthorization.user_code}`,
|
|
343
|
+
'La URL mostrada es la de Qwen, no la de Alibaba Cloud.',
|
|
344
|
+
browserOpened
|
|
345
|
+
? 'Si no ves la pagina, copia esta URL y pegala manualmente en tu navegador.'
|
|
346
|
+
: 'Copia la URL anterior en tu navegador y continua el login.',
|
|
347
|
+
'Cuando ya tengas la pagina abierta, presiona cualquier tecla para empezar a esperar el token.'
|
|
348
|
+
]
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
if (typeof waitUntilReady === 'function') {
|
|
352
|
+
const readyAction = await waitUntilReady();
|
|
353
|
+
|
|
354
|
+
if (readyAction === navigation.EXIT) {
|
|
355
|
+
return navigation.EXIT;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const tokenPayload = await pollForToken({
|
|
360
|
+
oauthConfig,
|
|
361
|
+
deviceCode: deviceAuthorization.device_code,
|
|
362
|
+
codeVerifier,
|
|
363
|
+
expiresInSeconds: Number(deviceAuthorization.expires_in || 600),
|
|
364
|
+
statusRenderer,
|
|
365
|
+
authUrl,
|
|
366
|
+
userCode: deviceAuthorization.user_code
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
statusRenderer({
|
|
370
|
+
title: 'Autenticacion completada',
|
|
371
|
+
subtitle: 'Qwen ya aprobo el login y el token fue recibido por la consola.',
|
|
372
|
+
lines: [
|
|
373
|
+
'Access token recibido correctamente.',
|
|
374
|
+
'La sesion OAuth ya puede guardarse localmente.'
|
|
375
|
+
]
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
authUrl,
|
|
380
|
+
tokenPayload
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export async function saveOAuthToken({ profileName, providerId, tokenPayload }) {
|
|
385
|
+
const { tokensDir: tokenDir } = await resolveClaudeConnectPaths();
|
|
386
|
+
const safeProfileName = profileName
|
|
387
|
+
.toLowerCase()
|
|
388
|
+
.trim()
|
|
389
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
390
|
+
.replace(/^-+|-+$/g, '');
|
|
391
|
+
const filePath = path.join(tokenDir, `${providerId}-${safeProfileName}.json`);
|
|
392
|
+
const savedAt = new Date().toISOString();
|
|
393
|
+
const expiresAt = typeof tokenPayload.expires_in === 'number'
|
|
394
|
+
? new Date(Date.now() + tokenPayload.expires_in * 1000).toISOString()
|
|
395
|
+
: null;
|
|
396
|
+
const payload = {
|
|
397
|
+
schemaVersion: 1,
|
|
398
|
+
providerId,
|
|
399
|
+
savedAt,
|
|
400
|
+
expiresAt,
|
|
401
|
+
token: tokenPayload
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
await fs.mkdir(tokenDir, { recursive: true });
|
|
405
|
+
await fs.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
|
|
406
|
+
|
|
407
|
+
return filePath;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export async function readOAuthToken(filePath) {
|
|
411
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
412
|
+
const parsed = JSON.parse(raw);
|
|
413
|
+
|
|
414
|
+
if (isObject(parsed) && isObject(parsed.token)) {
|
|
415
|
+
return {
|
|
416
|
+
filePath,
|
|
417
|
+
schemaVersion: typeof parsed.schemaVersion === 'number' ? parsed.schemaVersion : 1,
|
|
418
|
+
providerId: typeof parsed.providerId === 'string' ? parsed.providerId : null,
|
|
419
|
+
savedAt: typeof parsed.savedAt === 'string' ? parsed.savedAt : null,
|
|
420
|
+
expiresAt: typeof parsed.expiresAt === 'string' ? parsed.expiresAt : null,
|
|
421
|
+
token: parsed.token
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
filePath,
|
|
427
|
+
schemaVersion: 0,
|
|
428
|
+
providerId: null,
|
|
429
|
+
savedAt: null,
|
|
430
|
+
expiresAt: null,
|
|
431
|
+
token: parsed
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export async function refreshOAuthToken({ filePath, tokenUrl, clientId }) {
|
|
436
|
+
const record = await readOAuthToken(filePath);
|
|
437
|
+
const refreshToken = record.token?.refresh_token;
|
|
438
|
+
|
|
439
|
+
if (typeof refreshToken !== 'string' || refreshToken.length === 0) {
|
|
440
|
+
throw new Error('No hay refresh_token disponible para renovar la sesion OAuth.');
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const response = await requestJson(tokenUrl, {
|
|
444
|
+
method: 'POST',
|
|
445
|
+
headers: {
|
|
446
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
447
|
+
accept: 'application/json'
|
|
448
|
+
},
|
|
449
|
+
body: encodeForm({
|
|
450
|
+
grant_type: 'refresh_token',
|
|
451
|
+
refresh_token: refreshToken,
|
|
452
|
+
client_id: clientId
|
|
453
|
+
})
|
|
454
|
+
});
|
|
455
|
+
const payload = response.payload;
|
|
456
|
+
|
|
457
|
+
if (!response.ok || !payload.access_token) {
|
|
458
|
+
const message = payload.error_description || payload.error || `HTTP ${response.status}`;
|
|
459
|
+
throw new Error(`No se pudo refrescar el token OAuth de Qwen: ${message}`);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const nextToken = {
|
|
463
|
+
...record.token,
|
|
464
|
+
...payload,
|
|
465
|
+
refresh_token: payload.refresh_token || refreshToken
|
|
466
|
+
};
|
|
467
|
+
const savedAt = new Date().toISOString();
|
|
468
|
+
const expiresAt = typeof nextToken.expires_in === 'number'
|
|
469
|
+
? new Date(Date.now() + nextToken.expires_in * 1000).toISOString()
|
|
470
|
+
: null;
|
|
471
|
+
const nextRecord = {
|
|
472
|
+
schemaVersion: 1,
|
|
473
|
+
providerId: record.providerId,
|
|
474
|
+
savedAt,
|
|
475
|
+
expiresAt,
|
|
476
|
+
token: nextToken
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
await fs.writeFile(filePath, `${JSON.stringify(nextRecord, null, 2)}\n`, { mode: 0o600 });
|
|
480
|
+
|
|
481
|
+
return {
|
|
482
|
+
...nextRecord,
|
|
483
|
+
filePath
|
|
484
|
+
};
|
|
485
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { resolveClaudeConnectPaths } from './app-paths.js';
|
|
4
|
+
|
|
5
|
+
export function slugifyProfileName(value) {
|
|
6
|
+
return value
|
|
7
|
+
.toLowerCase()
|
|
8
|
+
.trim()
|
|
9
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
10
|
+
.replace(/^-+|-+$/g, '')
|
|
11
|
+
.slice(0, 48);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildProfile({ provider, model, authMethod, profileName, apiKeyEnvVar, oauthSession }) {
|
|
15
|
+
const profile = {
|
|
16
|
+
schemaVersion: 1,
|
|
17
|
+
profileName,
|
|
18
|
+
provider: {
|
|
19
|
+
id: provider.id,
|
|
20
|
+
name: provider.name,
|
|
21
|
+
vendor: provider.vendor
|
|
22
|
+
},
|
|
23
|
+
model: {
|
|
24
|
+
id: model.id,
|
|
25
|
+
name: model.name,
|
|
26
|
+
contextWindow: model.contextWindow
|
|
27
|
+
},
|
|
28
|
+
auth: {
|
|
29
|
+
method: authMethod.id
|
|
30
|
+
},
|
|
31
|
+
endpoint: {
|
|
32
|
+
baseUrl: provider.baseUrl
|
|
33
|
+
},
|
|
34
|
+
integration: {
|
|
35
|
+
protocol: 'openai-compatible',
|
|
36
|
+
notes: provider.description
|
|
37
|
+
},
|
|
38
|
+
createdAt: new Date().toISOString()
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
if (authMethod.id === 'token') {
|
|
42
|
+
profile.auth.envVar = apiKeyEnvVar;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (authMethod.id === 'oauth' && oauthSession) {
|
|
46
|
+
profile.auth.oauth = oauthSession;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return profile;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function saveProfile(profile) {
|
|
53
|
+
const { profilesDir: configDir } = await resolveClaudeConnectPaths();
|
|
54
|
+
const fileName = `${slugifyProfileName(profile.profileName || `${profile.provider.id}-${profile.model.id}`)}.json`;
|
|
55
|
+
const filePath = path.join(configDir, fileName);
|
|
56
|
+
|
|
57
|
+
await fs.mkdir(configDir, { recursive: true });
|
|
58
|
+
await fs.writeFile(filePath, `${JSON.stringify(profile, null, 2)}\n`, { mode: 0o600 });
|
|
59
|
+
|
|
60
|
+
return filePath;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function readProfileFile(filePath) {
|
|
64
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
65
|
+
return JSON.parse(raw);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function updateProfileFile(filePath, profile) {
|
|
69
|
+
await fs.writeFile(filePath, `${JSON.stringify(profile, null, 2)}\n`, { mode: 0o600 });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function deleteProfileFile(filePath) {
|
|
73
|
+
await fs.unlink(filePath);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function listProfiles() {
|
|
77
|
+
const { profilesDir: configDir } = await resolveClaudeConnectPaths();
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const entries = await fs.readdir(configDir, { withFileTypes: true });
|
|
81
|
+
const files = entries
|
|
82
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
83
|
+
.map((entry) => path.join(configDir, entry.name))
|
|
84
|
+
.sort();
|
|
85
|
+
|
|
86
|
+
const profiles = [];
|
|
87
|
+
|
|
88
|
+
for (const filePath of files) {
|
|
89
|
+
const profile = await readProfileFile(filePath);
|
|
90
|
+
profiles.push({
|
|
91
|
+
...profile,
|
|
92
|
+
filePath
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return profiles;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { resolveClaudeConnectPaths } from './app-paths.js';
|
|
4
|
+
import { slugifyProfileName } from './profile.js';
|
|
5
|
+
|
|
6
|
+
export async function saveManagedTokenSecret({ profileName, providerId, modelId, envVar, token }) {
|
|
7
|
+
const { claudeConnectHome } = await resolveClaudeConnectPaths();
|
|
8
|
+
const secretsDir = path.join(claudeConnectHome, 'secrets');
|
|
9
|
+
const filePath = path.join(secretsDir, `${slugifyProfileName(`${providerId}-${profileName}`)}.json`);
|
|
10
|
+
|
|
11
|
+
await fs.mkdir(secretsDir, { recursive: true });
|
|
12
|
+
await fs.writeFile(
|
|
13
|
+
filePath,
|
|
14
|
+
`${JSON.stringify({
|
|
15
|
+
schemaVersion: 1,
|
|
16
|
+
providerId,
|
|
17
|
+
modelId,
|
|
18
|
+
envVar,
|
|
19
|
+
token,
|
|
20
|
+
savedAt: new Date().toISOString()
|
|
21
|
+
}, null, 2)}\n`,
|
|
22
|
+
{ mode: 0o600 }
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
return filePath;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function readManagedTokenSecret(filePath) {
|
|
29
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
30
|
+
return JSON.parse(raw);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function deleteManagedTokenSecret(filePath) {
|
|
34
|
+
try {
|
|
35
|
+
await fs.unlink(filePath);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return true;
|
|
45
|
+
}
|