expandai 0.0.0 → 0.0.2
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/README.md +241 -6
- package/build/dts/ApiClient.d.ts +231 -0
- package/build/dts/ApiClient.d.ts.map +1 -0
- package/build/dts/Authentication.d.ts +23 -0
- package/build/dts/Authentication.d.ts.map +1 -0
- package/build/dts/BrowserOAuth.d.ts +99 -0
- package/build/dts/BrowserOAuth.d.ts.map +1 -0
- package/build/dts/Commands/Fetch.d.ts +30 -0
- package/build/dts/Commands/Fetch.d.ts.map +1 -0
- package/build/dts/Commands/Hook.d.ts +17 -0
- package/build/dts/Commands/Hook.d.ts.map +1 -0
- package/build/dts/Commands/Mcp.d.ts +11 -0
- package/build/dts/Commands/Mcp.d.ts.map +1 -0
- package/build/dts/Commands/McpRun.d.ts +5 -0
- package/build/dts/Commands/McpRun.d.ts.map +1 -0
- package/build/dts/Commands/Search.d.ts +21 -0
- package/build/dts/Commands/Search.d.ts.map +1 -0
- package/build/dts/Commands/Skill.d.ts +12 -0
- package/build/dts/Commands/Skill.d.ts.map +1 -0
- package/build/dts/Commands/Uninstall.d.ts +9 -0
- package/build/dts/Commands/Uninstall.d.ts.map +1 -0
- package/build/dts/Config.d.ts +105 -0
- package/build/dts/Config.d.ts.map +1 -0
- package/build/dts/DeviceAuth.d.ts +87 -0
- package/build/dts/DeviceAuth.d.ts.map +1 -0
- package/build/dts/Hook/Claude.d.ts +13 -0
- package/build/dts/Hook/Claude.d.ts.map +1 -0
- package/build/dts/Hook/Errors.d.ts +20 -0
- package/build/dts/Hook/Errors.d.ts.map +1 -0
- package/build/dts/Hook/Opencode.d.ts +13 -0
- package/build/dts/Hook/Opencode.d.ts.map +1 -0
- package/build/dts/Hook/SnapshotNote.d.ts +8 -0
- package/build/dts/Hook/SnapshotNote.d.ts.map +1 -0
- package/build/dts/Install/Claude/Hook.d.ts +60 -0
- package/build/dts/Install/Claude/Hook.d.ts.map +1 -0
- package/build/dts/Install/Claude/Mcp.d.ts +45 -0
- package/build/dts/Install/Claude/Mcp.d.ts.map +1 -0
- package/build/dts/Install/Claude/Skill.d.ts +24 -0
- package/build/dts/Install/Claude/Skill.d.ts.map +1 -0
- package/build/dts/Install/Codex/Mcp.d.ts +39 -0
- package/build/dts/Install/Codex/Mcp.d.ts.map +1 -0
- package/build/dts/Install/Codex/Skill.d.ts +20 -0
- package/build/dts/Install/Codex/Skill.d.ts.map +1 -0
- package/build/dts/Install/Cursor/Mcp.d.ts +45 -0
- package/build/dts/Install/Cursor/Mcp.d.ts.map +1 -0
- package/build/dts/Install/Cursor/Skill.d.ts +27 -0
- package/build/dts/Install/Cursor/Skill.d.ts.map +1 -0
- package/build/dts/Install/Domain.d.ts +73 -0
- package/build/dts/Install/Domain.d.ts.map +1 -0
- package/build/dts/Install/Hook.d.ts +30 -0
- package/build/dts/Install/Hook.d.ts.map +1 -0
- package/build/dts/Install/Mcp.d.ts +28 -0
- package/build/dts/Install/Mcp.d.ts.map +1 -0
- package/build/dts/Install/Opencode/Hook.d.ts +23 -0
- package/build/dts/Install/Opencode/Hook.d.ts.map +1 -0
- package/build/dts/Install/Opencode/Mcp.d.ts +47 -0
- package/build/dts/Install/Opencode/Mcp.d.ts.map +1 -0
- package/build/dts/Install/Opencode/Skill.d.ts +24 -0
- package/build/dts/Install/Opencode/Skill.d.ts.map +1 -0
- package/build/dts/Install/Skill.d.ts +32 -0
- package/build/dts/Install/Skill.d.ts.map +1 -0
- package/build/dts/Install/Uninstall.d.ts +32 -0
- package/build/dts/Install/Uninstall.d.ts.map +1 -0
- package/build/dts/Mcp/ExpandDocs.d.ts +7 -0
- package/build/dts/Mcp/ExpandDocs.d.ts.map +1 -0
- package/build/dts/Mcp/RequestHeaders.d.ts +15 -0
- package/build/dts/Mcp/RequestHeaders.d.ts.map +1 -0
- package/build/dts/Mcp/Server.d.ts +10 -0
- package/build/dts/Mcp/Server.d.ts.map +1 -0
- package/build/dts/Mcp/Toolkit.d.ts +3 -0
- package/build/dts/Mcp/Toolkit.d.ts.map +1 -0
- package/build/dts/Mcp/tools/Citations.d.ts +237 -0
- package/build/dts/Mcp/tools/Citations.d.ts.map +1 -0
- package/build/dts/Mcp/tools/Fetch.d.ts +167 -0
- package/build/dts/Mcp/tools/Fetch.d.ts.map +1 -0
- package/build/dts/Snippets.d.ts +19 -0
- package/build/dts/Snippets.d.ts.map +1 -0
- package/build/dts/index.d.ts +2 -0
- package/build/dts/index.d.ts.map +1 -0
- package/build/dts/main.d.ts +3 -0
- package/build/dts/main.d.ts.map +1 -0
- package/build/esm/ApiClient.js +182 -0
- package/build/esm/ApiClient.js.map +1 -0
- package/build/esm/Authentication.js +112 -0
- package/build/esm/Authentication.js.map +1 -0
- package/build/esm/BrowserOAuth.js +254 -0
- package/build/esm/BrowserOAuth.js.map +1 -0
- package/build/esm/Commands/Fetch.js +95 -0
- package/build/esm/Commands/Fetch.js.map +1 -0
- package/build/esm/Commands/Hook.js +20 -0
- package/build/esm/Commands/Hook.js.map +1 -0
- package/build/esm/Commands/Mcp.js +19 -0
- package/build/esm/Commands/Mcp.js.map +1 -0
- package/build/esm/Commands/McpRun.js +15 -0
- package/build/esm/Commands/McpRun.js.map +1 -0
- package/build/esm/Commands/Search.js +43 -0
- package/build/esm/Commands/Search.js.map +1 -0
- package/build/esm/Commands/Skill.js +17 -0
- package/build/esm/Commands/Skill.js.map +1 -0
- package/build/esm/Commands/Uninstall.js +28 -0
- package/build/esm/Commands/Uninstall.js.map +1 -0
- package/build/esm/Config.js +145 -0
- package/build/esm/Config.js.map +1 -0
- package/build/esm/DeviceAuth.js +127 -0
- package/build/esm/DeviceAuth.js.map +1 -0
- package/build/esm/Hook/Claude.js +74 -0
- package/build/esm/Hook/Claude.js.map +1 -0
- package/build/esm/Hook/Errors.js +18 -0
- package/build/esm/Hook/Errors.js.map +1 -0
- package/build/esm/Hook/Opencode.js +50 -0
- package/build/esm/Hook/Opencode.js.map +1 -0
- package/build/esm/Hook/SnapshotNote.js +18 -0
- package/build/esm/Hook/SnapshotNote.js.map +1 -0
- package/build/esm/Install/Claude/Hook.js +89 -0
- package/build/esm/Install/Claude/Hook.js.map +1 -0
- package/build/esm/Install/Claude/Mcp.js +71 -0
- package/build/esm/Install/Claude/Mcp.js.map +1 -0
- package/build/esm/Install/Claude/Skill.js +34 -0
- package/build/esm/Install/Claude/Skill.js.map +1 -0
- package/build/esm/Install/Codex/Mcp.js +92 -0
- package/build/esm/Install/Codex/Mcp.js.map +1 -0
- package/build/esm/Install/Codex/Skill.js +30 -0
- package/build/esm/Install/Codex/Skill.js.map +1 -0
- package/build/esm/Install/Cursor/Mcp.js +71 -0
- package/build/esm/Install/Cursor/Mcp.js.map +1 -0
- package/build/esm/Install/Cursor/Skill.js +37 -0
- package/build/esm/Install/Cursor/Skill.js.map +1 -0
- package/build/esm/Install/Domain.js +105 -0
- package/build/esm/Install/Domain.js.map +1 -0
- package/build/esm/Install/Hook.js +38 -0
- package/build/esm/Install/Hook.js.map +1 -0
- package/build/esm/Install/Mcp.js +40 -0
- package/build/esm/Install/Mcp.js.map +1 -0
- package/build/esm/Install/Opencode/Hook.js +44 -0
- package/build/esm/Install/Opencode/Hook.js.map +1 -0
- package/build/esm/Install/Opencode/Mcp.js +73 -0
- package/build/esm/Install/Opencode/Mcp.js.map +1 -0
- package/build/esm/Install/Opencode/Skill.js +34 -0
- package/build/esm/Install/Opencode/Skill.js.map +1 -0
- package/build/esm/Install/Opencode/expandai-webfetch.js +38 -0
- package/build/esm/Install/Skill.js +41 -0
- package/build/esm/Install/Skill.js.map +1 -0
- package/build/esm/Install/Uninstall.js +49 -0
- package/build/esm/Install/Uninstall.js.map +1 -0
- package/build/esm/Mcp/ExpandDocs.js +64 -0
- package/build/esm/Mcp/ExpandDocs.js.map +1 -0
- package/build/esm/Mcp/RequestHeaders.js +13 -0
- package/build/esm/Mcp/RequestHeaders.js.map +1 -0
- package/build/esm/Mcp/Server.js +11 -0
- package/build/esm/Mcp/Server.js.map +1 -0
- package/build/esm/Mcp/Toolkit.js +16 -0
- package/build/esm/Mcp/Toolkit.js.map +1 -0
- package/build/esm/Mcp/tools/Citations.js +92 -0
- package/build/esm/Mcp/tools/Citations.js.map +1 -0
- package/build/esm/Mcp/tools/Fetch.js +141 -0
- package/build/esm/Mcp/tools/Fetch.js.map +1 -0
- package/build/esm/Snippets.js +18 -0
- package/build/esm/Snippets.js.map +1 -0
- package/build/esm/index.js +2 -0
- package/build/esm/index.js.map +1 -0
- package/build/esm/main.js +97 -0
- package/build/esm/main.js.map +1 -0
- package/build/skills/expandai/SKILL.md +72 -0
- package/package.json +72 -10
- package/src/ApiClient.ts +257 -0
- package/src/Authentication.ts +164 -0
- package/src/BrowserOAuth.ts +361 -0
- package/src/Commands/Fetch.ts +157 -0
- package/src/Commands/Hook.ts +48 -0
- package/src/Commands/Mcp.ts +30 -0
- package/src/Commands/McpRun.ts +25 -0
- package/src/Commands/Search.ts +92 -0
- package/src/Commands/Skill.ts +30 -0
- package/src/Commands/Uninstall.ts +38 -0
- package/src/Config.ts +195 -0
- package/src/DeviceAuth.ts +193 -0
- package/src/Hook/Claude.ts +81 -0
- package/src/Hook/Errors.ts +19 -0
- package/src/Hook/Opencode.ts +58 -0
- package/src/Hook/SnapshotNote.ts +18 -0
- package/src/Install/Claude/Hook.ts +133 -0
- package/src/Install/Claude/Mcp.ts +82 -0
- package/src/Install/Claude/Skill.ts +44 -0
- package/src/Install/Codex/Mcp.ts +109 -0
- package/src/Install/Codex/Skill.ts +40 -0
- package/src/Install/Cursor/Mcp.ts +82 -0
- package/src/Install/Cursor/Skill.ts +47 -0
- package/src/Install/Domain.ts +148 -0
- package/src/Install/Hook.ts +50 -0
- package/src/Install/Mcp.ts +71 -0
- package/src/Install/Opencode/Hook.ts +55 -0
- package/src/Install/Opencode/Mcp.ts +84 -0
- package/src/Install/Opencode/Skill.ts +44 -0
- package/src/Install/Opencode/expandai-webfetch.d.ts +23 -0
- package/src/Install/Opencode/expandai-webfetch.js +38 -0
- package/src/Install/Skill.ts +57 -0
- package/src/Install/Uninstall.ts +82 -0
- package/src/Mcp/ExpandDocs.ts +71 -0
- package/src/Mcp/RequestHeaders.ts +16 -0
- package/src/Mcp/Server.ts +14 -0
- package/src/Mcp/Toolkit.ts +21 -0
- package/src/Mcp/tools/Citations.ts +110 -0
- package/src/Mcp/tools/Fetch.ts +177 -0
- package/src/Snippets.ts +32 -0
- package/src/index.ts +1 -0
- package/src/main.ts +146 -0
- package/bun.lockb +0 -0
- package/index.ts +0 -1
- package/tsconfig.json +0 -27
package/src/ApiClient.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { HttpClient, HttpClientRequest, HttpClientResponse } from '@effect/platform'
|
|
2
|
+
import { NodeHttpClient } from '@effect/platform-node'
|
|
3
|
+
import { fetchObjectModeJson, fetchSnapshotSearch } from '@expandai/sdk/FetchJson'
|
|
4
|
+
import * as Generated from '@expandai/sdk/Generated'
|
|
5
|
+
import { decodeError, unexpectedStatus } from '@expandai/sdk/HttpClientHelpers'
|
|
6
|
+
import { Data, DateTime, Duration, Effect, flow, Match, Option, Redacted, Schema } from 'effect'
|
|
7
|
+
import { BrowserOAuthService } from './BrowserOAuth.js'
|
|
8
|
+
import { ConfigManager, OAuthConfig, OAuthTokenExpired } from './Config.js'
|
|
9
|
+
import { CurrentRequestHeaders } from './Mcp/RequestHeaders.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Single user-facing error for any failure crossing the API boundary. The
|
|
13
|
+
* generated client raises a half-dozen status-specific tagged classes plus
|
|
14
|
+
* transport/decode errors; we collapse them here so the rest of the app sees
|
|
15
|
+
* one tag with a ready-made `.message` and never has to know about
|
|
16
|
+
* `Generated.ts` types.
|
|
17
|
+
*/
|
|
18
|
+
export class ApiRequestFailed extends Schema.TaggedError<ApiRequestFailed>()('ApiRequestFailed', {
|
|
19
|
+
detail: Schema.String,
|
|
20
|
+
}) {
|
|
21
|
+
get message() {
|
|
22
|
+
return this.detail
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Derived rather than hand-written so codegen drift surfaces as a type error.
|
|
27
|
+
type ClientError =
|
|
28
|
+
| Effect.Effect.Error<ReturnType<Generated.Client['fetchFetch']>>
|
|
29
|
+
| Effect.Effect.Error<ReturnType<Generated.Client['fetchFetchJson']>>
|
|
30
|
+
| Effect.Effect.Error<ReturnType<Generated.Client['fetchFetchSearch']>>
|
|
31
|
+
| Effect.Effect.Error<ReturnType<Generated.Client['fetchBatched']>>
|
|
32
|
+
| Effect.Effect.Error<ReturnType<Generated.Client['fetchGetBatched']>>
|
|
33
|
+
type Issue = typeof Generated.Issue.Type
|
|
34
|
+
type OAuthAuthMethod = typeof OAuthConfig.Type
|
|
35
|
+
|
|
36
|
+
const OAUTH_REFRESH_SKEW = Duration.minutes(1)
|
|
37
|
+
|
|
38
|
+
class OAuthAuthMethodChanged extends Data.TaggedError('OAuthAuthMethodChanged')<Record<string, never>> {}
|
|
39
|
+
|
|
40
|
+
function getField(value: unknown, key: string): unknown {
|
|
41
|
+
return typeof value === 'object' && value !== null && key in value ? value[key as keyof typeof value] : undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getStringField(value: unknown, key: string): string | undefined {
|
|
45
|
+
const field = getField(value, key)
|
|
46
|
+
return typeof field === 'string' ? field : undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getIssueList(value: unknown): Issue[] {
|
|
50
|
+
const issues = getField(value, 'issues')
|
|
51
|
+
return Array.isArray(issues) ? (issues as Issue[]) : []
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getUnsupportedFields(value: unknown): string[] {
|
|
55
|
+
const unsupportedFields = getField(value, 'unsupportedFields')
|
|
56
|
+
return Array.isArray(unsupportedFields) ? unsupportedFields.filter((field) => typeof field === 'string') : []
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const formatIssue = Effect.fn('ApiClient.formatIssue')(function* ({ path, message }: Issue) {
|
|
60
|
+
const prefix = path.map((segment) => (typeof segment === 'object' ? segment.key : String(segment))).join('.')
|
|
61
|
+
return prefix ? `${prefix}: ${message}` : message
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const formatApiFailure = Effect.fn('ApiClient.formatApiFailure')(function* (error: ClientError) {
|
|
65
|
+
switch (getStringField(error, '_tag')) {
|
|
66
|
+
case 'HttpApiDecodeError': {
|
|
67
|
+
const issues = getIssueList(error)
|
|
68
|
+
return yield* issues.length === 0
|
|
69
|
+
? Effect.succeed(getStringField(error, 'message') ?? 'Invalid request')
|
|
70
|
+
: Effect.forEach(issues, formatIssue).pipe(Effect.map((parts) => parts.join('; ')))
|
|
71
|
+
}
|
|
72
|
+
case 'AuthFailed':
|
|
73
|
+
return `Authentication failed (${getStringField(error, 'reason') ?? 'unknown'}). Run \`expandai login\` to re-authenticate.`
|
|
74
|
+
case 'AccessBlocked':
|
|
75
|
+
return 'Access blocked. Check your billing status or contact support.'
|
|
76
|
+
case 'BatchedIncludeUnsupported':
|
|
77
|
+
return `Unsupported batched include fields: ${getUnsupportedFields(error).join(', ')}.`
|
|
78
|
+
case 'FetchSnapshotSearchNotFound':
|
|
79
|
+
return 'Fetch snapshot not found.'
|
|
80
|
+
case 'PayloadTooLarge':
|
|
81
|
+
return 'Request payload is too large.'
|
|
82
|
+
case 'TooManyRequests':
|
|
83
|
+
return 'Rate limit exceeded — try again in a moment.'
|
|
84
|
+
case 'InternalError':
|
|
85
|
+
return 'The expand.ai API returned an internal error. Try again or contact support.'
|
|
86
|
+
case 'ServiceUnavailable':
|
|
87
|
+
return 'The expand.ai API is temporarily unavailable. Try again shortly.'
|
|
88
|
+
default:
|
|
89
|
+
return error instanceof Error ? error.message : (getStringField(error, 'message') ?? 'Expand API request failed.')
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
const liftApiError = <A, R>(self: Effect.Effect<A, ClientError, R>) =>
|
|
94
|
+
self.pipe(
|
|
95
|
+
Effect.catchAll((error) =>
|
|
96
|
+
formatApiFailure(error).pipe(Effect.flatMap((detail) => Effect.fail(new ApiRequestFailed({ detail })))),
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolves outgoing API auth per call. When the current request scope carries
|
|
102
|
+
* an `Authorization` header (via `CurrentRequestHeaders`), that value is
|
|
103
|
+
* forwarded; otherwise auth comes from `~/.config/expandai/config.json`.
|
|
104
|
+
*/
|
|
105
|
+
export class ApiClient extends Effect.Service<ApiClient>()('@expandai/cli/ApiClient', {
|
|
106
|
+
dependencies: [NodeHttpClient.layer, ConfigManager.Default, BrowserOAuthService.Default],
|
|
107
|
+
scoped: Effect.gen(function* () {
|
|
108
|
+
const configManager = yield* ConfigManager
|
|
109
|
+
const browserOAuth = yield* BrowserOAuthService
|
|
110
|
+
const baseClient = yield* HttpClient.HttpClient
|
|
111
|
+
const refreshLock = yield* Effect.makeSemaphore(1)
|
|
112
|
+
|
|
113
|
+
const freshOAuthAccessToken = Effect.fn('ApiClient.freshOAuthAccessToken')(function* ({
|
|
114
|
+
accessToken,
|
|
115
|
+
expiresAt,
|
|
116
|
+
refreshToken,
|
|
117
|
+
}: OAuthAuthMethod) {
|
|
118
|
+
const now = yield* DateTime.now
|
|
119
|
+
const refreshThreshold = refreshToken === undefined ? now : DateTime.addDuration(now, OAUTH_REFRESH_SKEW)
|
|
120
|
+
if (DateTime.lessThan(refreshThreshold, expiresAt)) {
|
|
121
|
+
return Option.some(Redacted.value(accessToken))
|
|
122
|
+
}
|
|
123
|
+
return Option.none<string>()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const refreshOAuthToken = Effect.fn('ApiClient.refreshOAuthToken')(function* ({
|
|
127
|
+
refreshToken,
|
|
128
|
+
expiresAt,
|
|
129
|
+
}: OAuthAuthMethod) {
|
|
130
|
+
if (refreshToken === undefined) {
|
|
131
|
+
return yield* new OAuthTokenExpired({ expiredAt: expiresAt })
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const now = yield* DateTime.now
|
|
135
|
+
const token = yield* browserOAuth.refreshToken(Redacted.value(refreshToken))
|
|
136
|
+
if (token === undefined) {
|
|
137
|
+
return yield* new OAuthTokenExpired({ expiredAt: expiresAt })
|
|
138
|
+
}
|
|
139
|
+
const refreshedExpiresAt = DateTime.addDuration(now, token.expires_in)
|
|
140
|
+
const nextRefreshToken = token.refresh_token === undefined ? refreshToken : Redacted.make(token.refresh_token)
|
|
141
|
+
yield* configManager.updateConfig({
|
|
142
|
+
authMethod: OAuthConfig.make({
|
|
143
|
+
accessToken: Redacted.make(token.access_token),
|
|
144
|
+
refreshToken: nextRefreshToken,
|
|
145
|
+
expiresAt: refreshedExpiresAt,
|
|
146
|
+
}),
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
return token.access_token
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const oauthBearerToken = Effect.fn('ApiClient.oauthBearerToken')(function* (authMethod: OAuthAuthMethod) {
|
|
153
|
+
const alreadyFresh = yield* freshOAuthAccessToken(authMethod)
|
|
154
|
+
if (Option.isSome(alreadyFresh)) {
|
|
155
|
+
return alreadyFresh.value
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return yield* refreshLock.withPermits(1)(
|
|
159
|
+
Effect.gen(function* () {
|
|
160
|
+
const { authMethod: persistedAuthMethod } = yield* configManager.loadConfig()
|
|
161
|
+
if (persistedAuthMethod._tag !== 'OAuth') {
|
|
162
|
+
return yield* new OAuthAuthMethodChanged({})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const persistedFresh = yield* freshOAuthAccessToken(persistedAuthMethod)
|
|
166
|
+
if (Option.isSome(persistedFresh)) {
|
|
167
|
+
return persistedFresh.value
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return yield* refreshOAuthToken(persistedAuthMethod)
|
|
171
|
+
}),
|
|
172
|
+
)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
// Resolved per call, not at construction. Effect Layers are globally
|
|
176
|
+
// memoized, so capturing CurrentRequestHeaders here once would freeze
|
|
177
|
+
// auth resolution to whatever was on the first request. Reading per
|
|
178
|
+
// call also keeps the OAuth `expiresAt` check live against `DateTime.now`.
|
|
179
|
+
// `Effect.serviceOption` reads the tag from the outer fiber context
|
|
180
|
+
// without leaking it into our R, so the API's per-request
|
|
181
|
+
// `Context.make(CurrentRequestHeaders, headers)` flows through and the
|
|
182
|
+
// stdio MCP path simply gets `Option.none` → empty headers.
|
|
183
|
+
const buildAuthedClientOnce = () =>
|
|
184
|
+
Effect.gen(function* () {
|
|
185
|
+
const forwardedHeaders = yield* Effect.serviceOption(CurrentRequestHeaders).pipe(
|
|
186
|
+
Effect.map(Option.getOrElse(() => ({}) as Record<string, string>)),
|
|
187
|
+
)
|
|
188
|
+
// header keys are lowercase per WHATWG Headers spec.
|
|
189
|
+
const forwardedAuth = Option.fromNullable(forwardedHeaders['authorization'])
|
|
190
|
+
|
|
191
|
+
if (Option.isSome(forwardedAuth)) {
|
|
192
|
+
// Forwarded path: caller owns the token's lifecycle (their session,
|
|
193
|
+
// their bearer). We don't re-check expiry — if it's stale the API
|
|
194
|
+
// will return 401. `OAuthTokenExpired` is for the *persisted*
|
|
195
|
+
// OAuth in the loadConfig branch only.
|
|
196
|
+
const apiUrl = yield* configManager.getApiUrl()
|
|
197
|
+
const mapRequest = flow(
|
|
198
|
+
HttpClientRequest.prependUrl(apiUrl.toString()),
|
|
199
|
+
HttpClientRequest.setHeader('authorization', forwardedAuth.value),
|
|
200
|
+
)
|
|
201
|
+
return baseClient.pipe(HttpClient.mapRequest(mapRequest))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const { authMethod, apiUrl } = yield* configManager.loadConfig()
|
|
205
|
+
const applyAuth = yield* Match.value(authMethod).pipe(
|
|
206
|
+
Match.tag('ApiKey', ({ apiKey }) =>
|
|
207
|
+
Effect.succeed(HttpClientRequest.setHeader('x-expand-api-key', Redacted.value(apiKey))),
|
|
208
|
+
),
|
|
209
|
+
Match.tag('OAuth', (authMethod) =>
|
|
210
|
+
oauthBearerToken(authMethod).pipe(Effect.map((accessToken) => HttpClientRequest.bearerToken(accessToken))),
|
|
211
|
+
),
|
|
212
|
+
Match.exhaustive,
|
|
213
|
+
)
|
|
214
|
+
const mapRequest = flow(HttpClientRequest.prependUrl(apiUrl.toString()), applyAuth)
|
|
215
|
+
return baseClient.pipe(HttpClient.mapRequest(mapRequest))
|
|
216
|
+
})
|
|
217
|
+
const buildAuthedClient = () =>
|
|
218
|
+
buildAuthedClientOnce().pipe(Effect.catchTag('OAuthAuthMethodChanged', () => buildAuthedClientOnce()))
|
|
219
|
+
const buildAuthed = () => buildAuthedClient().pipe(Effect.map(Generated.make))
|
|
220
|
+
const fetchMarkdown = (client: HttpClient.HttpClient, options: Parameters<Generated.Client['fetchFetch']>[0]) =>
|
|
221
|
+
HttpClientRequest.make('POST')('/v1/fetch').pipe(
|
|
222
|
+
HttpClientRequest.setUrlParams(options.params.include !== undefined ? { include: options.params.include } : {}),
|
|
223
|
+
(req) => Effect.orDie(HttpClientRequest.bodyJson(req, options.payload)),
|
|
224
|
+
Effect.flatMap((request) =>
|
|
225
|
+
Effect.flatMap(
|
|
226
|
+
client.execute(request),
|
|
227
|
+
HttpClientResponse.matchStatus({
|
|
228
|
+
'200': (response) => response.text,
|
|
229
|
+
'400': (response) => decodeError(response, Generated.FetchFetch400),
|
|
230
|
+
'401': (response) => decodeError(response, Generated.AuthFailed),
|
|
231
|
+
'403': (response) => decodeError(response, Generated.FetchFetch403),
|
|
232
|
+
'413': (response) => decodeError(response, Generated.PayloadTooLarge),
|
|
233
|
+
'429': (response) => decodeError(response, Generated.TooManyRequests),
|
|
234
|
+
'500': (response) => decodeError(response, Generated.InternalError),
|
|
235
|
+
'503': (response) => decodeError(response, Generated.ServiceUnavailable),
|
|
236
|
+
orElse: (response) => unexpectedStatus(request, response),
|
|
237
|
+
}),
|
|
238
|
+
),
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
fetchFetch: (...args: Parameters<Generated.Client['fetchFetch']>) =>
|
|
244
|
+
buildAuthedClient().pipe(Effect.flatMap((client) => fetchMarkdown(client, ...args).pipe(liftApiError))),
|
|
245
|
+
fetchFetchResult: (...args: Parameters<Generated.Client['fetchFetchJson']>) =>
|
|
246
|
+
buildAuthedClient().pipe(Effect.flatMap((client) => fetchObjectModeJson(client, ...args).pipe(liftApiError))),
|
|
247
|
+
fetchFetchJson: (...args: Parameters<Generated.Client['fetchFetchJson']>) =>
|
|
248
|
+
buildAuthedClient().pipe(Effect.flatMap((client) => fetchObjectModeJson(client, ...args).pipe(liftApiError))),
|
|
249
|
+
fetchFetchSearch: (...args: Parameters<Generated.Client['fetchFetchSearch']>) =>
|
|
250
|
+
buildAuthedClient().pipe(Effect.flatMap((client) => fetchSnapshotSearch(client, ...args).pipe(liftApiError))),
|
|
251
|
+
fetchBatched: (...args: Parameters<Generated.Client['fetchBatched']>) =>
|
|
252
|
+
buildAuthed().pipe(Effect.flatMap((client) => client.fetchBatched(...args).pipe(liftApiError))),
|
|
253
|
+
fetchGetBatched: (...args: Parameters<Generated.Client['fetchGetBatched']>) =>
|
|
254
|
+
buildAuthed().pipe(Effect.flatMap((client) => client.fetchGetBatched(...args).pipe(liftApiError))),
|
|
255
|
+
} as const
|
|
256
|
+
}),
|
|
257
|
+
}) {}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { Command, Prompt } from '@effect/cli'
|
|
2
|
+
import { Console, DateTime, Duration, Effect, Either, Match, Schema, String } from 'effect'
|
|
3
|
+
import open from 'open'
|
|
4
|
+
import { BrowserOAuthService } from './BrowserOAuth.js'
|
|
5
|
+
import { ApiKey, ApiKeyConfig, ConfigManager, OAuthConfig } from './Config.js'
|
|
6
|
+
import { Uninstall } from './Install/Uninstall.js'
|
|
7
|
+
|
|
8
|
+
type AuthMethod = typeof ApiKeyConfig.Type | typeof OAuthConfig.Type
|
|
9
|
+
|
|
10
|
+
/** Render a user-facing description of the current auth state. Pure given DateTime.now. */
|
|
11
|
+
export const formatAuthStatus = Effect.fn('Authentication.formatAuthStatus')(function* (authMethod: AuthMethod) {
|
|
12
|
+
return yield* Match.value(authMethod).pipe(
|
|
13
|
+
Match.tag('ApiKey', () => Effect.succeed('Authenticated via API key.')),
|
|
14
|
+
Match.tag('OAuth', ({ expiresAt }) =>
|
|
15
|
+
Effect.gen(function* () {
|
|
16
|
+
const now = yield* DateTime.now
|
|
17
|
+
const expired = DateTime.lessThan(expiresAt, now)
|
|
18
|
+
const detail = expired
|
|
19
|
+
? `expired ${Duration.format(DateTime.distanceDuration(expiresAt, now))} ago — run \`expandai login\``
|
|
20
|
+
: `expires in ${Duration.format(DateTime.distanceDuration(now, expiresAt))}`
|
|
21
|
+
return `Authenticated via OAuth (${detail}).`
|
|
22
|
+
}),
|
|
23
|
+
),
|
|
24
|
+
Match.exhaustive,
|
|
25
|
+
)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
export class Authentication extends Effect.Service<Authentication>()('@expandai/cli/Authentication', {
|
|
29
|
+
dependencies: [ConfigManager.Default, BrowserOAuthService.Default, Uninstall.Default],
|
|
30
|
+
effect: Effect.gen(function* () {
|
|
31
|
+
const configManager = yield* ConfigManager
|
|
32
|
+
const browserOAuth = yield* BrowserOAuthService
|
|
33
|
+
const uninstall = yield* Uninstall
|
|
34
|
+
|
|
35
|
+
const apiKeyLogin = Effect.fn('Authentication.apiKeyLogin')(function* () {
|
|
36
|
+
const apiKey = yield* Prompt.password({ message: 'Enter your API Key' })
|
|
37
|
+
yield* configManager.updateConfig({
|
|
38
|
+
authMethod: ApiKeyConfig.make({ apiKey: yield* Schema.validate(ApiKey)(apiKey) }),
|
|
39
|
+
})
|
|
40
|
+
yield* Console.log('API key saved successfully!')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const oauthLogin = Effect.fn('Authentication.oauthLogin')(function* () {
|
|
44
|
+
yield* Console.log(
|
|
45
|
+
String.stripMargin(`
|
|
46
|
+
|
|
|
47
|
+
|Starting browser authorization...`),
|
|
48
|
+
)
|
|
49
|
+
yield* browserOAuth.login((authorizeUrl) =>
|
|
50
|
+
Effect.gen(function* () {
|
|
51
|
+
yield* Console.log(
|
|
52
|
+
String.stripMargin(`
|
|
53
|
+
|
|
|
54
|
+
|Open this URL in a browser:
|
|
55
|
+
| ${authorizeUrl.toString()}
|
|
56
|
+
|`),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
const shouldOpen = yield* Prompt.confirm({ message: 'Open browser now?', initial: true })
|
|
60
|
+
if (shouldOpen) {
|
|
61
|
+
yield* Effect.tryPromise(() => open(authorizeUrl.toString())).pipe(
|
|
62
|
+
Effect.catchAll(() => Console.log('Could not open browser automatically — use the URL above.')),
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
yield* Console.log(
|
|
67
|
+
String.stripMargin(`
|
|
68
|
+
|
|
|
69
|
+
|Waiting for browser authorization...`),
|
|
70
|
+
)
|
|
71
|
+
}),
|
|
72
|
+
)
|
|
73
|
+
yield* Console.log(
|
|
74
|
+
String.stripMargin(`
|
|
75
|
+
|
|
|
76
|
+
|Successfully authenticated!`),
|
|
77
|
+
)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const login = Effect.fn('Authentication.login')(function* () {
|
|
81
|
+
const existing = yield* Effect.either(configManager.loadConfig())
|
|
82
|
+
|
|
83
|
+
if (Either.isRight(existing)) {
|
|
84
|
+
const status = yield* formatAuthStatus(existing.right.authMethod)
|
|
85
|
+
const proceed = yield* Prompt.confirm({
|
|
86
|
+
message: String.stripMargin(`
|
|
87
|
+
|${status}
|
|
88
|
+
|Do you want to re-authenticate?`),
|
|
89
|
+
})
|
|
90
|
+
if (!proceed) {
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const method = yield* Prompt.select({
|
|
96
|
+
message: 'How would you like to authenticate?',
|
|
97
|
+
choices: [
|
|
98
|
+
{ title: 'Browser Login', value: 'oauth' as const, description: 'Authenticate via browser' },
|
|
99
|
+
{ title: 'API Key', value: 'api-key' as const, description: 'Paste your API key' },
|
|
100
|
+
],
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
yield* Match.value(method).pipe(
|
|
104
|
+
Match.when('api-key', () => apiKeyLogin()),
|
|
105
|
+
Match.when('oauth', () => oauthLogin()),
|
|
106
|
+
Match.exhaustive,
|
|
107
|
+
)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const whoami = Effect.fn('Authentication.whoami')(function* () {
|
|
111
|
+
const { authMethod } = yield* configManager.loadConfig()
|
|
112
|
+
const text = yield* formatAuthStatus(authMethod)
|
|
113
|
+
yield* Console.log(text)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const logout = Effect.fn('Authentication.logout')(function* () {
|
|
117
|
+
yield* configManager.clearConfig()
|
|
118
|
+
|
|
119
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
120
|
+
yield* Effect.logInfo(
|
|
121
|
+
String.stripMargin(`
|
|
122
|
+
|Skipping install cleanup — \`expandai logout\` is not interactive in this context.
|
|
123
|
+
|Run \`expandai uninstall\` to remove hooks, skills, and MCP entries.`),
|
|
124
|
+
)
|
|
125
|
+
yield* Console.log('Logged out.')
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const cleanup = yield* Prompt.multiSelect({
|
|
130
|
+
message: 'Remove installed expandai integrations?',
|
|
131
|
+
choices: [
|
|
132
|
+
{ title: 'Global hooks', value: { target: 'hook' as const, scope: 'global' as const } },
|
|
133
|
+
{ title: 'Project hooks', value: { target: 'hook' as const, scope: 'project' as const } },
|
|
134
|
+
{ title: 'Global skills', value: { target: 'skill' as const, scope: 'global' as const } },
|
|
135
|
+
{ title: 'Project skills', value: { target: 'skill' as const, scope: 'project' as const } },
|
|
136
|
+
{ title: 'Global MCP entries', value: { target: 'mcp' as const, scope: 'global' as const } },
|
|
137
|
+
{ title: 'Project MCP entries', value: { target: 'mcp' as const, scope: 'project' as const } },
|
|
138
|
+
],
|
|
139
|
+
})
|
|
140
|
+
if (cleanup.length > 0) {
|
|
141
|
+
yield* Effect.forEach(cleanup, (item) => uninstall.uninstall(item), { discard: true })
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
yield* Console.log('Logged out.')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
return { login, whoami, logout } as const
|
|
148
|
+
}),
|
|
149
|
+
}) {}
|
|
150
|
+
|
|
151
|
+
export const LoginCommand = Command.make('login').pipe(
|
|
152
|
+
Command.withDescription('Authenticate with ExpandAI'),
|
|
153
|
+
Command.withHandler(() => Effect.flatMap(Authentication, (auth) => auth.login())),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
export const WhoamiCommand = Command.make('whoami').pipe(
|
|
157
|
+
Command.withDescription('Show current authentication status'),
|
|
158
|
+
Command.withHandler(() => Effect.flatMap(Authentication, (auth) => auth.whoami())),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
export const LogoutCommand = Command.make('logout').pipe(
|
|
162
|
+
Command.withDescription('Clear stored credentials and optionally remove installed integrations'),
|
|
163
|
+
Command.withHandler(() => Effect.flatMap(Authentication, (auth) => auth.logout())),
|
|
164
|
+
)
|