@link-assistant/agent 0.0.8 → 0.0.11
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/EXAMPLES.md +80 -1
- package/MODELS.md +72 -24
- package/README.md +95 -2
- package/TOOLS.md +20 -0
- package/package.json +36 -2
- package/src/agent/agent.ts +68 -54
- package/src/auth/claude-oauth.ts +426 -0
- package/src/auth/index.ts +28 -26
- package/src/auth/plugins.ts +876 -0
- package/src/bun/index.ts +53 -43
- package/src/bus/global.ts +5 -5
- package/src/bus/index.ts +59 -53
- package/src/cli/bootstrap.js +12 -12
- package/src/cli/bootstrap.ts +6 -6
- package/src/cli/cmd/agent.ts +97 -92
- package/src/cli/cmd/auth.ts +468 -0
- package/src/cli/cmd/cmd.ts +2 -2
- package/src/cli/cmd/export.ts +41 -41
- package/src/cli/cmd/mcp.ts +210 -53
- package/src/cli/cmd/models.ts +30 -29
- package/src/cli/cmd/run.ts +269 -213
- package/src/cli/cmd/stats.ts +185 -146
- package/src/cli/error.ts +17 -13
- package/src/cli/ui.ts +78 -0
- package/src/command/index.ts +26 -26
- package/src/config/config.ts +528 -288
- package/src/config/markdown.ts +15 -15
- package/src/file/ripgrep.ts +201 -169
- package/src/file/time.ts +21 -18
- package/src/file/watcher.ts +51 -42
- package/src/file.ts +1 -1
- package/src/flag/flag.ts +26 -11
- package/src/format/formatter.ts +206 -162
- package/src/format/index.ts +61 -61
- package/src/global/index.ts +21 -21
- package/src/id/id.ts +47 -33
- package/src/index.js +554 -332
- package/src/json-standard/index.ts +173 -0
- package/src/mcp/index.ts +135 -128
- package/src/patch/index.ts +336 -267
- package/src/project/bootstrap.ts +15 -15
- package/src/project/instance.ts +43 -36
- package/src/project/project.ts +47 -47
- package/src/project/state.ts +37 -33
- package/src/provider/models-macro.ts +5 -5
- package/src/provider/models.ts +32 -32
- package/src/provider/opencode.js +19 -19
- package/src/provider/provider.ts +518 -277
- package/src/provider/transform.ts +143 -102
- package/src/server/project.ts +21 -21
- package/src/server/server.ts +111 -105
- package/src/session/agent.js +66 -60
- package/src/session/compaction.ts +136 -111
- package/src/session/index.ts +189 -156
- package/src/session/message-v2.ts +312 -268
- package/src/session/message.ts +73 -57
- package/src/session/processor.ts +180 -166
- package/src/session/prompt.ts +678 -533
- package/src/session/retry.ts +26 -23
- package/src/session/revert.ts +76 -62
- package/src/session/status.ts +26 -26
- package/src/session/summary.ts +97 -76
- package/src/session/system.ts +77 -63
- package/src/session/todo.ts +22 -16
- package/src/snapshot/index.ts +92 -76
- package/src/storage/storage.ts +157 -120
- package/src/tool/bash.ts +116 -106
- package/src/tool/batch.ts +73 -59
- package/src/tool/codesearch.ts +60 -53
- package/src/tool/edit.ts +319 -263
- package/src/tool/glob.ts +32 -28
- package/src/tool/grep.ts +72 -53
- package/src/tool/invalid.ts +7 -7
- package/src/tool/ls.ts +77 -64
- package/src/tool/multiedit.ts +30 -21
- package/src/tool/patch.ts +121 -94
- package/src/tool/read.ts +140 -122
- package/src/tool/registry.ts +38 -38
- package/src/tool/task.ts +93 -60
- package/src/tool/todo.ts +16 -16
- package/src/tool/tool.ts +45 -36
- package/src/tool/webfetch.ts +97 -74
- package/src/tool/websearch.ts +78 -64
- package/src/tool/write.ts +21 -15
- package/src/util/binary.ts +27 -19
- package/src/util/context.ts +8 -8
- package/src/util/defer.ts +7 -5
- package/src/util/error.ts +24 -19
- package/src/util/eventloop.ts +16 -10
- package/src/util/filesystem.ts +37 -33
- package/src/util/fn.ts +11 -8
- package/src/util/iife.ts +1 -1
- package/src/util/keybind.ts +44 -44
- package/src/util/lazy.ts +7 -7
- package/src/util/locale.ts +20 -16
- package/src/util/lock.ts +43 -38
- package/src/util/log.ts +95 -85
- package/src/util/queue.ts +8 -8
- package/src/util/rpc.ts +35 -23
- package/src/util/scrap.ts +4 -4
- package/src/util/signal.ts +5 -5
- package/src/util/timeout.ts +6 -6
- package/src/util/token.ts +2 -2
- package/src/util/wildcard.ts +38 -27
package/src/agent/agent.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
import { Config } from
|
|
2
|
-
import z from
|
|
3
|
-
import { Provider } from
|
|
4
|
-
import { generateObject, type ModelMessage } from
|
|
5
|
-
import PROMPT_GENERATE from
|
|
6
|
-
import { SystemPrompt } from
|
|
7
|
-
import { Instance } from
|
|
8
|
-
import { mergeDeep } from
|
|
1
|
+
import { Config } from '../config/config';
|
|
2
|
+
import z from 'zod';
|
|
3
|
+
import { Provider } from '../provider/provider';
|
|
4
|
+
import { generateObject, type ModelMessage } from 'ai';
|
|
5
|
+
import PROMPT_GENERATE from './generate.txt';
|
|
6
|
+
import { SystemPrompt } from '../session/system';
|
|
7
|
+
import { Instance } from '../project/instance';
|
|
8
|
+
import { mergeDeep } from 'remeda';
|
|
9
9
|
|
|
10
10
|
export namespace Agent {
|
|
11
11
|
export const Info = z
|
|
12
12
|
.object({
|
|
13
13
|
name: z.string(),
|
|
14
14
|
description: z.string().optional(),
|
|
15
|
-
mode: z.enum([
|
|
15
|
+
mode: z.enum(['subagent', 'primary', 'all']),
|
|
16
16
|
builtIn: z.boolean(),
|
|
17
17
|
topP: z.number().optional(),
|
|
18
18
|
temperature: z.number().optional(),
|
|
@@ -28,112 +28,126 @@ export namespace Agent {
|
|
|
28
28
|
options: z.record(z.string(), z.any()),
|
|
29
29
|
})
|
|
30
30
|
.meta({
|
|
31
|
-
ref:
|
|
32
|
-
})
|
|
33
|
-
export type Info = z.infer<typeof Info
|
|
31
|
+
ref: 'Agent',
|
|
32
|
+
});
|
|
33
|
+
export type Info = z.infer<typeof Info>;
|
|
34
34
|
|
|
35
35
|
const state = Instance.state(async () => {
|
|
36
|
-
const cfg = await Config.get()
|
|
37
|
-
const defaultTools = cfg.tools ?? {}
|
|
36
|
+
const cfg = await Config.get();
|
|
37
|
+
const defaultTools = cfg.tools ?? {};
|
|
38
38
|
|
|
39
39
|
const result: Record<string, Info> = {
|
|
40
40
|
general: {
|
|
41
|
-
name:
|
|
41
|
+
name: 'general',
|
|
42
42
|
description:
|
|
43
|
-
|
|
43
|
+
'General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.',
|
|
44
44
|
tools: {
|
|
45
45
|
todoread: false,
|
|
46
46
|
todowrite: false,
|
|
47
47
|
...defaultTools,
|
|
48
48
|
},
|
|
49
49
|
options: {},
|
|
50
|
-
mode:
|
|
50
|
+
mode: 'subagent',
|
|
51
51
|
builtIn: true,
|
|
52
52
|
},
|
|
53
53
|
build: {
|
|
54
|
-
name:
|
|
54
|
+
name: 'build',
|
|
55
55
|
tools: { ...defaultTools },
|
|
56
56
|
options: {},
|
|
57
|
-
mode:
|
|
57
|
+
mode: 'primary',
|
|
58
58
|
builtIn: true,
|
|
59
59
|
},
|
|
60
60
|
plan: {
|
|
61
|
-
name:
|
|
61
|
+
name: 'plan',
|
|
62
62
|
options: {},
|
|
63
63
|
tools: {
|
|
64
64
|
...defaultTools,
|
|
65
65
|
},
|
|
66
|
-
mode:
|
|
66
|
+
mode: 'primary',
|
|
67
67
|
builtIn: true,
|
|
68
68
|
},
|
|
69
|
-
}
|
|
69
|
+
};
|
|
70
70
|
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
|
|
71
71
|
if (value.disable) {
|
|
72
|
-
delete result[key]
|
|
73
|
-
continue
|
|
72
|
+
delete result[key];
|
|
73
|
+
continue;
|
|
74
74
|
}
|
|
75
|
-
let item = result[key]
|
|
75
|
+
let item = result[key];
|
|
76
76
|
if (!item)
|
|
77
77
|
item = result[key] = {
|
|
78
78
|
name: key,
|
|
79
|
-
mode:
|
|
79
|
+
mode: 'all',
|
|
80
80
|
options: {},
|
|
81
81
|
tools: {},
|
|
82
82
|
builtIn: false,
|
|
83
|
-
}
|
|
84
|
-
const {
|
|
83
|
+
};
|
|
84
|
+
const {
|
|
85
|
+
name,
|
|
86
|
+
model,
|
|
87
|
+
prompt,
|
|
88
|
+
tools,
|
|
89
|
+
description,
|
|
90
|
+
temperature,
|
|
91
|
+
top_p,
|
|
92
|
+
mode,
|
|
93
|
+
color,
|
|
94
|
+
...extra
|
|
95
|
+
} = value;
|
|
85
96
|
item.options = {
|
|
86
97
|
...item.options,
|
|
87
98
|
...extra,
|
|
88
|
-
}
|
|
89
|
-
if (model) item.model = Provider.parseModel(model)
|
|
90
|
-
if (prompt) item.prompt = prompt
|
|
99
|
+
};
|
|
100
|
+
if (model) item.model = Provider.parseModel(model);
|
|
101
|
+
if (prompt) item.prompt = prompt;
|
|
91
102
|
if (tools)
|
|
92
103
|
item.tools = {
|
|
93
104
|
...item.tools,
|
|
94
105
|
...tools,
|
|
95
|
-
}
|
|
106
|
+
};
|
|
96
107
|
item.tools = {
|
|
97
108
|
...defaultTools,
|
|
98
109
|
...item.tools,
|
|
99
|
-
}
|
|
100
|
-
if (description) item.description = description
|
|
101
|
-
if (temperature != undefined) item.temperature = temperature
|
|
102
|
-
if (top_p != undefined) item.topP = top_p
|
|
103
|
-
if (mode) item.mode = mode
|
|
104
|
-
if (color) item.color = color
|
|
110
|
+
};
|
|
111
|
+
if (description) item.description = description;
|
|
112
|
+
if (temperature != undefined) item.temperature = temperature;
|
|
113
|
+
if (top_p != undefined) item.topP = top_p;
|
|
114
|
+
if (mode) item.mode = mode;
|
|
115
|
+
if (color) item.color = color;
|
|
105
116
|
// just here for consistency & to prevent it from being added as an option
|
|
106
|
-
if (name) item.name = name
|
|
117
|
+
if (name) item.name = name;
|
|
107
118
|
}
|
|
108
|
-
return result
|
|
109
|
-
})
|
|
119
|
+
return result;
|
|
120
|
+
});
|
|
110
121
|
|
|
111
122
|
export async function get(agent: string) {
|
|
112
|
-
return state().then((x) => x[agent])
|
|
123
|
+
return state().then((x) => x[agent]);
|
|
113
124
|
}
|
|
114
125
|
|
|
115
126
|
export async function list() {
|
|
116
|
-
return state().then((x) => Object.values(x))
|
|
127
|
+
return state().then((x) => Object.values(x));
|
|
117
128
|
}
|
|
118
129
|
|
|
119
130
|
export async function generate(input: { description: string }) {
|
|
120
|
-
const defaultModel = await Provider.defaultModel()
|
|
121
|
-
const model = await Provider.getModel(
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
131
|
+
const defaultModel = await Provider.defaultModel();
|
|
132
|
+
const model = await Provider.getModel(
|
|
133
|
+
defaultModel.providerID,
|
|
134
|
+
defaultModel.modelID
|
|
135
|
+
);
|
|
136
|
+
const system = SystemPrompt.header(defaultModel.providerID);
|
|
137
|
+
system.push(PROMPT_GENERATE);
|
|
138
|
+
const existing = await list();
|
|
125
139
|
const result = await generateObject({
|
|
126
140
|
temperature: 0.3,
|
|
127
141
|
prompt: [
|
|
128
142
|
...system.map(
|
|
129
143
|
(item): ModelMessage => ({
|
|
130
|
-
role:
|
|
144
|
+
role: 'system',
|
|
131
145
|
content: item,
|
|
132
|
-
})
|
|
146
|
+
})
|
|
133
147
|
),
|
|
134
148
|
{
|
|
135
|
-
role:
|
|
136
|
-
content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(
|
|
149
|
+
role: 'user',
|
|
150
|
+
content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(', ')}\n Return ONLY the JSON object, no other text, do not wrap in backticks`,
|
|
137
151
|
},
|
|
138
152
|
],
|
|
139
153
|
model: model.language,
|
|
@@ -142,8 +156,8 @@ export namespace Agent {
|
|
|
142
156
|
whenToUse: z.string(),
|
|
143
157
|
systemPrompt: z.string(),
|
|
144
158
|
}),
|
|
145
|
-
})
|
|
146
|
-
return result.object
|
|
159
|
+
});
|
|
160
|
+
return result.object;
|
|
147
161
|
}
|
|
148
162
|
}
|
|
149
163
|
|
|
@@ -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
|
+
}
|