@link-assistant/agent 0.9.0 → 0.10.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/agent",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "A minimal, public domain AI CLI agent compatible with OpenCode's JSON interface. Bun-only runtime.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -2181,6 +2181,455 @@ const GooglePlugin: AuthPlugin = {
2181
2181
  },
2182
2182
  };
2183
2183
 
2184
+ /**
2185
+ * Qwen OAuth Configuration
2186
+ * Used for Qwen Coder subscription authentication via chat.qwen.ai
2187
+ *
2188
+ * Based on the official Qwen Code CLI (QwenLM/qwen-code)
2189
+ * and qwen-auth-opencode reference implementation:
2190
+ * https://github.com/QwenLM/Qwen3-Coder
2191
+ * https://github.com/lion-lef/qwen-auth-opencode
2192
+ */
2193
+ const QWEN_OAUTH_CLIENT_ID = 'f0304373b74a44d2b584a3fb70ca9e56';
2194
+ const QWEN_OAUTH_SCOPE = 'openid profile email model.completion';
2195
+ const QWEN_OAUTH_DEVICE_CODE_ENDPOINT =
2196
+ 'https://chat.qwen.ai/api/v1/oauth2/device/code';
2197
+ const QWEN_OAUTH_TOKEN_ENDPOINT = 'https://chat.qwen.ai/api/v1/oauth2/token';
2198
+ const QWEN_OAUTH_DEFAULT_API_URL = 'https://portal.qwen.ai/v1';
2199
+
2200
+ /**
2201
+ * Detect if running in a headless environment (no GUI)
2202
+ */
2203
+ function isHeadlessEnvironment(): boolean {
2204
+ // Check common headless indicators
2205
+ if (!process.stdout.isTTY) return true;
2206
+ if (process.env.SSH_CLIENT || process.env.SSH_TTY) return true;
2207
+ if (process.env.CI) return true;
2208
+ if (!process.env.DISPLAY && process.platform === 'linux') return true;
2209
+ return false;
2210
+ }
2211
+
2212
+ /**
2213
+ * Open URL in the default browser
2214
+ */
2215
+ function openBrowser(url: string): void {
2216
+ const platform = process.platform;
2217
+ let command: string;
2218
+
2219
+ if (platform === 'darwin') {
2220
+ command = 'open';
2221
+ } else if (platform === 'win32') {
2222
+ command = 'start';
2223
+ } else {
2224
+ command = 'xdg-open';
2225
+ }
2226
+
2227
+ Bun.spawn([command, url], { stdout: 'ignore', stderr: 'ignore' });
2228
+ }
2229
+
2230
+ /**
2231
+ * Qwen OAuth Plugin
2232
+ * Supports Qwen Coder subscription via OAuth device flow.
2233
+ *
2234
+ * Uses OAuth 2.0 Device Authorization Grant (RFC 8628) with PKCE (RFC 7636),
2235
+ * matching the official Qwen Code CLI implementation.
2236
+ *
2237
+ * @see https://github.com/QwenLM/Qwen3-Coder
2238
+ */
2239
+ const QwenPlugin: AuthPlugin = {
2240
+ provider: 'qwen-coder',
2241
+ methods: [
2242
+ {
2243
+ label: 'Qwen Coder Subscription (OAuth)',
2244
+ type: 'oauth',
2245
+ async authorize() {
2246
+ // Generate PKCE pair
2247
+ const codeVerifier = generateRandomString(32);
2248
+ const codeChallenge = generateCodeChallenge(codeVerifier);
2249
+
2250
+ // Request device code
2251
+ const deviceResponse = await fetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, {
2252
+ method: 'POST',
2253
+ headers: {
2254
+ 'Content-Type': 'application/x-www-form-urlencoded',
2255
+ Accept: 'application/json',
2256
+ },
2257
+ body: new URLSearchParams({
2258
+ client_id: QWEN_OAUTH_CLIENT_ID,
2259
+ scope: QWEN_OAUTH_SCOPE,
2260
+ code_challenge: codeChallenge,
2261
+ code_challenge_method: 'S256',
2262
+ }).toString(),
2263
+ });
2264
+
2265
+ if (!deviceResponse.ok) {
2266
+ const errorText = await deviceResponse.text();
2267
+ log.error(() => ({
2268
+ message: 'qwen oauth device code request failed',
2269
+ status: deviceResponse.status,
2270
+ error: errorText,
2271
+ }));
2272
+ throw new Error(
2273
+ `Device authorization failed: ${deviceResponse.status}`
2274
+ );
2275
+ }
2276
+
2277
+ const deviceData = (await deviceResponse.json()) as {
2278
+ device_code: string;
2279
+ user_code: string;
2280
+ verification_uri: string;
2281
+ verification_uri_complete: string;
2282
+ expires_in: number;
2283
+ interval?: number;
2284
+ };
2285
+
2286
+ const pollInterval = (deviceData.interval || 2) * 1000;
2287
+ const maxPollAttempts = Math.ceil(
2288
+ deviceData.expires_in / (pollInterval / 1000)
2289
+ );
2290
+
2291
+ // Try to open browser in non-headless environments
2292
+ if (!isHeadlessEnvironment()) {
2293
+ try {
2294
+ openBrowser(deviceData.verification_uri_complete);
2295
+ } catch {
2296
+ // Ignore browser open errors
2297
+ }
2298
+ }
2299
+
2300
+ const instructions = isHeadlessEnvironment()
2301
+ ? `Visit: ${deviceData.verification_uri}\nEnter code: ${deviceData.user_code}`
2302
+ : `Opening browser for authentication...\nIf browser doesn't open, visit: ${deviceData.verification_uri}\nEnter code: ${deviceData.user_code}`;
2303
+
2304
+ return {
2305
+ url: deviceData.verification_uri_complete,
2306
+ instructions,
2307
+ method: 'auto' as const,
2308
+ async callback(): Promise<AuthResult> {
2309
+ // Poll for authorization completion
2310
+ for (let attempt = 0; attempt < maxPollAttempts; attempt++) {
2311
+ const tokenResponse = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
2312
+ method: 'POST',
2313
+ headers: {
2314
+ 'Content-Type': 'application/x-www-form-urlencoded',
2315
+ Accept: 'application/json',
2316
+ },
2317
+ body: new URLSearchParams({
2318
+ client_id: QWEN_OAUTH_CLIENT_ID,
2319
+ device_code: deviceData.device_code,
2320
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
2321
+ code_verifier: codeVerifier,
2322
+ }).toString(),
2323
+ });
2324
+
2325
+ if (!tokenResponse.ok) {
2326
+ const errorText = await tokenResponse.text();
2327
+ try {
2328
+ const errorJson = JSON.parse(errorText);
2329
+ if (
2330
+ errorJson.error === 'authorization_pending' ||
2331
+ errorJson.error === 'slow_down'
2332
+ ) {
2333
+ await new Promise((resolve) =>
2334
+ setTimeout(
2335
+ resolve,
2336
+ errorJson.error === 'slow_down'
2337
+ ? pollInterval * 1.5
2338
+ : pollInterval
2339
+ )
2340
+ );
2341
+ continue;
2342
+ }
2343
+ } catch {
2344
+ // JSON parse failed, treat as regular error
2345
+ }
2346
+
2347
+ log.error(() => ({
2348
+ message: 'qwen oauth token poll failed',
2349
+ status: tokenResponse.status,
2350
+ error: errorText,
2351
+ }));
2352
+ return { type: 'failed' };
2353
+ }
2354
+
2355
+ const tokenData = (await tokenResponse.json()) as {
2356
+ access_token: string;
2357
+ refresh_token?: string;
2358
+ token_type: string;
2359
+ expires_in: number;
2360
+ resource_url?: string;
2361
+ };
2362
+
2363
+ return {
2364
+ type: 'success',
2365
+ refresh: tokenData.refresh_token || '',
2366
+ access: tokenData.access_token,
2367
+ expires: Date.now() + tokenData.expires_in * 1000,
2368
+ };
2369
+ }
2370
+
2371
+ log.error(() => ({
2372
+ message: 'qwen oauth authorization timeout',
2373
+ }));
2374
+ return { type: 'failed' };
2375
+ },
2376
+ };
2377
+ },
2378
+ },
2379
+ ],
2380
+ async loader(getAuth, provider) {
2381
+ const auth = await getAuth();
2382
+ if (!auth || auth.type !== 'oauth') return {};
2383
+
2384
+ // Zero out cost for subscription users (free tier)
2385
+ if (provider?.models) {
2386
+ for (const model of Object.values(provider.models)) {
2387
+ (model as any).cost = {
2388
+ input: 0,
2389
+ output: 0,
2390
+ cache: {
2391
+ read: 0,
2392
+ write: 0,
2393
+ },
2394
+ };
2395
+ }
2396
+ }
2397
+
2398
+ return {
2399
+ apiKey: 'oauth-token-used-via-custom-fetch',
2400
+ baseURL: QWEN_OAUTH_DEFAULT_API_URL,
2401
+ async fetch(input: RequestInfo | URL, init?: RequestInit) {
2402
+ let currentAuth = await getAuth();
2403
+ if (!currentAuth || currentAuth.type !== 'oauth')
2404
+ return fetch(input, init);
2405
+
2406
+ // Refresh token if expired (with 5 minute buffer)
2407
+ const FIVE_MIN_MS = 5 * 60 * 1000;
2408
+ if (
2409
+ !currentAuth.access ||
2410
+ currentAuth.expires < Date.now() + FIVE_MIN_MS
2411
+ ) {
2412
+ if (!currentAuth.refresh) {
2413
+ log.error(() => ({
2414
+ message:
2415
+ 'qwen oauth token expired and no refresh token available',
2416
+ }));
2417
+ throw new Error(
2418
+ 'Qwen OAuth token expired. Please re-authenticate with: agent auth login'
2419
+ );
2420
+ }
2421
+
2422
+ log.info(() => ({
2423
+ message: 'refreshing qwen oauth token',
2424
+ reason: !currentAuth.access
2425
+ ? 'no access token'
2426
+ : 'token expiring soon',
2427
+ }));
2428
+
2429
+ const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
2430
+ method: 'POST',
2431
+ headers: {
2432
+ 'Content-Type': 'application/x-www-form-urlencoded',
2433
+ Accept: 'application/json',
2434
+ },
2435
+ body: new URLSearchParams({
2436
+ grant_type: 'refresh_token',
2437
+ refresh_token: currentAuth.refresh,
2438
+ client_id: QWEN_OAUTH_CLIENT_ID,
2439
+ }),
2440
+ });
2441
+
2442
+ if (!response.ok) {
2443
+ const errorText = await response.text().catch(() => 'unknown');
2444
+ log.error(() => ({
2445
+ message: 'qwen oauth token refresh failed',
2446
+ status: response.status,
2447
+ error: errorText.substring(0, 200),
2448
+ }));
2449
+ throw new Error(
2450
+ `Qwen token refresh failed: ${response.status}. Please re-authenticate with: agent auth login`
2451
+ );
2452
+ }
2453
+
2454
+ const json = await response.json();
2455
+ log.info(() => ({
2456
+ message: 'qwen oauth token refreshed successfully',
2457
+ expiresIn: json.expires_in,
2458
+ }));
2459
+
2460
+ await Auth.set('qwen-coder', {
2461
+ type: 'oauth',
2462
+ refresh: json.refresh_token || currentAuth.refresh,
2463
+ access: json.access_token,
2464
+ expires: Date.now() + json.expires_in * 1000,
2465
+ });
2466
+ currentAuth = {
2467
+ type: 'oauth',
2468
+ refresh: json.refresh_token || currentAuth.refresh,
2469
+ access: json.access_token,
2470
+ expires: Date.now() + json.expires_in * 1000,
2471
+ };
2472
+ }
2473
+
2474
+ const headers: Record<string, string> = {
2475
+ ...(init?.headers as Record<string, string>),
2476
+ Authorization: `Bearer ${currentAuth.access}`,
2477
+ };
2478
+ delete headers['x-api-key'];
2479
+
2480
+ return fetch(input, {
2481
+ ...init,
2482
+ headers,
2483
+ });
2484
+ },
2485
+ };
2486
+ },
2487
+ };
2488
+
2489
+ /**
2490
+ * Alibaba Plugin (alias for Qwen Coder)
2491
+ * This provides a separate menu entry for Alibaba
2492
+ * with the same Qwen Coder subscription authentication.
2493
+ */
2494
+ const AlibabaPlugin: AuthPlugin = {
2495
+ provider: 'alibaba',
2496
+ methods: [
2497
+ {
2498
+ label: 'Qwen Coder Subscription (OAuth)',
2499
+ type: 'oauth',
2500
+ async authorize() {
2501
+ // Delegate to QwenPlugin's OAuth method
2502
+ const qwenMethod = QwenPlugin.methods[0];
2503
+ if (qwenMethod?.authorize) {
2504
+ const result = await qwenMethod.authorize({});
2505
+ // Override the callback to save as alibaba provider
2506
+ if ('callback' in result) {
2507
+ const originalCallback = result.callback;
2508
+ return {
2509
+ ...result,
2510
+ async callback(code?: string): Promise<AuthResult> {
2511
+ const authResult = await originalCallback(code);
2512
+ if (authResult.type === 'success' && 'refresh' in authResult) {
2513
+ return {
2514
+ ...authResult,
2515
+ provider: 'alibaba',
2516
+ };
2517
+ }
2518
+ return authResult;
2519
+ },
2520
+ };
2521
+ }
2522
+ }
2523
+ return {
2524
+ method: 'auto' as const,
2525
+ async callback(): Promise<AuthResult> {
2526
+ return { type: 'failed' };
2527
+ },
2528
+ };
2529
+ },
2530
+ },
2531
+ ],
2532
+ async loader(getAuth, provider) {
2533
+ const auth = await getAuth();
2534
+ if (!auth || auth.type !== 'oauth') return {};
2535
+
2536
+ // Zero out cost for subscription users (free tier)
2537
+ if (provider?.models) {
2538
+ for (const model of Object.values(provider.models)) {
2539
+ (model as any).cost = {
2540
+ input: 0,
2541
+ output: 0,
2542
+ cache: {
2543
+ read: 0,
2544
+ write: 0,
2545
+ },
2546
+ };
2547
+ }
2548
+ }
2549
+
2550
+ return {
2551
+ apiKey: 'oauth-token-used-via-custom-fetch',
2552
+ baseURL: QWEN_OAUTH_DEFAULT_API_URL,
2553
+ async fetch(input: RequestInfo | URL, init?: RequestInit) {
2554
+ let currentAuth = await getAuth();
2555
+ if (!currentAuth || currentAuth.type !== 'oauth')
2556
+ return fetch(input, init);
2557
+
2558
+ // Refresh token if expired (with 5 minute buffer)
2559
+ const FIVE_MIN_MS = 5 * 60 * 1000;
2560
+ if (
2561
+ !currentAuth.access ||
2562
+ currentAuth.expires < Date.now() + FIVE_MIN_MS
2563
+ ) {
2564
+ if (!currentAuth.refresh) {
2565
+ log.error(() => ({
2566
+ message:
2567
+ 'qwen oauth token expired and no refresh token available (alibaba)',
2568
+ }));
2569
+ throw new Error(
2570
+ 'Qwen OAuth token expired. Please re-authenticate with: agent auth login'
2571
+ );
2572
+ }
2573
+
2574
+ log.info(() => ({
2575
+ message: 'refreshing qwen oauth token (alibaba provider)',
2576
+ }));
2577
+
2578
+ const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
2579
+ method: 'POST',
2580
+ headers: {
2581
+ 'Content-Type': 'application/x-www-form-urlencoded',
2582
+ Accept: 'application/json',
2583
+ },
2584
+ body: new URLSearchParams({
2585
+ grant_type: 'refresh_token',
2586
+ refresh_token: currentAuth.refresh,
2587
+ client_id: QWEN_OAUTH_CLIENT_ID,
2588
+ }),
2589
+ });
2590
+
2591
+ if (!response.ok) {
2592
+ const errorText = await response.text().catch(() => 'unknown');
2593
+ log.error(() => ({
2594
+ message: 'qwen oauth token refresh failed (alibaba)',
2595
+ status: response.status,
2596
+ error: errorText.substring(0, 200),
2597
+ }));
2598
+ throw new Error(
2599
+ `Qwen token refresh failed: ${response.status}. Please re-authenticate with: agent auth login`
2600
+ );
2601
+ }
2602
+
2603
+ const json = await response.json();
2604
+ await Auth.set('alibaba', {
2605
+ type: 'oauth',
2606
+ refresh: json.refresh_token || currentAuth.refresh,
2607
+ access: json.access_token,
2608
+ expires: Date.now() + json.expires_in * 1000,
2609
+ });
2610
+ currentAuth = {
2611
+ type: 'oauth',
2612
+ refresh: json.refresh_token || currentAuth.refresh,
2613
+ access: json.access_token,
2614
+ expires: Date.now() + json.expires_in * 1000,
2615
+ };
2616
+ }
2617
+
2618
+ const headers: Record<string, string> = {
2619
+ ...(init?.headers as Record<string, string>),
2620
+ Authorization: `Bearer ${currentAuth.access}`,
2621
+ };
2622
+ delete headers['x-api-key'];
2623
+
2624
+ return fetch(input, {
2625
+ ...init,
2626
+ headers,
2627
+ });
2628
+ },
2629
+ };
2630
+ },
2631
+ };
2632
+
2184
2633
  /**
2185
2634
  * Registry of all auth plugins
2186
2635
  */
@@ -2189,6 +2638,8 @@ const plugins: Record<string, AuthPlugin> = {
2189
2638
  'github-copilot': GitHubCopilotPlugin,
2190
2639
  openai: OpenAIPlugin,
2191
2640
  google: GooglePlugin,
2641
+ 'qwen-coder': QwenPlugin,
2642
+ alibaba: AlibabaPlugin,
2192
2643
  };
2193
2644
 
2194
2645
  /**
package/src/cli/output.ts CHANGED
@@ -199,5 +199,24 @@ export function outputInput(
199
199
  writeStdout(message, compact);
200
200
  }
201
201
 
202
+ /**
203
+ * Output a help/informational message to stdout
204
+ * Used for CLI help text display (not an error condition)
205
+ */
206
+ export function outputHelp(
207
+ help: {
208
+ message: string;
209
+ hint?: string;
210
+ [key: string]: unknown;
211
+ },
212
+ compact?: boolean
213
+ ): void {
214
+ const message: OutputMessage = {
215
+ type: 'status',
216
+ ...help,
217
+ };
218
+ writeStdout(message, compact);
219
+ }
220
+
202
221
  // Re-export for backward compatibility
203
222
  export { output as write };
package/src/index.js CHANGED
@@ -31,9 +31,11 @@ import { createBusEventSubscription } from './cli/event-handler.js';
31
31
  import {
32
32
  outputStatus,
33
33
  outputError,
34
+ outputHelp,
34
35
  setCompactJson,
35
36
  outputInput,
36
37
  } from './cli/output.ts';
38
+ import stripAnsi from 'strip-ansi';
37
39
  import { createRequire } from 'module';
38
40
  import { readFileSync } from 'fs';
39
41
  import { dirname, join } from 'path';
@@ -899,14 +901,14 @@ async function main() {
899
901
  process.exit(1);
900
902
  }
901
903
 
902
- // Handle validation errors (msg without err)
904
+ // Handle validation messages (msg without err) - informational, not an error
905
+ // Display help text on stdout (industry standard: git, gh, npm all use stdout for help)
903
906
  if (msg) {
904
- outputError({
905
- errorType: 'ValidationError',
907
+ outputHelp({
906
908
  message: msg,
907
- hint: yargs.help(),
909
+ hint: stripAnsi(yargs.help()),
908
910
  });
909
- process.exit(1);
911
+ process.exit(0);
910
912
  }
911
913
  })
912
914
  .help();
@@ -321,6 +321,66 @@ export namespace Provider {
321
321
  options: {},
322
322
  };
323
323
  },
324
+ /**
325
+ * Qwen Coder OAuth provider for Qwen subscription users
326
+ * Uses OAuth credentials from agent auth login (Qwen Coder Subscription)
327
+ *
328
+ * To authenticate, run: agent auth login (select Qwen Coder)
329
+ */
330
+ 'qwen-coder': async (input) => {
331
+ const auth = await Auth.get('qwen-coder');
332
+ if (auth?.type === 'oauth') {
333
+ log.info(() => ({
334
+ message: 'using qwen-coder oauth credentials',
335
+ }));
336
+ const loaderFn = await AuthPlugins.getLoader('qwen-coder');
337
+ if (loaderFn) {
338
+ const result = await loaderFn(() => Auth.get('qwen-coder'), input);
339
+ if (result.fetch) {
340
+ return {
341
+ autoload: true,
342
+ options: {
343
+ apiKey: result.apiKey || '',
344
+ baseURL: result.baseURL,
345
+ fetch: result.fetch,
346
+ },
347
+ };
348
+ }
349
+ }
350
+ }
351
+ // Default: not auto-loaded without OAuth
352
+ return { autoload: false };
353
+ },
354
+ /**
355
+ * Alibaba OAuth provider (alias for Qwen Coder)
356
+ * Uses OAuth credentials from agent auth login (Alibaba / Qwen Coder Subscription)
357
+ *
358
+ * To authenticate, run: agent auth login (select Alibaba)
359
+ */
360
+ alibaba: async (input) => {
361
+ const auth = await Auth.get('alibaba');
362
+ if (auth?.type === 'oauth') {
363
+ log.info(() => ({
364
+ message: 'using alibaba oauth credentials',
365
+ }));
366
+ const loaderFn = await AuthPlugins.getLoader('alibaba');
367
+ if (loaderFn) {
368
+ const result = await loaderFn(() => Auth.get('alibaba'), input);
369
+ if (result.fetch) {
370
+ return {
371
+ autoload: true,
372
+ options: {
373
+ apiKey: result.apiKey || '',
374
+ baseURL: result.baseURL,
375
+ fetch: result.fetch,
376
+ },
377
+ };
378
+ }
379
+ }
380
+ }
381
+ // Default: not auto-loaded without OAuth
382
+ return { autoload: false };
383
+ },
324
384
  /**
325
385
  * Google OAuth provider for Gemini subscription users
326
386
  * Uses OAuth credentials from agent auth login (Google AI Pro/Ultra)