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.
@@ -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
+ }