clanka 0.0.4 → 0.0.5

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 (46) hide show
  1. package/dist/Agent.d.ts +45 -19
  2. package/dist/Agent.d.ts.map +1 -1
  3. package/dist/Agent.js +172 -66
  4. package/dist/Agent.js.map +1 -1
  5. package/dist/Codex.d.ts +6 -1
  6. package/dist/Codex.d.ts.map +1 -1
  7. package/dist/Codex.js +16 -3
  8. package/dist/Codex.js.map +1 -1
  9. package/dist/GithubCopilot.d.ts +11 -0
  10. package/dist/GithubCopilot.d.ts.map +1 -0
  11. package/dist/GithubCopilot.js +14 -0
  12. package/dist/GithubCopilot.js.map +1 -0
  13. package/dist/GithubCopilotAuth.d.ts +57 -0
  14. package/dist/GithubCopilotAuth.d.ts.map +1 -0
  15. package/dist/GithubCopilotAuth.js +218 -0
  16. package/dist/GithubCopilotAuth.js.map +1 -0
  17. package/dist/GithubCopilotAuth.test.d.ts +2 -0
  18. package/dist/GithubCopilotAuth.test.d.ts.map +1 -0
  19. package/dist/GithubCopilotAuth.test.js +267 -0
  20. package/dist/GithubCopilotAuth.test.js.map +1 -0
  21. package/dist/OutputFormatter.d.ts +2 -1
  22. package/dist/OutputFormatter.d.ts.map +1 -1
  23. package/dist/OutputFormatter.js +8 -2
  24. package/dist/OutputFormatter.js.map +1 -1
  25. package/dist/ScriptExtraction.d.ts +34 -0
  26. package/dist/ScriptExtraction.d.ts.map +1 -0
  27. package/dist/ScriptExtraction.js +68 -0
  28. package/dist/ScriptExtraction.js.map +1 -0
  29. package/dist/ScriptExtraction.test.d.ts +2 -0
  30. package/dist/ScriptExtraction.test.d.ts.map +1 -0
  31. package/dist/ScriptExtraction.test.js +64 -0
  32. package/dist/ScriptExtraction.test.js.map +1 -0
  33. package/dist/index.d.ts +4 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +4 -0
  36. package/dist/index.js.map +1 -1
  37. package/package.json +3 -1
  38. package/src/Agent.ts +247 -87
  39. package/src/Codex.ts +18 -3
  40. package/src/GithubCopilot.ts +14 -0
  41. package/src/GithubCopilotAuth.test.ts +469 -0
  42. package/src/GithubCopilotAuth.ts +441 -0
  43. package/src/OutputFormatter.ts +11 -4
  44. package/src/ScriptExtraction.test.ts +96 -0
  45. package/src/ScriptExtraction.ts +75 -0
  46. package/src/index.ts +5 -0
@@ -0,0 +1,469 @@
1
+ import { assert, describe, it } from "@effect/vitest"
2
+ import { Deferred, Effect, Fiber, Option, Ref } from "effect"
3
+ import {
4
+ HttpClient,
5
+ HttpClientRequest,
6
+ HttpClientResponse,
7
+ } from "effect/unstable/http"
8
+ import { KeyValueStore } from "effect/unstable/persistence"
9
+ import {
10
+ COPILOT_VISION_REQUEST_HEADER,
11
+ DEFAULT_OPENAI_INTENT,
12
+ DEFAULT_USER_AGENT,
13
+ GithubCopilotAuth,
14
+ GithubCopilotAuthError,
15
+ INITIATOR_HEADER,
16
+ ISSUER,
17
+ OPENAI_INTENT_HEADER,
18
+ STORE_PREFIX,
19
+ STORE_TOKEN_KEY,
20
+ TokenData,
21
+ toGithubCopilotAuthKeyValueStore,
22
+ toTokenStore,
23
+ } from "./GithubCopilotAuth.ts"
24
+
25
+ const jsonResponse = (body: unknown, status = 200): Response =>
26
+ new Response(JSON.stringify(body), {
27
+ status,
28
+ headers: {
29
+ "content-type": "application/json",
30
+ },
31
+ })
32
+
33
+ const getBody = (request: HttpClientRequest.HttpClientRequest): string => {
34
+ if (request.body._tag !== "Uint8Array") {
35
+ throw new Error("Expected request body to be a Uint8Array payload")
36
+ }
37
+
38
+ return new TextDecoder().decode(request.body.body)
39
+ }
40
+
41
+ const makeClient = Effect.fn("makeClient")(function* (
42
+ handler: (
43
+ request: HttpClientRequest.HttpClientRequest,
44
+ attempt: number,
45
+ ) => Response,
46
+ ) {
47
+ const attempts = yield* Ref.make(0)
48
+ const requests = yield* Ref.make<Array<HttpClientRequest.HttpClientRequest>>(
49
+ [],
50
+ )
51
+ const client = HttpClient.make((request) =>
52
+ Effect.gen(function* () {
53
+ const attempt = yield* Ref.updateAndGet(attempts, (count) => count + 1)
54
+ yield* Ref.update(requests, (current) => [...current, request])
55
+ return HttpClientResponse.fromWeb(request, handler(request, attempt))
56
+ }),
57
+ )
58
+
59
+ return {
60
+ attempts,
61
+ client,
62
+ requests,
63
+ } as const
64
+ })
65
+
66
+ const makeEffectClient = Effect.fn("makeEffectClient")(function* (
67
+ handler: (
68
+ request: HttpClientRequest.HttpClientRequest,
69
+ attempt: number,
70
+ ) => Effect.Effect<Response>,
71
+ ) {
72
+ const attempts = yield* Ref.make(0)
73
+ const requests = yield* Ref.make<Array<HttpClientRequest.HttpClientRequest>>(
74
+ [],
75
+ )
76
+ const client = HttpClient.make((request) =>
77
+ Effect.gen(function* () {
78
+ const attempt = yield* Ref.updateAndGet(attempts, (count) => count + 1)
79
+ yield* Ref.update(requests, (current) => [...current, request])
80
+ return HttpClientResponse.fromWeb(
81
+ request,
82
+ yield* handler(request, attempt),
83
+ )
84
+ }),
85
+ )
86
+
87
+ return {
88
+ attempts,
89
+ client,
90
+ requests,
91
+ } as const
92
+ })
93
+
94
+ describe("GithubCopilotAuth", () => {
95
+ it.effect(
96
+ "persists token data through the prefixed schema store",
97
+ Effect.fn(function* () {
98
+ const kvs = yield* KeyValueStore.KeyValueStore
99
+ const tokenStore = toTokenStore(kvs)
100
+ const token = new TokenData({
101
+ access: "copilot-access-token",
102
+ expires: 0,
103
+ })
104
+
105
+ yield* Effect.orDie(tokenStore.set(STORE_TOKEN_KEY, token))
106
+
107
+ const stored = yield* Effect.orDie(tokenStore.get(STORE_TOKEN_KEY))
108
+
109
+ assert.strictEqual(Option.isSome(stored), true)
110
+ if (Option.isNone(stored)) {
111
+ return
112
+ }
113
+
114
+ assert.strictEqual(stored.value.access, token.access)
115
+ assert.strictEqual(stored.value.expires, token.expires)
116
+
117
+ const rawValue = yield* Effect.orDie(
118
+ kvs.get(`${STORE_PREFIX}${STORE_TOKEN_KEY}`),
119
+ )
120
+ const unprefixedValue = yield* Effect.orDie(kvs.get(STORE_TOKEN_KEY))
121
+
122
+ assert.strictEqual(typeof rawValue, "string")
123
+ assert.strictEqual(unprefixedValue, undefined)
124
+ }, Effect.provide(KeyValueStore.layerMemory)),
125
+ )
126
+
127
+ it("treats expires=0 as non-expiring", () => {
128
+ const nonExpiring = new TokenData({
129
+ access: "copilot-access-token",
130
+ expires: 0,
131
+ })
132
+ const expired = new TokenData({
133
+ access: "copilot-access-token",
134
+ expires: Date.now() - 1_000,
135
+ })
136
+ const valid = new TokenData({
137
+ access: "copilot-access-token",
138
+ expires: Date.now() + 60_000,
139
+ })
140
+
141
+ assert.strictEqual(nonExpiring.isExpired(), false)
142
+ assert.strictEqual(expired.isExpired(), true)
143
+ assert.strictEqual(valid.isExpired(), false)
144
+ })
145
+
146
+ it("constructs GithubCopilotAuthError with the expected tagged shape", () => {
147
+ const error = new GithubCopilotAuthError({
148
+ reason: "DeviceFlowFailed",
149
+ message: "Could not authenticate with GitHub Copilot",
150
+ })
151
+
152
+ assert.strictEqual(error._tag, "GithubCopilotAuthError")
153
+ assert.strictEqual(error.reason, "DeviceFlowFailed")
154
+ assert.strictEqual(
155
+ error.message,
156
+ "Could not authenticate with GitHub Copilot",
157
+ )
158
+ })
159
+
160
+ it.effect("returns a cached token without performing any requests", () =>
161
+ Effect.gen(function* () {
162
+ const kvs = yield* KeyValueStore.KeyValueStore
163
+ const tokenStore = toTokenStore(kvs)
164
+ yield* Effect.orDie(
165
+ tokenStore.set(
166
+ STORE_TOKEN_KEY,
167
+ new TokenData({
168
+ access: "cached-copilot-token",
169
+ expires: 0,
170
+ }),
171
+ ),
172
+ )
173
+
174
+ const { attempts, client } = yield* makeClient(
175
+ () => new Response(null, { status: 500 }),
176
+ )
177
+
178
+ const auth = yield* GithubCopilotAuth.make.pipe(
179
+ Effect.provideService(HttpClient.HttpClient, client),
180
+ )
181
+
182
+ const token = yield* auth.get
183
+
184
+ assert.strictEqual(token.access, "cached-copilot-token")
185
+ assert.strictEqual(yield* Ref.get(attempts), 0)
186
+ }).pipe(Effect.provide(KeyValueStore.layerMemory)),
187
+ )
188
+
189
+ it.effect(
190
+ "authenticates, persists the token, and clears memory plus storage on logout",
191
+ () =>
192
+ Effect.gen(function* () {
193
+ const kvs = yield* KeyValueStore.KeyValueStore
194
+ const tokenStore = toTokenStore(kvs)
195
+
196
+ let authCount = 0
197
+ const { attempts, client, requests } = yield* makeClient((request) => {
198
+ if (request.url === `${ISSUER}/login/device/code`) {
199
+ authCount += 1
200
+ return jsonResponse({
201
+ device_code: `device-code-${authCount}`,
202
+ user_code: `USER-${authCount}`,
203
+ verification_uri: `${ISSUER}/login/device`,
204
+ interval: 1,
205
+ })
206
+ }
207
+
208
+ if (request.url === `${ISSUER}/login/oauth/access_token`) {
209
+ return jsonResponse({
210
+ access_token: `copilot-token-${authCount}`,
211
+ })
212
+ }
213
+
214
+ return new Response(null, { status: 500 })
215
+ })
216
+
217
+ const auth = yield* GithubCopilotAuth.make.pipe(
218
+ Effect.provideService(HttpClient.HttpClient, client),
219
+ )
220
+
221
+ const authenticated = yield* auth.authenticate
222
+ assert.strictEqual(authenticated.access, "copilot-token-1")
223
+ assert.strictEqual(yield* Ref.get(attempts), 2)
224
+
225
+ const stored = yield* Effect.orDie(tokenStore.get(STORE_TOKEN_KEY))
226
+ assert.strictEqual(Option.isSome(stored), true)
227
+ if (Option.isSome(stored)) {
228
+ assert.strictEqual(stored.value.access, "copilot-token-1")
229
+ }
230
+
231
+ yield* auth.logout
232
+ const storedAfterLogout = yield* Effect.orDie(
233
+ tokenStore.get(STORE_TOKEN_KEY),
234
+ )
235
+ assert.strictEqual(Option.isNone(storedAfterLogout), true)
236
+
237
+ const tokenAfterLogout = yield* auth.get
238
+ assert.strictEqual(tokenAfterLogout.access, "copilot-token-2")
239
+ assert.strictEqual(yield* Ref.get(attempts), 4)
240
+
241
+ const seenRequests = yield* Ref.get(requests)
242
+ assert.strictEqual(seenRequests.length, 4)
243
+ assert.strictEqual(seenRequests[0]?.url, `${ISSUER}/login/device/code`)
244
+ assert.strictEqual(
245
+ getBody(seenRequests[0]!).includes(
246
+ `"client_id":"Ov23li8tweQw6odWQebz"`,
247
+ ),
248
+ true,
249
+ )
250
+ }).pipe(Effect.provide(KeyValueStore.layerMemory)),
251
+ )
252
+
253
+ it.effect("clears corrupted persisted tokens before re-authenticating", () =>
254
+ Effect.gen(function* () {
255
+ const kvs = yield* KeyValueStore.KeyValueStore
256
+ yield* Effect.orDie(
257
+ kvs.set(`${STORE_PREFIX}${STORE_TOKEN_KEY}`, "not-json"),
258
+ )
259
+
260
+ const { attempts, client } = yield* makeClient((request) => {
261
+ if (request.url === `${ISSUER}/login/device/code`) {
262
+ return jsonResponse({
263
+ device_code: "device-code",
264
+ user_code: "ABCD-EFGH",
265
+ verification_uri: `${ISSUER}/login/device`,
266
+ interval: 1,
267
+ })
268
+ }
269
+
270
+ if (request.url === `${ISSUER}/login/oauth/access_token`) {
271
+ return jsonResponse({
272
+ access_token: "fresh-copilot-token",
273
+ })
274
+ }
275
+
276
+ return new Response(null, { status: 500 })
277
+ })
278
+
279
+ const auth = yield* GithubCopilotAuth.make.pipe(
280
+ Effect.provideService(HttpClient.HttpClient, client),
281
+ )
282
+
283
+ assert.strictEqual(
284
+ yield* Effect.orDie(kvs.get(`${STORE_PREFIX}${STORE_TOKEN_KEY}`)),
285
+ undefined,
286
+ )
287
+ assert.strictEqual(yield* Ref.get(attempts), 0)
288
+
289
+ const token = yield* auth.get
290
+
291
+ assert.strictEqual(token.access, "fresh-copilot-token")
292
+ assert.strictEqual(yield* Ref.get(attempts), 2)
293
+ }).pipe(Effect.provide(KeyValueStore.layerMemory)),
294
+ )
295
+
296
+ it.effect("serializes concurrent get calls behind one authentication", () =>
297
+ Effect.gen(function* () {
298
+ const authStarted = yield* Deferred.make<void>()
299
+ const releaseAuth = yield* Deferred.make<void>()
300
+ const { attempts, client } = yield* makeEffectClient((request) => {
301
+ if (request.url === `${ISSUER}/login/device/code`) {
302
+ return Effect.succeed(
303
+ jsonResponse({
304
+ device_code: "device-code",
305
+ user_code: "ABCD-EFGH",
306
+ verification_uri: `${ISSUER}/login/device`,
307
+ interval: 1,
308
+ }),
309
+ )
310
+ }
311
+
312
+ if (request.url === `${ISSUER}/login/oauth/access_token`) {
313
+ return Effect.gen(function* () {
314
+ yield* Deferred.succeed(authStarted, void 0)
315
+ yield* Deferred.await(releaseAuth)
316
+ return jsonResponse({
317
+ access_token: "shared-copilot-token",
318
+ })
319
+ })
320
+ }
321
+
322
+ return Effect.succeed(new Response(null, { status: 500 }))
323
+ })
324
+
325
+ const auth = yield* GithubCopilotAuth.make.pipe(
326
+ Effect.provideService(HttpClient.HttpClient, client),
327
+ )
328
+
329
+ const firstFiber = yield* auth.get.pipe(
330
+ Effect.forkChild({ startImmediately: true }),
331
+ )
332
+ yield* Deferred.await(authStarted)
333
+
334
+ const secondFiber = yield* auth.get.pipe(
335
+ Effect.forkChild({ startImmediately: true }),
336
+ )
337
+ yield* Effect.yieldNow
338
+
339
+ assert.strictEqual(yield* Ref.get(attempts), 2)
340
+
341
+ yield* Deferred.succeed(releaseAuth, void 0)
342
+
343
+ const first = yield* Fiber.join(firstFiber)
344
+ const second = yield* Fiber.join(secondFiber)
345
+
346
+ assert.strictEqual(yield* Ref.get(attempts), 2)
347
+ assert.strictEqual(first.access, "shared-copilot-token")
348
+ assert.strictEqual(second.access, "shared-copilot-token")
349
+ }).pipe(Effect.provide(KeyValueStore.layerMemory)),
350
+ )
351
+
352
+ it.effect(
353
+ "injects Copilot auth and request metadata headers through the client layer",
354
+ () =>
355
+ Effect.gen(function* () {
356
+ const kvs = yield* KeyValueStore.KeyValueStore
357
+ const tokenStore = toTokenStore(kvs)
358
+ yield* Effect.orDie(
359
+ tokenStore.set(
360
+ STORE_TOKEN_KEY,
361
+ new TokenData({
362
+ access: "copilot-token",
363
+ expires: 0,
364
+ }),
365
+ ),
366
+ )
367
+
368
+ const { client, requests } = yield* makeClient(() =>
369
+ jsonResponse({ ok: true }),
370
+ )
371
+
372
+ const wrappedClient = yield* Effect.gen(function* () {
373
+ return yield* HttpClient.HttpClient
374
+ }).pipe(
375
+ Effect.provide(GithubCopilotAuth.layerClientNoDeps),
376
+ Effect.provideService(HttpClient.HttpClient, client),
377
+ Effect.provideService(
378
+ GithubCopilotAuth,
379
+ yield* GithubCopilotAuth.make.pipe(
380
+ Effect.provideService(HttpClient.HttpClient, client),
381
+ ),
382
+ ),
383
+ )
384
+
385
+ yield* HttpClientRequest.post(
386
+ "https://api.githubcopilot.com/chat/completions",
387
+ ).pipe(
388
+ HttpClientRequest.bodyJsonUnsafe({
389
+ model: "gpt-4.1",
390
+ messages: [
391
+ {
392
+ role: "user",
393
+ content: [
394
+ {
395
+ type: "text",
396
+ text: "describe this image",
397
+ },
398
+ {
399
+ type: "image_url",
400
+ image_url: {
401
+ url: "https://example.com/image.png",
402
+ },
403
+ },
404
+ ],
405
+ },
406
+ ],
407
+ }),
408
+ wrappedClient.execute,
409
+ )
410
+
411
+ yield* HttpClientRequest.post(
412
+ "https://api.githubcopilot.com/chat/completions",
413
+ ).pipe(
414
+ HttpClientRequest.bodyJsonUnsafe({
415
+ model: "gpt-4.1",
416
+ messages: [
417
+ {
418
+ role: "assistant",
419
+ content: "running tools",
420
+ },
421
+ ],
422
+ }),
423
+ wrappedClient.execute,
424
+ )
425
+
426
+ const seenRequests = yield* Ref.get(requests)
427
+ const visionRequest = seenRequests[0]!
428
+ const agentRequest = seenRequests[1]!
429
+
430
+ assert.strictEqual(
431
+ visionRequest.headers.authorization,
432
+ "Bearer copilot-token",
433
+ )
434
+ assert.strictEqual(
435
+ visionRequest.headers["user-agent"],
436
+ DEFAULT_USER_AGENT,
437
+ )
438
+ assert.strictEqual(
439
+ visionRequest.headers[OPENAI_INTENT_HEADER.toLowerCase()],
440
+ DEFAULT_OPENAI_INTENT,
441
+ )
442
+ assert.strictEqual(visionRequest.headers[INITIATOR_HEADER], "user")
443
+ assert.strictEqual(
444
+ visionRequest.headers[COPILOT_VISION_REQUEST_HEADER.toLowerCase()],
445
+ "true",
446
+ )
447
+ assert.strictEqual(agentRequest.headers[INITIATOR_HEADER], "agent")
448
+ }).pipe(Effect.provide(KeyValueStore.layerMemory)),
449
+ )
450
+
451
+ it.effect(
452
+ "exposes the prefixed store helper",
453
+ Effect.fn(function* () {
454
+ const kvs = yield* KeyValueStore.KeyValueStore
455
+ const prefixedStore = toGithubCopilotAuthKeyValueStore(kvs)
456
+
457
+ yield* Effect.orDie(prefixedStore.set(STORE_TOKEN_KEY, "raw-token"))
458
+
459
+ assert.strictEqual(
460
+ yield* Effect.orDie(kvs.get(`${STORE_PREFIX}${STORE_TOKEN_KEY}`)),
461
+ "raw-token",
462
+ )
463
+ assert.strictEqual(
464
+ yield* Effect.orDie(kvs.get(STORE_TOKEN_KEY)),
465
+ undefined,
466
+ )
467
+ }, Effect.provide(KeyValueStore.layerMemory)),
468
+ )
469
+ })