@link-assistant/agent 0.0.9 → 0.0.12

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.
Files changed (104) hide show
  1. package/EXAMPLES.md +36 -0
  2. package/MODELS.md +72 -24
  3. package/README.md +59 -2
  4. package/TOOLS.md +20 -0
  5. package/package.json +35 -2
  6. package/src/agent/agent.ts +68 -54
  7. package/src/auth/claude-oauth.ts +426 -0
  8. package/src/auth/index.ts +28 -26
  9. package/src/auth/plugins.ts +876 -0
  10. package/src/bun/index.ts +53 -43
  11. package/src/bus/global.ts +5 -5
  12. package/src/bus/index.ts +59 -53
  13. package/src/cli/bootstrap.js +12 -12
  14. package/src/cli/bootstrap.ts +6 -6
  15. package/src/cli/cmd/agent.ts +97 -92
  16. package/src/cli/cmd/auth.ts +469 -0
  17. package/src/cli/cmd/cmd.ts +2 -2
  18. package/src/cli/cmd/export.ts +41 -41
  19. package/src/cli/cmd/mcp.ts +144 -119
  20. package/src/cli/cmd/models.ts +30 -29
  21. package/src/cli/cmd/run.ts +269 -213
  22. package/src/cli/cmd/stats.ts +185 -146
  23. package/src/cli/error.ts +17 -13
  24. package/src/cli/ui.ts +39 -24
  25. package/src/command/index.ts +26 -26
  26. package/src/config/config.ts +528 -288
  27. package/src/config/markdown.ts +15 -15
  28. package/src/file/ripgrep.ts +201 -169
  29. package/src/file/time.ts +21 -18
  30. package/src/file/watcher.ts +51 -42
  31. package/src/file.ts +1 -1
  32. package/src/flag/flag.ts +26 -11
  33. package/src/format/formatter.ts +206 -162
  34. package/src/format/index.ts +61 -61
  35. package/src/global/index.ts +21 -21
  36. package/src/id/id.ts +47 -33
  37. package/src/index.js +346 -199
  38. package/src/json-standard/index.ts +67 -51
  39. package/src/mcp/index.ts +135 -128
  40. package/src/patch/index.ts +336 -267
  41. package/src/project/bootstrap.ts +15 -15
  42. package/src/project/instance.ts +43 -36
  43. package/src/project/project.ts +47 -47
  44. package/src/project/state.ts +37 -33
  45. package/src/provider/models-macro.ts +5 -5
  46. package/src/provider/models.ts +32 -32
  47. package/src/provider/opencode.js +19 -19
  48. package/src/provider/provider.ts +518 -277
  49. package/src/provider/transform.ts +143 -102
  50. package/src/server/project.ts +21 -21
  51. package/src/server/server.ts +111 -105
  52. package/src/session/agent.js +66 -60
  53. package/src/session/compaction.ts +136 -111
  54. package/src/session/index.ts +189 -156
  55. package/src/session/message-v2.ts +312 -268
  56. package/src/session/message.ts +73 -57
  57. package/src/session/processor.ts +180 -166
  58. package/src/session/prompt.ts +678 -533
  59. package/src/session/retry.ts +26 -23
  60. package/src/session/revert.ts +76 -62
  61. package/src/session/status.ts +26 -26
  62. package/src/session/summary.ts +97 -76
  63. package/src/session/system.ts +77 -63
  64. package/src/session/todo.ts +22 -16
  65. package/src/snapshot/index.ts +92 -76
  66. package/src/storage/storage.ts +157 -120
  67. package/src/tool/bash.ts +116 -106
  68. package/src/tool/batch.ts +73 -59
  69. package/src/tool/codesearch.ts +60 -53
  70. package/src/tool/edit.ts +319 -263
  71. package/src/tool/glob.ts +32 -28
  72. package/src/tool/grep.ts +72 -53
  73. package/src/tool/invalid.ts +7 -7
  74. package/src/tool/ls.ts +77 -64
  75. package/src/tool/multiedit.ts +30 -21
  76. package/src/tool/patch.ts +121 -94
  77. package/src/tool/read.ts +140 -122
  78. package/src/tool/registry.ts +38 -38
  79. package/src/tool/task.ts +93 -60
  80. package/src/tool/todo.ts +16 -16
  81. package/src/tool/tool.ts +45 -36
  82. package/src/tool/webfetch.ts +97 -74
  83. package/src/tool/websearch.ts +78 -64
  84. package/src/tool/write.ts +21 -15
  85. package/src/util/binary.ts +27 -19
  86. package/src/util/context.ts +8 -8
  87. package/src/util/defer.ts +7 -5
  88. package/src/util/error.ts +24 -19
  89. package/src/util/eventloop.ts +16 -10
  90. package/src/util/filesystem.ts +37 -33
  91. package/src/util/fn.ts +11 -8
  92. package/src/util/iife.ts +1 -1
  93. package/src/util/keybind.ts +44 -44
  94. package/src/util/lazy.ts +7 -7
  95. package/src/util/locale.ts +20 -16
  96. package/src/util/lock.ts +43 -38
  97. package/src/util/log.ts +95 -85
  98. package/src/util/queue.ts +8 -8
  99. package/src/util/rpc.ts +35 -23
  100. package/src/util/scrap.ts +4 -4
  101. package/src/util/signal.ts +5 -5
  102. package/src/util/timeout.ts +6 -6
  103. package/src/util/token.ts +2 -2
  104. package/src/util/wildcard.ts +38 -27
@@ -0,0 +1,426 @@
1
+ import crypto from 'crypto';
2
+ import path from 'path';
3
+ import { Global } from '../global';
4
+ import { Log } from '../util/log';
5
+ import z from 'zod';
6
+
7
+ /**
8
+ * Claude OAuth Module
9
+ *
10
+ * Implements OAuth 2.0 with PKCE for Claude authentication.
11
+ * This allows using Claude Pro/Max subscription for API access.
12
+ *
13
+ * OAuth Flow:
14
+ * 1. Generate PKCE code verifier and challenge
15
+ * 2. Open browser to authorization URL
16
+ * 3. User authenticates and authorizes
17
+ * 4. Receive authorization code via redirect
18
+ * 5. Exchange code for tokens
19
+ * 6. Store tokens in ~/.claude/.credentials.json
20
+ *
21
+ * References:
22
+ * - Claude Code CLI uses the same OAuth flow
23
+ * - https://github.com/grll/claude-code-login for implementation details
24
+ */
25
+ export namespace ClaudeOAuth {
26
+ const log = Log.create({ service: 'claude-oauth' });
27
+
28
+ /**
29
+ * OAuth Configuration
30
+ * These values are from the Claude Code CLI OAuth implementation
31
+ */
32
+ export const Config = {
33
+ // OAuth endpoints
34
+ authorizationUrl: 'https://claude.ai/oauth/authorize',
35
+ tokenUrl: 'https://console.anthropic.com/v1/oauth/token',
36
+ redirectUri: 'https://console.anthropic.com/oauth/code/callback',
37
+
38
+ // OAuth client - this is the same client ID used by Claude Code CLI
39
+ clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
40
+
41
+ // Requested scopes for API access
42
+ scopes: ['org:create_api_key', 'user:profile', 'user:inference'],
43
+
44
+ // API beta header for OAuth authentication
45
+ betaHeader: 'oauth-2025-04-20',
46
+ } as const;
47
+
48
+ /**
49
+ * Schema for OAuth credentials stored in ~/.claude/.credentials.json
50
+ */
51
+ export const Credentials = z.object({
52
+ claudeAiOauth: z
53
+ .object({
54
+ accessToken: z.string(),
55
+ refreshToken: z.string(),
56
+ expiresAt: z.number(),
57
+ scopes: z.array(z.string()).optional(),
58
+ subscriptionType: z.string().optional(),
59
+ rateLimitTier: z.string().optional(),
60
+ })
61
+ .optional(),
62
+ });
63
+
64
+ export type Credentials = z.infer<typeof Credentials>;
65
+
66
+ /**
67
+ * Schema for OAuth state file (temporary, during auth flow)
68
+ */
69
+ export const OAuthState = z.object({
70
+ state: z.string(),
71
+ codeVerifier: z.string(),
72
+ expiresAt: z.number(),
73
+ });
74
+
75
+ export type OAuthState = z.infer<typeof OAuthState>;
76
+
77
+ /**
78
+ * Schema for token response from OAuth server
79
+ */
80
+ export const TokenResponse = z.object({
81
+ access_token: z.string(),
82
+ refresh_token: z.string(),
83
+ expires_in: z.number(),
84
+ token_type: z.string(),
85
+ scope: z.string().optional(),
86
+ });
87
+
88
+ export type TokenResponse = z.infer<typeof TokenResponse>;
89
+
90
+ /**
91
+ * Paths for credential and state storage
92
+ */
93
+ const claudeDir = path.join(Global.Path.home, '.claude');
94
+ const credentialsPath = path.join(claudeDir, '.credentials.json');
95
+ const statePath = path.join(claudeDir, '.oauth_state.json');
96
+
97
+ /**
98
+ * Generate a cryptographically secure random string
99
+ */
100
+ function generateRandomString(length: number): string {
101
+ return crypto.randomBytes(length).toString('base64url');
102
+ }
103
+
104
+ /**
105
+ * Generate PKCE code challenge from code verifier
106
+ */
107
+ function generateCodeChallenge(verifier: string): string {
108
+ return crypto.createHash('sha256').update(verifier).digest('base64url');
109
+ }
110
+
111
+ /**
112
+ * Generate authorization URL with PKCE parameters
113
+ *
114
+ * @returns Object containing the authorization URL and state data to save
115
+ */
116
+ export function generateAuthUrl(): { url: string; state: OAuthState } {
117
+ // Generate PKCE values
118
+ const codeVerifier = generateRandomString(32);
119
+ const codeChallenge = generateCodeChallenge(codeVerifier);
120
+ const state = generateRandomString(32);
121
+
122
+ // Build authorization URL
123
+ const params = new URLSearchParams({
124
+ client_id: Config.clientId,
125
+ redirect_uri: Config.redirectUri,
126
+ response_type: 'code',
127
+ scope: Config.scopes.join(' '),
128
+ state,
129
+ code_challenge: codeChallenge,
130
+ code_challenge_method: 'S256',
131
+ });
132
+
133
+ const url = `${Config.authorizationUrl}?${params.toString()}`;
134
+
135
+ // State expires in 10 minutes
136
+ const oauthState: OAuthState = {
137
+ state,
138
+ codeVerifier,
139
+ expiresAt: Date.now() + 10 * 60 * 1000,
140
+ };
141
+
142
+ return { url, state: oauthState };
143
+ }
144
+
145
+ /**
146
+ * Save OAuth state for later code exchange
147
+ */
148
+ export async function saveState(state: OAuthState): Promise<void> {
149
+ await Bun.write(statePath, JSON.stringify(state, null, 2));
150
+ log.info('saved oauth state', {
151
+ expiresAt: new Date(state.expiresAt).toISOString(),
152
+ });
153
+ }
154
+
155
+ /**
156
+ * Load saved OAuth state
157
+ */
158
+ export async function loadState(): Promise<OAuthState | undefined> {
159
+ try {
160
+ const file = Bun.file(statePath);
161
+ if (!(await file.exists())) {
162
+ return undefined;
163
+ }
164
+ const content = await file.json();
165
+ const parsed = OAuthState.parse(content);
166
+
167
+ if (parsed.expiresAt < Date.now()) {
168
+ log.warn('oauth state expired');
169
+ await clearState();
170
+ return undefined;
171
+ }
172
+
173
+ return parsed;
174
+ } catch (error) {
175
+ log.error('failed to load oauth state', { error });
176
+ return undefined;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Clear saved OAuth state
182
+ */
183
+ export async function clearState(): Promise<void> {
184
+ try {
185
+ const file = Bun.file(statePath);
186
+ if (await file.exists()) {
187
+ await Bun.write(statePath, '');
188
+ // Delete the file
189
+ const fs = await import('fs/promises');
190
+ await fs.unlink(statePath).catch(() => {});
191
+ }
192
+ } catch (error) {
193
+ log.error('failed to clear oauth state', { error });
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Exchange authorization code for tokens
199
+ *
200
+ * @param code - Authorization code from OAuth callback
201
+ * @param codeVerifier - PKCE code verifier
202
+ * @returns Token response from OAuth server
203
+ */
204
+ export async function exchangeCode(
205
+ code: string,
206
+ codeVerifier: string
207
+ ): Promise<TokenResponse> {
208
+ const body = new URLSearchParams({
209
+ grant_type: 'authorization_code',
210
+ client_id: Config.clientId,
211
+ redirect_uri: Config.redirectUri,
212
+ code,
213
+ code_verifier: codeVerifier,
214
+ });
215
+
216
+ log.info('exchanging authorization code for tokens');
217
+
218
+ const response = await fetch(Config.tokenUrl, {
219
+ method: 'POST',
220
+ headers: {
221
+ 'Content-Type': 'application/x-www-form-urlencoded',
222
+ },
223
+ body: body.toString(),
224
+ });
225
+
226
+ if (!response.ok) {
227
+ const error = await response.text();
228
+ log.error('token exchange failed', { status: response.status, error });
229
+ throw new Error(`Token exchange failed: ${response.status} ${error}`);
230
+ }
231
+
232
+ const data = await response.json();
233
+ return TokenResponse.parse(data);
234
+ }
235
+
236
+ /**
237
+ * Save credentials to ~/.claude/.credentials.json
238
+ *
239
+ * @param tokens - Token response from OAuth server
240
+ */
241
+ export async function saveCredentials(tokens: TokenResponse): Promise<void> {
242
+ // Ensure .claude directory exists
243
+ const fs = await import('fs/promises');
244
+ await fs.mkdir(claudeDir, { recursive: true });
245
+
246
+ // Load existing credentials if any
247
+ let existing: Credentials = {};
248
+ try {
249
+ const file = Bun.file(credentialsPath);
250
+ if (await file.exists()) {
251
+ existing = await file.json();
252
+ }
253
+ } catch {
254
+ // Ignore errors reading existing credentials
255
+ }
256
+
257
+ // Update with new OAuth credentials
258
+ const credentials: Credentials = {
259
+ ...existing,
260
+ claudeAiOauth: {
261
+ accessToken: tokens.access_token,
262
+ refreshToken: tokens.refresh_token,
263
+ expiresAt: Date.now() + tokens.expires_in * 1000,
264
+ scopes: tokens.scope?.split(' '),
265
+ subscriptionType: 'unknown', // Will be populated from API response
266
+ },
267
+ };
268
+
269
+ await Bun.write(credentialsPath, JSON.stringify(credentials, null, 2));
270
+ log.info('saved credentials', {
271
+ expiresAt: new Date(credentials.claudeAiOauth!.expiresAt).toISOString(),
272
+ });
273
+ }
274
+
275
+ /**
276
+ * Get stored OAuth credentials
277
+ *
278
+ * @returns OAuth credentials if available, undefined otherwise
279
+ */
280
+ export async function getCredentials(): Promise<
281
+ Credentials['claudeAiOauth'] | undefined
282
+ > {
283
+ try {
284
+ const file = Bun.file(credentialsPath);
285
+ if (!(await file.exists())) {
286
+ log.info('credentials file not found', { path: credentialsPath });
287
+ return undefined;
288
+ }
289
+
290
+ const content = await file.json();
291
+ const parsed = Credentials.parse(content);
292
+
293
+ if (!parsed.claudeAiOauth) {
294
+ log.info('no claudeAiOauth credentials found');
295
+ return undefined;
296
+ }
297
+
298
+ // Check if token is expired
299
+ if (parsed.claudeAiOauth.expiresAt < Date.now()) {
300
+ log.warn('token expired', {
301
+ expiresAt: new Date(parsed.claudeAiOauth.expiresAt).toISOString(),
302
+ });
303
+ // TODO: Implement token refresh using refreshToken
304
+ // For now, user needs to re-authenticate
305
+ }
306
+
307
+ log.info('loaded oauth credentials', {
308
+ subscriptionType: parsed.claudeAiOauth.subscriptionType,
309
+ scopes: parsed.claudeAiOauth.scopes,
310
+ });
311
+
312
+ return parsed.claudeAiOauth;
313
+ } catch (error) {
314
+ log.error('failed to read credentials', { error });
315
+ return undefined;
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Get access token for API requests
321
+ */
322
+ export async function getAccessToken(): Promise<string | undefined> {
323
+ const creds = await getCredentials();
324
+ return creds?.accessToken;
325
+ }
326
+
327
+ /**
328
+ * Check if OAuth credentials are available and valid
329
+ */
330
+ export async function isAuthenticated(): Promise<boolean> {
331
+ const creds = await getCredentials();
332
+ if (!creds?.accessToken) return false;
333
+
334
+ // Check if not expired (with 5 minute buffer)
335
+ return creds.expiresAt > Date.now() + 5 * 60 * 1000;
336
+ }
337
+
338
+ /**
339
+ * Complete the OAuth flow by exchanging the authorization code
340
+ *
341
+ * @param code - Authorization code from OAuth callback
342
+ * @returns Success status
343
+ */
344
+ export async function completeAuth(code: string): Promise<boolean> {
345
+ const state = await loadState();
346
+ if (!state) {
347
+ log.error('no oauth state found - please start login flow first');
348
+ return false;
349
+ }
350
+
351
+ try {
352
+ const tokens = await exchangeCode(code, state.codeVerifier);
353
+ await saveCredentials(tokens);
354
+ await clearState();
355
+ log.info('authentication completed successfully');
356
+ return true;
357
+ } catch (error) {
358
+ log.error('failed to complete authentication', { error });
359
+ await clearState();
360
+ return false;
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Refresh the access token using the refresh token
366
+ */
367
+ export async function refreshToken(): Promise<boolean> {
368
+ const creds = await getCredentials();
369
+ if (!creds?.refreshToken) {
370
+ log.error('no refresh token available');
371
+ return false;
372
+ }
373
+
374
+ const body = new URLSearchParams({
375
+ grant_type: 'refresh_token',
376
+ client_id: Config.clientId,
377
+ refresh_token: creds.refreshToken,
378
+ });
379
+
380
+ log.info('refreshing access token');
381
+
382
+ try {
383
+ const response = await fetch(Config.tokenUrl, {
384
+ method: 'POST',
385
+ headers: {
386
+ 'Content-Type': 'application/x-www-form-urlencoded',
387
+ },
388
+ body: body.toString(),
389
+ });
390
+
391
+ if (!response.ok) {
392
+ const error = await response.text();
393
+ log.error('token refresh failed', { status: response.status, error });
394
+ return false;
395
+ }
396
+
397
+ const tokens = TokenResponse.parse(await response.json());
398
+ await saveCredentials(tokens);
399
+ log.info('token refreshed successfully');
400
+ return true;
401
+ } catch (error) {
402
+ log.error('failed to refresh token', { error });
403
+ return false;
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Create a custom fetch function that uses OAuth Bearer token authentication
409
+ */
410
+ export function createAuthenticatedFetch(accessToken: string): typeof fetch {
411
+ return async (url, init) => {
412
+ const headers = new Headers(init?.headers);
413
+ // Remove x-api-key if present and add Authorization Bearer
414
+ headers.delete('x-api-key');
415
+ headers.set('Authorization', `Bearer ${accessToken}`);
416
+ // Add OAuth beta header
417
+ const existingBeta = headers.get('anthropic-beta');
418
+ if (existingBeta) {
419
+ headers.set('anthropic-beta', `${Config.betaHeader},${existingBeta}`);
420
+ } else {
421
+ headers.set('anthropic-beta', Config.betaHeader);
422
+ }
423
+ return fetch(url, { ...init, headers });
424
+ };
425
+ }
426
+ }
package/src/auth/index.ts CHANGED
@@ -1,64 +1,66 @@
1
- import path from "path"
2
- import { Global } from "../global"
3
- import fs from "fs/promises"
4
- import z from "zod"
1
+ import path from 'path';
2
+ import { Global } from '../global';
3
+ import fs from 'fs/promises';
4
+ import z from 'zod';
5
5
 
6
6
  export namespace Auth {
7
7
  export const Oauth = z
8
8
  .object({
9
- type: z.literal("oauth"),
9
+ type: z.literal('oauth'),
10
10
  refresh: z.string(),
11
11
  access: z.string(),
12
12
  expires: z.number(),
13
13
  enterpriseUrl: z.string().optional(),
14
14
  })
15
- .meta({ ref: "OAuth" })
15
+ .meta({ ref: 'OAuth' });
16
16
 
17
17
  export const Api = z
18
18
  .object({
19
- type: z.literal("api"),
19
+ type: z.literal('api'),
20
20
  key: z.string(),
21
21
  })
22
- .meta({ ref: "ApiAuth" })
22
+ .meta({ ref: 'ApiAuth' });
23
23
 
24
24
  export const WellKnown = z
25
25
  .object({
26
- type: z.literal("wellknown"),
26
+ type: z.literal('wellknown'),
27
27
  key: z.string(),
28
28
  token: z.string(),
29
29
  })
30
- .meta({ ref: "WellKnownAuth" })
30
+ .meta({ ref: 'WellKnownAuth' });
31
31
 
32
- export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" })
33
- export type Info = z.infer<typeof Info>
32
+ export const Info = z
33
+ .discriminatedUnion('type', [Oauth, Api, WellKnown])
34
+ .meta({ ref: 'Auth' });
35
+ export type Info = z.infer<typeof Info>;
34
36
 
35
- const filepath = path.join(Global.Path.data, "auth.json")
37
+ const filepath = path.join(Global.Path.data, 'auth.json');
36
38
 
37
39
  export async function get(providerID: string) {
38
- const file = Bun.file(filepath)
40
+ const file = Bun.file(filepath);
39
41
  return file
40
42
  .json()
41
43
  .catch(() => ({}))
42
- .then((x) => x[providerID] as Info | undefined)
44
+ .then((x) => x[providerID] as Info | undefined);
43
45
  }
44
46
 
45
47
  export async function all(): Promise<Record<string, Info>> {
46
- const file = Bun.file(filepath)
47
- return file.json().catch(() => ({}))
48
+ const file = Bun.file(filepath);
49
+ return file.json().catch(() => ({}));
48
50
  }
49
51
 
50
52
  export async function set(key: string, info: Info) {
51
- const file = Bun.file(filepath)
52
- const data = await all()
53
- await Bun.write(file, JSON.stringify({ ...data, [key]: info }, null, 2))
54
- await fs.chmod(file.name!, 0o600)
53
+ const file = Bun.file(filepath);
54
+ const data = await all();
55
+ await Bun.write(file, JSON.stringify({ ...data, [key]: info }, null, 2));
56
+ await fs.chmod(file.name!, 0o600);
55
57
  }
56
58
 
57
59
  export async function remove(key: string) {
58
- const file = Bun.file(filepath)
59
- const data = await all()
60
- delete data[key]
61
- await Bun.write(file, JSON.stringify(data, null, 2))
62
- await fs.chmod(file.name!, 0o600)
60
+ const file = Bun.file(filepath);
61
+ const data = await all();
62
+ delete data[key];
63
+ await Bun.write(file, JSON.stringify(data, null, 2));
64
+ await fs.chmod(file.name!, 0o600);
63
65
  }
64
66
  }