clanka 0.6.1 → 0.7.0

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/src/XaiAuth.ts ADDED
@@ -0,0 +1,272 @@
1
+ /**
2
+ * @since 1.0.0
3
+ */
4
+ /** @effect-diagnostics schemaNumber:off */
5
+ import * as Context from "effect/Context"
6
+ import * as Effect from "effect/Effect"
7
+ import * as Layer from "effect/Layer"
8
+ import * as Option from "effect/Option"
9
+ import * as Schedule from "effect/Schedule"
10
+ import * as Schema from "effect/Schema"
11
+ import * as Semaphore from "effect/Semaphore"
12
+ import * as HttpClient from "effect/unstable/http/HttpClient"
13
+ import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
14
+ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
15
+ import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"
16
+ import { DeviceCodeHandler } from "./DeviceCodeHandler.ts"
17
+
18
+ const CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
19
+ const ISSUER = "https://auth.x.ai"
20
+ const TOKEN_URL = ISSUER + "/oauth2/token"
21
+ const DEFAULT_TOKEN_EXPIRY_SECONDS = 3600
22
+ const DEFAULT_DEVICE_EXPIRY_SECONDS = 600
23
+
24
+ export class TokenData extends Schema.Class<TokenData>(
25
+ "clanka/XaiAuth/TokenData",
26
+ )({
27
+ access: Schema.String,
28
+ refresh: Schema.String,
29
+ expires: Schema.Number,
30
+ }) {
31
+ isExpired(): boolean {
32
+ return this.expires < Date.now() + 30_000
33
+ }
34
+ }
35
+
36
+ export class XaiAuthError extends Schema.TaggedError<XaiAuthError>()(
37
+ "XaiAuthError",
38
+ {
39
+ reason: Schema.Literals(["DeviceFlowFailed", "RefreshFailed"]),
40
+ message: Schema.String,
41
+ cause: Schema.optional(Schema.Defect()),
42
+ },
43
+ ) {}
44
+
45
+ const DeviceCodeResponse = Schema.Struct({
46
+ device_code: Schema.String,
47
+ user_code: Schema.String,
48
+ verification_uri: Schema.String,
49
+ expires_in: Schema.Number,
50
+ interval: Schema.optional(Schema.Number),
51
+ })
52
+ const TokenResponse = Schema.Struct({
53
+ access_token: Schema.String,
54
+ refresh_token: Schema.optional(Schema.String),
55
+ expires_in: Schema.optional(Schema.Number),
56
+ })
57
+ const TokenError = Schema.Struct({
58
+ error: Schema.String,
59
+ interval: Schema.optional(Schema.Number),
60
+ })
61
+ const PollResponse = Schema.Union([TokenResponse, TokenError])
62
+ const toTokenData = (token: typeof TokenResponse.Type, refresh = "") =>
63
+ new TokenData({
64
+ access: token.access_token,
65
+ refresh: token.refresh_token || refresh,
66
+ expires:
67
+ Date.now() + (token.expires_in ?? DEFAULT_TOKEN_EXPIRY_SECONDS) * 1000,
68
+ })
69
+
70
+ export const toTokenStore = (store: KeyValueStore.KeyValueStore) =>
71
+ KeyValueStore.toSchemaStore(
72
+ KeyValueStore.prefix(store, "xai.auth/"),
73
+ TokenData,
74
+ )
75
+
76
+ export class XaiAuth extends Context.Service<
77
+ XaiAuth,
78
+ {
79
+ readonly get: Effect.Effect<TokenData, XaiAuthError>
80
+ readonly authenticate: Effect.Effect<TokenData, XaiAuthError>
81
+ readonly logout: Effect.Effect<void>
82
+ }
83
+ >()("clanka/XaiAuth") {
84
+ static readonly make = Effect.gen(function* () {
85
+ const verification = yield* DeviceCodeHandler
86
+ const tokenStore = toTokenStore(yield* KeyValueStore.KeyValueStore)
87
+ const httpClient = (yield* HttpClient.HttpClient).pipe(
88
+ HttpClient.mapRequest(
89
+ HttpClientRequest.setHeader("User-Agent", "clanka"),
90
+ ),
91
+ HttpClient.retryTransient({
92
+ retryOn: "errors-and-responses",
93
+ times: 5,
94
+ schedule: Schedule.min([
95
+ Schedule.exponential(150),
96
+ Schedule.spaced(5000),
97
+ ]),
98
+ }),
99
+ )
100
+ const semaphore = Semaphore.makeUnsafe(1)
101
+ let currentToken = yield* tokenStore.get("token").pipe(
102
+ Effect.catchTag("SchemaError", () =>
103
+ tokenStore.remove("token").pipe(Effect.as(Option.none())),
104
+ ),
105
+ Effect.orDie,
106
+ )
107
+ const saveToken = (token: TokenData) =>
108
+ tokenStore.set("token", token).pipe(
109
+ Effect.orDie,
110
+ Effect.tap(() =>
111
+ Effect.sync(() => {
112
+ currentToken = Option.some(token)
113
+ }),
114
+ ),
115
+ Effect.as(token),
116
+ )
117
+ const clearToken = tokenStore.remove("token").pipe(
118
+ Effect.orDie,
119
+ Effect.tap(() =>
120
+ Effect.sync(() => {
121
+ currentToken = Option.none()
122
+ }),
123
+ ),
124
+ )
125
+
126
+ const authenticateWithDeviceFlow = Effect.gen(function* () {
127
+ const response = yield* HttpClientRequest.post(
128
+ ISSUER + "/oauth2/device/code",
129
+ ).pipe(
130
+ HttpClientRequest.bodyUrlParams({
131
+ client_id: CLIENT_ID,
132
+ scope:
133
+ "openid profile email offline_access grok-cli:access api:access",
134
+ referrer: "clanka",
135
+ }),
136
+ httpClient.execute,
137
+ Effect.flatMap(HttpClientResponse.filterStatusOk),
138
+ )
139
+ const device =
140
+ yield* HttpClientResponse.schemaBodyJson(DeviceCodeResponse)(response)
141
+ yield* verification.onCode({
142
+ verifyUrl: device.verification_uri,
143
+ deviceCode: device.user_code,
144
+ })
145
+ const request = HttpClientRequest.post(TOKEN_URL).pipe(
146
+ HttpClientRequest.bodyUrlParams({
147
+ client_id: CLIENT_ID,
148
+ device_code: device.device_code,
149
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
150
+ }),
151
+ )
152
+ let delayMs = Math.max(device.interval ?? 5, 1) * 1000
153
+ const poll = Effect.gen(function* () {
154
+ while (true) {
155
+ // OAuth errors arrive as HTTP 400; decode their bodies before deciding whether to retry.
156
+ const response = yield* httpClient.execute(request)
157
+ const payload =
158
+ yield* HttpClientResponse.schemaBodyJson(PollResponse)(response)
159
+ if ("access_token" in payload) {
160
+ yield* HttpClientResponse.filterStatusOk(response)
161
+ return toTokenData(payload)
162
+ }
163
+ if (payload.error === "slow_down") {
164
+ delayMs = Math.max(delayMs, (payload.interval ?? 0) * 1000) + 5000
165
+ } else if (payload.error !== "authorization_pending") {
166
+ return yield* new XaiAuthError({
167
+ reason: "DeviceFlowFailed",
168
+ message: `xAI device authorization failed: ${payload.error}`,
169
+ })
170
+ }
171
+ yield* Effect.sleep(delayMs + 3000)
172
+ }
173
+ })
174
+ return yield* poll.pipe(
175
+ Effect.timeoutOrElse({
176
+ duration:
177
+ (device.expires_in > 0
178
+ ? device.expires_in
179
+ : DEFAULT_DEVICE_EXPIRY_SECONDS) * 1000,
180
+ orElse: () =>
181
+ Effect.fail(
182
+ new XaiAuthError({
183
+ reason: "DeviceFlowFailed",
184
+ message: "xAI device authorization failed: expired_token",
185
+ }),
186
+ ),
187
+ }),
188
+ )
189
+ }).pipe(
190
+ Effect.mapError((cause) =>
191
+ cause instanceof XaiAuthError
192
+ ? cause
193
+ : new XaiAuthError({
194
+ reason: "DeviceFlowFailed",
195
+ message: "Failed to authorize xAI device",
196
+ cause,
197
+ }),
198
+ ),
199
+ )
200
+
201
+ const refreshToken = Effect.fn("XaiAuth.refreshToken")(
202
+ function* (refresh: string) {
203
+ const response = yield* HttpClientRequest.post(TOKEN_URL).pipe(
204
+ HttpClientRequest.bodyUrlParams({
205
+ client_id: CLIENT_ID,
206
+ grant_type: "refresh_token",
207
+ refresh_token: refresh,
208
+ }),
209
+ httpClient.execute,
210
+ Effect.flatMap(HttpClientResponse.filterStatusOk),
211
+ )
212
+ return toTokenData(
213
+ yield* HttpClientResponse.schemaBodyJson(TokenResponse)(response),
214
+ refresh,
215
+ )
216
+ },
217
+ Effect.mapError(
218
+ (cause) =>
219
+ new XaiAuthError({
220
+ reason: "RefreshFailed",
221
+ message: "Failed to refresh xAI access token",
222
+ cause,
223
+ }),
224
+ ),
225
+ )
226
+
227
+ const authenticate = Effect.uninterruptibleMask(
228
+ Effect.fnUntraced(function* (restore) {
229
+ return yield* saveToken(yield* restore(authenticateWithDeviceFlow))
230
+ }),
231
+ )
232
+ const get = Effect.uninterruptibleMask(
233
+ Effect.fnUntraced(function* (restore) {
234
+ if (Option.isSome(currentToken)) {
235
+ if (!currentToken.value.isExpired()) return currentToken.value
236
+ const refreshed = yield* restore(
237
+ refreshToken(currentToken.value.refresh).pipe(Effect.option),
238
+ )
239
+ if (Option.isSome(refreshed)) return yield* saveToken(refreshed.value)
240
+ yield* clearToken
241
+ }
242
+ return yield* saveToken(yield* restore(authenticateWithDeviceFlow))
243
+ }),
244
+ )
245
+ return XaiAuth.of({
246
+ get: semaphore.withPermit(get),
247
+ authenticate: semaphore.withPermit(authenticate),
248
+ logout: semaphore.withPermit(Effect.uninterruptible(clearToken)),
249
+ })
250
+ })
251
+
252
+ static readonly layer = Layer.effect(XaiAuth, XaiAuth.make)
253
+ static readonly layerClient = Layer.effect(
254
+ HttpClient.HttpClient,
255
+ Effect.gen(function* () {
256
+ const auth = yield* XaiAuth
257
+ return (yield* HttpClient.HttpClient).pipe(
258
+ HttpClient.mapRequestEffect((request) =>
259
+ auth.get.pipe(
260
+ Effect.map((token) =>
261
+ request.pipe(
262
+ HttpClientRequest.bearerToken(token.access),
263
+ HttpClientRequest.setHeader("User-Agent", "clanka"),
264
+ ),
265
+ ),
266
+ Effect.orDie,
267
+ ),
268
+ ),
269
+ )
270
+ }),
271
+ ).pipe(Layer.provide(XaiAuth.layer))
272
+ }
package/src/cli.ts CHANGED
@@ -11,6 +11,7 @@ import * as Acp from "./Acp.ts"
11
11
  import * as AgentExecutor from "./AgentExecutor.ts"
12
12
  import * as Codex from "./Codex.ts"
13
13
  import * as Copilot from "./Copilot.ts"
14
+ import * as Xai from "./Xai.ts"
14
15
  import * as Agent from "./Agent.ts"
15
16
  import * as Compaction from "./Compaction.ts"
16
17
  import * as Stream from "effect/Stream"
@@ -32,9 +33,9 @@ import packageJson from "../package.json" with { type: "json" }
32
33
 
33
34
  const version = packageJson.version
34
35
 
35
- type Provider = "openai" | "copilot"
36
+ type Provider = "openai" | "copilot" | "xai"
36
37
 
37
- const providers: ReadonlyArray<Provider> = ["openai", "copilot"]
38
+ const providers: ReadonlyArray<Provider> = ["openai", "copilot", "xai"]
38
39
 
39
40
  const withSubagentModel = <E, R>(
40
41
  model: Layer.Layer<
@@ -49,9 +50,13 @@ const modelLayer = (provider: Provider, model: string, effort: string) =>
49
50
  ? withSubagentModel(
50
51
  Codex.modelWebSocket(model, { reasoning: { effort: effort as any } }),
51
52
  ).pipe(Layer.provide(Codex.layerClient))
52
- : withSubagentModel(Copilot.model(model, { reasoning: { effort } })).pipe(
53
- Layer.provide(Copilot.layerClient),
54
- )
53
+ : provider === "xai"
54
+ ? withSubagentModel(
55
+ Xai.model(model, { reasoning: { effort: effort as any } }),
56
+ ).pipe(Layer.provide(Xai.layerClient))
57
+ : withSubagentModel(Copilot.model(model, { reasoning: { effort } })).pipe(
58
+ Layer.provide(Copilot.layerClient),
59
+ )
55
60
 
56
61
  const parseModelId = (modelId: string) => {
57
62
  const [provider, model, effort, ...rest] = modelId.split("/")
@@ -89,27 +94,16 @@ const provider = Flag.Literals("provider", providers).pipe(
89
94
  title: "copilot",
90
95
  value: "copilot",
91
96
  },
97
+ {
98
+ title: "xai",
99
+ value: "xai",
100
+ },
92
101
  ],
93
102
  }),
94
103
  ),
95
104
  )
96
105
 
97
- const model = Flag.String("model").pipe(
98
- Flag.withAlias("m"),
99
- Flag.withFallbackPrompt(
100
- Prompt.String({
101
- message: "Enter a model",
102
- default: "gpt-6-astra/medium",
103
- validate(value) {
104
- const parts = value.split("/")
105
- if (parts.length !== 2) {
106
- return Effect.fail("Invalid model")
107
- }
108
- return Effect.succeed(value)
109
- },
110
- }),
111
- ),
112
- )
106
+ const model = Flag.String("model").pipe(Flag.withAlias("m"), Flag.optional)
113
107
 
114
108
  const semantic = Flag.Directory("search").pipe(
115
109
  Flag.withDescription(
@@ -237,7 +231,21 @@ Command.make("clanka", {
237
231
  prompt: nonInteractivePrompt,
238
232
  }) {
239
233
  const stdio = yield* Stdio.Stdio
240
- const [model, reasoning] = modelRaw.split("/") as [string, string]
234
+ const selectedModel = yield* Option.match(modelRaw, {
235
+ onSome: Effect.succeed,
236
+ onNone: () =>
237
+ Prompt.String({
238
+ message: "Enter a model",
239
+ default:
240
+ provider === "xai" ? "grok-4.6/high" : "gpt-6-astra/medium",
241
+ validate(value) {
242
+ return value.split("/").length === 2
243
+ ? Effect.succeed(value)
244
+ : Effect.fail("Invalid model")
245
+ },
246
+ }),
247
+ })
248
+ const [model, reasoning] = selectedModel.split("/") as [string, string]
241
249
  const Model = modelLayer(provider, model, reasoning)
242
250
 
243
251
  return yield* Effect.gen(function* () {
package/src/index.ts CHANGED
@@ -73,3 +73,8 @@ export * as ToolkitRenderer from "./ToolkitRenderer.ts"
73
73
  * @since 1.0.0
74
74
  */
75
75
  export * as TypeBuilder from "./TypeBuilder.ts"
76
+
77
+ /**
78
+ * @since 1.0.0
79
+ */
80
+ export * as Xai from "./Xai.ts"