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.
Files changed (209) hide show
  1. package/README.md +241 -6
  2. package/build/dts/ApiClient.d.ts +231 -0
  3. package/build/dts/ApiClient.d.ts.map +1 -0
  4. package/build/dts/Authentication.d.ts +23 -0
  5. package/build/dts/Authentication.d.ts.map +1 -0
  6. package/build/dts/BrowserOAuth.d.ts +99 -0
  7. package/build/dts/BrowserOAuth.d.ts.map +1 -0
  8. package/build/dts/Commands/Fetch.d.ts +30 -0
  9. package/build/dts/Commands/Fetch.d.ts.map +1 -0
  10. package/build/dts/Commands/Hook.d.ts +17 -0
  11. package/build/dts/Commands/Hook.d.ts.map +1 -0
  12. package/build/dts/Commands/Mcp.d.ts +11 -0
  13. package/build/dts/Commands/Mcp.d.ts.map +1 -0
  14. package/build/dts/Commands/McpRun.d.ts +5 -0
  15. package/build/dts/Commands/McpRun.d.ts.map +1 -0
  16. package/build/dts/Commands/Search.d.ts +21 -0
  17. package/build/dts/Commands/Search.d.ts.map +1 -0
  18. package/build/dts/Commands/Skill.d.ts +12 -0
  19. package/build/dts/Commands/Skill.d.ts.map +1 -0
  20. package/build/dts/Commands/Uninstall.d.ts +9 -0
  21. package/build/dts/Commands/Uninstall.d.ts.map +1 -0
  22. package/build/dts/Config.d.ts +105 -0
  23. package/build/dts/Config.d.ts.map +1 -0
  24. package/build/dts/DeviceAuth.d.ts +87 -0
  25. package/build/dts/DeviceAuth.d.ts.map +1 -0
  26. package/build/dts/Hook/Claude.d.ts +13 -0
  27. package/build/dts/Hook/Claude.d.ts.map +1 -0
  28. package/build/dts/Hook/Errors.d.ts +20 -0
  29. package/build/dts/Hook/Errors.d.ts.map +1 -0
  30. package/build/dts/Hook/Opencode.d.ts +13 -0
  31. package/build/dts/Hook/Opencode.d.ts.map +1 -0
  32. package/build/dts/Hook/SnapshotNote.d.ts +8 -0
  33. package/build/dts/Hook/SnapshotNote.d.ts.map +1 -0
  34. package/build/dts/Install/Claude/Hook.d.ts +60 -0
  35. package/build/dts/Install/Claude/Hook.d.ts.map +1 -0
  36. package/build/dts/Install/Claude/Mcp.d.ts +45 -0
  37. package/build/dts/Install/Claude/Mcp.d.ts.map +1 -0
  38. package/build/dts/Install/Claude/Skill.d.ts +24 -0
  39. package/build/dts/Install/Claude/Skill.d.ts.map +1 -0
  40. package/build/dts/Install/Codex/Mcp.d.ts +39 -0
  41. package/build/dts/Install/Codex/Mcp.d.ts.map +1 -0
  42. package/build/dts/Install/Codex/Skill.d.ts +20 -0
  43. package/build/dts/Install/Codex/Skill.d.ts.map +1 -0
  44. package/build/dts/Install/Cursor/Mcp.d.ts +45 -0
  45. package/build/dts/Install/Cursor/Mcp.d.ts.map +1 -0
  46. package/build/dts/Install/Cursor/Skill.d.ts +27 -0
  47. package/build/dts/Install/Cursor/Skill.d.ts.map +1 -0
  48. package/build/dts/Install/Domain.d.ts +73 -0
  49. package/build/dts/Install/Domain.d.ts.map +1 -0
  50. package/build/dts/Install/Hook.d.ts +30 -0
  51. package/build/dts/Install/Hook.d.ts.map +1 -0
  52. package/build/dts/Install/Mcp.d.ts +28 -0
  53. package/build/dts/Install/Mcp.d.ts.map +1 -0
  54. package/build/dts/Install/Opencode/Hook.d.ts +23 -0
  55. package/build/dts/Install/Opencode/Hook.d.ts.map +1 -0
  56. package/build/dts/Install/Opencode/Mcp.d.ts +47 -0
  57. package/build/dts/Install/Opencode/Mcp.d.ts.map +1 -0
  58. package/build/dts/Install/Opencode/Skill.d.ts +24 -0
  59. package/build/dts/Install/Opencode/Skill.d.ts.map +1 -0
  60. package/build/dts/Install/Skill.d.ts +32 -0
  61. package/build/dts/Install/Skill.d.ts.map +1 -0
  62. package/build/dts/Install/Uninstall.d.ts +32 -0
  63. package/build/dts/Install/Uninstall.d.ts.map +1 -0
  64. package/build/dts/Mcp/ExpandDocs.d.ts +7 -0
  65. package/build/dts/Mcp/ExpandDocs.d.ts.map +1 -0
  66. package/build/dts/Mcp/RequestHeaders.d.ts +15 -0
  67. package/build/dts/Mcp/RequestHeaders.d.ts.map +1 -0
  68. package/build/dts/Mcp/Server.d.ts +10 -0
  69. package/build/dts/Mcp/Server.d.ts.map +1 -0
  70. package/build/dts/Mcp/Toolkit.d.ts +3 -0
  71. package/build/dts/Mcp/Toolkit.d.ts.map +1 -0
  72. package/build/dts/Mcp/tools/Citations.d.ts +237 -0
  73. package/build/dts/Mcp/tools/Citations.d.ts.map +1 -0
  74. package/build/dts/Mcp/tools/Fetch.d.ts +167 -0
  75. package/build/dts/Mcp/tools/Fetch.d.ts.map +1 -0
  76. package/build/dts/Snippets.d.ts +19 -0
  77. package/build/dts/Snippets.d.ts.map +1 -0
  78. package/build/dts/index.d.ts +2 -0
  79. package/build/dts/index.d.ts.map +1 -0
  80. package/build/dts/main.d.ts +3 -0
  81. package/build/dts/main.d.ts.map +1 -0
  82. package/build/esm/ApiClient.js +182 -0
  83. package/build/esm/ApiClient.js.map +1 -0
  84. package/build/esm/Authentication.js +112 -0
  85. package/build/esm/Authentication.js.map +1 -0
  86. package/build/esm/BrowserOAuth.js +254 -0
  87. package/build/esm/BrowserOAuth.js.map +1 -0
  88. package/build/esm/Commands/Fetch.js +95 -0
  89. package/build/esm/Commands/Fetch.js.map +1 -0
  90. package/build/esm/Commands/Hook.js +20 -0
  91. package/build/esm/Commands/Hook.js.map +1 -0
  92. package/build/esm/Commands/Mcp.js +19 -0
  93. package/build/esm/Commands/Mcp.js.map +1 -0
  94. package/build/esm/Commands/McpRun.js +15 -0
  95. package/build/esm/Commands/McpRun.js.map +1 -0
  96. package/build/esm/Commands/Search.js +43 -0
  97. package/build/esm/Commands/Search.js.map +1 -0
  98. package/build/esm/Commands/Skill.js +17 -0
  99. package/build/esm/Commands/Skill.js.map +1 -0
  100. package/build/esm/Commands/Uninstall.js +28 -0
  101. package/build/esm/Commands/Uninstall.js.map +1 -0
  102. package/build/esm/Config.js +145 -0
  103. package/build/esm/Config.js.map +1 -0
  104. package/build/esm/DeviceAuth.js +127 -0
  105. package/build/esm/DeviceAuth.js.map +1 -0
  106. package/build/esm/Hook/Claude.js +74 -0
  107. package/build/esm/Hook/Claude.js.map +1 -0
  108. package/build/esm/Hook/Errors.js +18 -0
  109. package/build/esm/Hook/Errors.js.map +1 -0
  110. package/build/esm/Hook/Opencode.js +50 -0
  111. package/build/esm/Hook/Opencode.js.map +1 -0
  112. package/build/esm/Hook/SnapshotNote.js +18 -0
  113. package/build/esm/Hook/SnapshotNote.js.map +1 -0
  114. package/build/esm/Install/Claude/Hook.js +89 -0
  115. package/build/esm/Install/Claude/Hook.js.map +1 -0
  116. package/build/esm/Install/Claude/Mcp.js +71 -0
  117. package/build/esm/Install/Claude/Mcp.js.map +1 -0
  118. package/build/esm/Install/Claude/Skill.js +34 -0
  119. package/build/esm/Install/Claude/Skill.js.map +1 -0
  120. package/build/esm/Install/Codex/Mcp.js +92 -0
  121. package/build/esm/Install/Codex/Mcp.js.map +1 -0
  122. package/build/esm/Install/Codex/Skill.js +30 -0
  123. package/build/esm/Install/Codex/Skill.js.map +1 -0
  124. package/build/esm/Install/Cursor/Mcp.js +71 -0
  125. package/build/esm/Install/Cursor/Mcp.js.map +1 -0
  126. package/build/esm/Install/Cursor/Skill.js +37 -0
  127. package/build/esm/Install/Cursor/Skill.js.map +1 -0
  128. package/build/esm/Install/Domain.js +105 -0
  129. package/build/esm/Install/Domain.js.map +1 -0
  130. package/build/esm/Install/Hook.js +38 -0
  131. package/build/esm/Install/Hook.js.map +1 -0
  132. package/build/esm/Install/Mcp.js +40 -0
  133. package/build/esm/Install/Mcp.js.map +1 -0
  134. package/build/esm/Install/Opencode/Hook.js +44 -0
  135. package/build/esm/Install/Opencode/Hook.js.map +1 -0
  136. package/build/esm/Install/Opencode/Mcp.js +73 -0
  137. package/build/esm/Install/Opencode/Mcp.js.map +1 -0
  138. package/build/esm/Install/Opencode/Skill.js +34 -0
  139. package/build/esm/Install/Opencode/Skill.js.map +1 -0
  140. package/build/esm/Install/Opencode/expandai-webfetch.js +38 -0
  141. package/build/esm/Install/Skill.js +41 -0
  142. package/build/esm/Install/Skill.js.map +1 -0
  143. package/build/esm/Install/Uninstall.js +49 -0
  144. package/build/esm/Install/Uninstall.js.map +1 -0
  145. package/build/esm/Mcp/ExpandDocs.js +64 -0
  146. package/build/esm/Mcp/ExpandDocs.js.map +1 -0
  147. package/build/esm/Mcp/RequestHeaders.js +13 -0
  148. package/build/esm/Mcp/RequestHeaders.js.map +1 -0
  149. package/build/esm/Mcp/Server.js +11 -0
  150. package/build/esm/Mcp/Server.js.map +1 -0
  151. package/build/esm/Mcp/Toolkit.js +16 -0
  152. package/build/esm/Mcp/Toolkit.js.map +1 -0
  153. package/build/esm/Mcp/tools/Citations.js +92 -0
  154. package/build/esm/Mcp/tools/Citations.js.map +1 -0
  155. package/build/esm/Mcp/tools/Fetch.js +141 -0
  156. package/build/esm/Mcp/tools/Fetch.js.map +1 -0
  157. package/build/esm/Snippets.js +18 -0
  158. package/build/esm/Snippets.js.map +1 -0
  159. package/build/esm/index.js +2 -0
  160. package/build/esm/index.js.map +1 -0
  161. package/build/esm/main.js +97 -0
  162. package/build/esm/main.js.map +1 -0
  163. package/build/skills/expandai/SKILL.md +72 -0
  164. package/package.json +72 -10
  165. package/src/ApiClient.ts +257 -0
  166. package/src/Authentication.ts +164 -0
  167. package/src/BrowserOAuth.ts +361 -0
  168. package/src/Commands/Fetch.ts +157 -0
  169. package/src/Commands/Hook.ts +48 -0
  170. package/src/Commands/Mcp.ts +30 -0
  171. package/src/Commands/McpRun.ts +25 -0
  172. package/src/Commands/Search.ts +92 -0
  173. package/src/Commands/Skill.ts +30 -0
  174. package/src/Commands/Uninstall.ts +38 -0
  175. package/src/Config.ts +195 -0
  176. package/src/DeviceAuth.ts +193 -0
  177. package/src/Hook/Claude.ts +81 -0
  178. package/src/Hook/Errors.ts +19 -0
  179. package/src/Hook/Opencode.ts +58 -0
  180. package/src/Hook/SnapshotNote.ts +18 -0
  181. package/src/Install/Claude/Hook.ts +133 -0
  182. package/src/Install/Claude/Mcp.ts +82 -0
  183. package/src/Install/Claude/Skill.ts +44 -0
  184. package/src/Install/Codex/Mcp.ts +109 -0
  185. package/src/Install/Codex/Skill.ts +40 -0
  186. package/src/Install/Cursor/Mcp.ts +82 -0
  187. package/src/Install/Cursor/Skill.ts +47 -0
  188. package/src/Install/Domain.ts +148 -0
  189. package/src/Install/Hook.ts +50 -0
  190. package/src/Install/Mcp.ts +71 -0
  191. package/src/Install/Opencode/Hook.ts +55 -0
  192. package/src/Install/Opencode/Mcp.ts +84 -0
  193. package/src/Install/Opencode/Skill.ts +44 -0
  194. package/src/Install/Opencode/expandai-webfetch.d.ts +23 -0
  195. package/src/Install/Opencode/expandai-webfetch.js +38 -0
  196. package/src/Install/Skill.ts +57 -0
  197. package/src/Install/Uninstall.ts +82 -0
  198. package/src/Mcp/ExpandDocs.ts +71 -0
  199. package/src/Mcp/RequestHeaders.ts +16 -0
  200. package/src/Mcp/Server.ts +14 -0
  201. package/src/Mcp/Toolkit.ts +21 -0
  202. package/src/Mcp/tools/Citations.ts +110 -0
  203. package/src/Mcp/tools/Fetch.ts +177 -0
  204. package/src/Snippets.ts +32 -0
  205. package/src/index.ts +1 -0
  206. package/src/main.ts +146 -0
  207. package/bun.lockb +0 -0
  208. package/index.ts +0 -1
  209. package/tsconfig.json +0 -27
@@ -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
+ )