expandai 0.0.2 → 0.0.3

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 (271) hide show
  1. package/README.md +42 -5
  2. package/build/dts/ApiClient.d.ts +128 -194
  3. package/build/dts/ApiClient.d.ts.map +1 -1
  4. package/build/dts/Auth/Domain.d.ts +95 -0
  5. package/build/dts/Auth/Domain.d.ts.map +1 -0
  6. package/build/dts/Auth/ManualAuthorization.d.ts +107 -0
  7. package/build/dts/Auth/ManualAuthorization.d.ts.map +1 -0
  8. package/build/dts/Auth/PendingInit.d.ts +35 -0
  9. package/build/dts/Auth/PendingInit.d.ts.map +1 -0
  10. package/build/dts/Auth/Pkce.d.ts +10 -0
  11. package/build/dts/Auth/Pkce.d.ts.map +1 -0
  12. package/build/dts/Auth/SetupGrantAuthorization.d.ts +34 -0
  13. package/build/dts/Auth/SetupGrantAuthorization.d.ts.map +1 -0
  14. package/build/dts/Authentication.d.ts +10 -9
  15. package/build/dts/Authentication.d.ts.map +1 -1
  16. package/build/dts/BrowserOAuth.d.ts +7 -7
  17. package/build/dts/BrowserOAuth.d.ts.map +1 -1
  18. package/build/dts/CommandPath.d.ts +2 -0
  19. package/build/dts/CommandPath.d.ts.map +1 -0
  20. package/build/dts/Commands/Fetch.d.ts.map +1 -1
  21. package/build/dts/Commands/Hook.d.ts +5 -5
  22. package/build/dts/Commands/Init.d.ts +55 -0
  23. package/build/dts/Commands/Init.d.ts.map +1 -0
  24. package/build/dts/Commands/Mcp.d.ts +6 -6
  25. package/build/dts/Commands/Search.d.ts.map +1 -1
  26. package/build/dts/Commands/Skill.d.ts +4 -4
  27. package/build/dts/Commands/Status.d.ts +27 -0
  28. package/build/dts/Commands/Status.d.ts.map +1 -0
  29. package/build/dts/Commands/Telemetry.d.ts +7 -0
  30. package/build/dts/Commands/Telemetry.d.ts.map +1 -0
  31. package/build/dts/Commands/Uninstall.d.ts +4 -4
  32. package/build/dts/Config.d.ts +108 -17
  33. package/build/dts/Config.d.ts.map +1 -1
  34. package/build/dts/Hook/SnapshotNote.d.ts +8 -4
  35. package/build/dts/Hook/SnapshotNote.d.ts.map +1 -1
  36. package/build/dts/Init/ClaudeAdapter.d.ts +61 -0
  37. package/build/dts/Init/ClaudeAdapter.d.ts.map +1 -0
  38. package/build/dts/Init/ClaudeProfile.d.ts +78 -0
  39. package/build/dts/Init/ClaudeProfile.d.ts.map +1 -0
  40. package/build/dts/Init/Domain.d.ts +139 -0
  41. package/build/dts/Init/Domain.d.ts.map +1 -0
  42. package/build/dts/Init/HarnessAdapter.d.ts +33 -0
  43. package/build/dts/Init/HarnessAdapter.d.ts.map +1 -0
  44. package/build/dts/Init/HarnessDetector.d.ts +19 -0
  45. package/build/dts/Init/HarnessDetector.d.ts.map +1 -0
  46. package/build/dts/Init/HarnessRegistry.d.ts +50 -0
  47. package/build/dts/Init/HarnessRegistry.d.ts.map +1 -0
  48. package/build/dts/Init/MutationTransaction.d.ts +96 -0
  49. package/build/dts/Init/MutationTransaction.d.ts.map +1 -0
  50. package/build/dts/Init/Service.d.ts +72 -0
  51. package/build/dts/Init/Service.d.ts.map +1 -0
  52. package/build/dts/Init/StandardAdapters.d.ts +42 -0
  53. package/build/dts/Init/StandardAdapters.d.ts.map +1 -0
  54. package/build/dts/Init/Status.d.ts +149 -0
  55. package/build/dts/Init/Status.d.ts.map +1 -0
  56. package/build/dts/Install/Claude/Hook.d.ts +85 -25
  57. package/build/dts/Install/Claude/Hook.d.ts.map +1 -1
  58. package/build/dts/Install/Claude/Mcp.d.ts +21 -18
  59. package/build/dts/Install/Claude/Mcp.d.ts.map +1 -1
  60. package/build/dts/Install/Claude/Skill.d.ts +14 -15
  61. package/build/dts/Install/Claude/Skill.d.ts.map +1 -1
  62. package/build/dts/Install/Codex/Mcp.d.ts +14 -8
  63. package/build/dts/Install/Codex/Mcp.d.ts.map +1 -1
  64. package/build/dts/Install/Codex/Skill.d.ts +4 -2
  65. package/build/dts/Install/Codex/Skill.d.ts.map +1 -1
  66. package/build/dts/Install/Cursor/Mcp.d.ts +13 -13
  67. package/build/dts/Install/Cursor/Mcp.d.ts.map +1 -1
  68. package/build/dts/Install/Cursor/Skill.d.ts +4 -2
  69. package/build/dts/Install/Cursor/Skill.d.ts.map +1 -1
  70. package/build/dts/Install/Domain.d.ts +35 -5
  71. package/build/dts/Install/Domain.d.ts.map +1 -1
  72. package/build/dts/Install/Hook.d.ts +4 -4
  73. package/build/dts/Install/Hook.d.ts.map +1 -1
  74. package/build/dts/Install/Mcp.d.ts +5 -5
  75. package/build/dts/Install/Mcp.d.ts.map +1 -1
  76. package/build/dts/Install/Opencode/Hook.d.ts +4 -3
  77. package/build/dts/Install/Opencode/Hook.d.ts.map +1 -1
  78. package/build/dts/Install/Opencode/Mcp.d.ts +11 -11
  79. package/build/dts/Install/Opencode/Mcp.d.ts.map +1 -1
  80. package/build/dts/Install/Opencode/Skill.d.ts +4 -2
  81. package/build/dts/Install/Opencode/Skill.d.ts.map +1 -1
  82. package/build/dts/Install/Skill.d.ts +3 -3
  83. package/build/dts/Install/Uninstall.d.ts +6 -6
  84. package/build/dts/Install/Uninstall.d.ts.map +1 -1
  85. package/build/dts/MarkdownAnsi.d.ts +29 -0
  86. package/build/dts/MarkdownAnsi.d.ts.map +1 -0
  87. package/build/dts/Mcp/ExpandDocs.d.ts +17 -5
  88. package/build/dts/Mcp/ExpandDocs.d.ts.map +1 -1
  89. package/build/dts/Mcp/Server.d.ts +1 -1
  90. package/build/dts/Mcp/Server.d.ts.map +1 -1
  91. package/build/dts/Mcp/tools/Citations.d.ts +100 -71
  92. package/build/dts/Mcp/tools/Citations.d.ts.map +1 -1
  93. package/build/dts/Mcp/tools/Fetch.d.ts +16 -15
  94. package/build/dts/Mcp/tools/Fetch.d.ts.map +1 -1
  95. package/build/dts/SecureFile.d.ts +23 -0
  96. package/build/dts/SecureFile.d.ts.map +1 -0
  97. package/build/dts/Snippets.d.ts +11 -12
  98. package/build/dts/Snippets.d.ts.map +1 -1
  99. package/build/dts/Telemetry.d.ts +30 -0
  100. package/build/dts/Telemetry.d.ts.map +1 -0
  101. package/build/dts/Version.d.ts +2 -0
  102. package/build/dts/Version.d.ts.map +1 -0
  103. package/build/dts/Xdg.d.ts +11 -0
  104. package/build/dts/Xdg.d.ts.map +1 -0
  105. package/build/esm/ApiClient.js +133 -10
  106. package/build/esm/ApiClient.js.map +1 -1
  107. package/build/esm/Auth/Domain.js +56 -0
  108. package/build/esm/Auth/Domain.js.map +1 -0
  109. package/build/esm/Auth/ManualAuthorization.js +253 -0
  110. package/build/esm/Auth/ManualAuthorization.js.map +1 -0
  111. package/build/esm/Auth/PendingInit.js +267 -0
  112. package/build/esm/Auth/PendingInit.js.map +1 -0
  113. package/build/esm/Auth/Pkce.js +23 -0
  114. package/build/esm/Auth/Pkce.js.map +1 -0
  115. package/build/esm/Auth/SetupGrantAuthorization.js +91 -0
  116. package/build/esm/Auth/SetupGrantAuthorization.js.map +1 -0
  117. package/build/esm/Authentication.js +38 -7
  118. package/build/esm/Authentication.js.map +1 -1
  119. package/build/esm/BrowserOAuth.js +7 -10
  120. package/build/esm/BrowserOAuth.js.map +1 -1
  121. package/build/esm/CommandPath.js +31 -0
  122. package/build/esm/CommandPath.js.map +1 -0
  123. package/build/esm/Commands/Fetch.js +30 -6
  124. package/build/esm/Commands/Fetch.js.map +1 -1
  125. package/build/esm/Commands/Init.js +371 -0
  126. package/build/esm/Commands/Init.js.map +1 -0
  127. package/build/esm/Commands/Search.js +6 -3
  128. package/build/esm/Commands/Search.js.map +1 -1
  129. package/build/esm/Commands/Status.js +186 -0
  130. package/build/esm/Commands/Status.js.map +1 -0
  131. package/build/esm/Commands/Telemetry.js +23 -0
  132. package/build/esm/Commands/Telemetry.js.map +1 -0
  133. package/build/esm/Config.js +502 -28
  134. package/build/esm/Config.js.map +1 -1
  135. package/build/esm/Hook/Claude.js +1 -1
  136. package/build/esm/Hook/Claude.js.map +1 -1
  137. package/build/esm/Hook/SnapshotNote.js +54 -7
  138. package/build/esm/Hook/SnapshotNote.js.map +1 -1
  139. package/build/esm/Init/ClaudeAdapter.js +137 -0
  140. package/build/esm/Init/ClaudeAdapter.js.map +1 -0
  141. package/build/esm/Init/ClaudeProfile.js +380 -0
  142. package/build/esm/Init/ClaudeProfile.js.map +1 -0
  143. package/build/esm/Init/Domain.js +118 -0
  144. package/build/esm/Init/Domain.js.map +1 -0
  145. package/build/esm/Init/HarnessAdapter.js +21 -0
  146. package/build/esm/Init/HarnessAdapter.js.map +1 -0
  147. package/build/esm/Init/HarnessDetector.js +65 -0
  148. package/build/esm/Init/HarnessDetector.js.map +1 -0
  149. package/build/esm/Init/HarnessRegistry.js +58 -0
  150. package/build/esm/Init/HarnessRegistry.js.map +1 -0
  151. package/build/esm/Init/MutationTransaction.js +494 -0
  152. package/build/esm/Init/MutationTransaction.js.map +1 -0
  153. package/build/esm/Init/Service.js +319 -0
  154. package/build/esm/Init/Service.js.map +1 -0
  155. package/build/esm/Init/StandardAdapters.js +116 -0
  156. package/build/esm/Init/StandardAdapters.js.map +1 -0
  157. package/build/esm/Init/Status.js +215 -0
  158. package/build/esm/Init/Status.js.map +1 -0
  159. package/build/esm/Install/Claude/Hook.js +182 -50
  160. package/build/esm/Install/Claude/Hook.js.map +1 -1
  161. package/build/esm/Install/Claude/Mcp.js +56 -26
  162. package/build/esm/Install/Claude/Mcp.js.map +1 -1
  163. package/build/esm/Install/Claude/Skill.js +49 -27
  164. package/build/esm/Install/Claude/Skill.js.map +1 -1
  165. package/build/esm/Install/Codex/Mcp.js +9 -4
  166. package/build/esm/Install/Codex/Mcp.js.map +1 -1
  167. package/build/esm/Install/Codex/Skill.js +4 -1
  168. package/build/esm/Install/Codex/Skill.js.map +1 -1
  169. package/build/esm/Install/Cursor/Mcp.js +7 -4
  170. package/build/esm/Install/Cursor/Mcp.js.map +1 -1
  171. package/build/esm/Install/Cursor/Skill.js +4 -1
  172. package/build/esm/Install/Cursor/Skill.js.map +1 -1
  173. package/build/esm/Install/Domain.js +60 -3
  174. package/build/esm/Install/Domain.js.map +1 -1
  175. package/build/esm/Install/Hook.js.map +1 -1
  176. package/build/esm/Install/Mcp.js +1 -1
  177. package/build/esm/Install/Mcp.js.map +1 -1
  178. package/build/esm/Install/Opencode/Hook.js +1 -1
  179. package/build/esm/Install/Opencode/Hook.js.map +1 -1
  180. package/build/esm/Install/Opencode/Mcp.js +6 -3
  181. package/build/esm/Install/Opencode/Mcp.js.map +1 -1
  182. package/build/esm/Install/Opencode/Skill.js +4 -1
  183. package/build/esm/Install/Opencode/Skill.js.map +1 -1
  184. package/build/esm/Install/Opencode/expandai-webfetch.js +27 -9
  185. package/build/esm/Install/Skill.js.map +1 -1
  186. package/build/esm/Install/Uninstall.js +2 -2
  187. package/build/esm/Install/Uninstall.js.map +1 -1
  188. package/build/esm/MarkdownAnsi.js +142 -0
  189. package/build/esm/MarkdownAnsi.js.map +1 -0
  190. package/build/esm/Mcp/ExpandDocs.js +44 -52
  191. package/build/esm/Mcp/ExpandDocs.js.map +1 -1
  192. package/build/esm/Mcp/RequestHeaders.js.map +1 -1
  193. package/build/esm/Mcp/Server.js +2 -1
  194. package/build/esm/Mcp/Server.js.map +1 -1
  195. package/build/esm/Mcp/tools/Citations.js +109 -8
  196. package/build/esm/Mcp/tools/Citations.js.map +1 -1
  197. package/build/esm/Mcp/tools/Fetch.js +36 -12
  198. package/build/esm/Mcp/tools/Fetch.js.map +1 -1
  199. package/build/esm/SecureFile.js +36 -0
  200. package/build/esm/SecureFile.js.map +1 -0
  201. package/build/esm/Snippets.js +12 -1
  202. package/build/esm/Snippets.js.map +1 -1
  203. package/build/esm/Telemetry.js +157 -0
  204. package/build/esm/Telemetry.js.map +1 -0
  205. package/build/esm/Version.js +4 -0
  206. package/build/esm/Version.js.map +1 -0
  207. package/build/esm/Xdg.js +15 -0
  208. package/build/esm/Xdg.js.map +1 -0
  209. package/build/esm/main.js +122 -14
  210. package/build/esm/main.js.map +1 -1
  211. package/build/skills/expandai/SKILL.md +26 -3
  212. package/package.json +4 -4
  213. package/src/ApiClient.ts +206 -13
  214. package/src/Auth/Domain.ts +63 -0
  215. package/src/Auth/ManualAuthorization.ts +296 -0
  216. package/src/Auth/PendingInit.ts +338 -0
  217. package/src/Auth/Pkce.ts +26 -0
  218. package/src/Auth/SetupGrantAuthorization.ts +116 -0
  219. package/src/Authentication.ts +49 -11
  220. package/src/BrowserOAuth.ts +10 -12
  221. package/src/CommandPath.ts +30 -0
  222. package/src/Commands/Fetch.ts +42 -11
  223. package/src/Commands/Init.ts +489 -0
  224. package/src/Commands/Search.ts +8 -3
  225. package/src/Commands/Status.ts +228 -0
  226. package/src/Commands/Telemetry.ts +42 -0
  227. package/src/Config.ts +695 -50
  228. package/src/Hook/Claude.ts +1 -1
  229. package/src/Hook/SnapshotNote.ts +55 -7
  230. package/src/Init/ClaudeAdapter.ts +191 -0
  231. package/src/Init/ClaudeProfile.ts +496 -0
  232. package/src/Init/Domain.ts +153 -0
  233. package/src/Init/HarnessAdapter.ts +35 -0
  234. package/src/Init/HarnessDetector.ts +77 -0
  235. package/src/Init/HarnessRegistry.ts +87 -0
  236. package/src/Init/MutationTransaction.ts +747 -0
  237. package/src/Init/Service.ts +385 -0
  238. package/src/Init/StandardAdapters.ts +141 -0
  239. package/src/Init/Status.ts +253 -0
  240. package/src/Install/Claude/Hook.ts +234 -81
  241. package/src/Install/Claude/Mcp.ts +80 -33
  242. package/src/Install/Claude/Skill.ts +70 -37
  243. package/src/Install/Codex/Mcp.ts +14 -9
  244. package/src/Install/Codex/Skill.ts +9 -5
  245. package/src/Install/Cursor/Mcp.ts +12 -9
  246. package/src/Install/Cursor/Skill.ts +9 -5
  247. package/src/Install/Domain.ts +77 -4
  248. package/src/Install/Hook.ts +3 -3
  249. package/src/Install/Mcp.ts +6 -6
  250. package/src/Install/Opencode/Hook.ts +5 -5
  251. package/src/Install/Opencode/Mcp.ts +11 -8
  252. package/src/Install/Opencode/Skill.ts +9 -5
  253. package/src/Install/Opencode/expandai-webfetch.js +27 -9
  254. package/src/Install/Skill.ts +3 -3
  255. package/src/Install/Uninstall.ts +3 -3
  256. package/src/MarkdownAnsi.ts +162 -0
  257. package/src/Mcp/ExpandDocs.ts +46 -55
  258. package/src/Mcp/Server.ts +2 -1
  259. package/src/Mcp/tools/Citations.ts +117 -8
  260. package/src/Mcp/tools/Fetch.ts +40 -14
  261. package/src/SecureFile.ts +70 -0
  262. package/src/Snippets.ts +19 -12
  263. package/src/Telemetry.ts +186 -0
  264. package/src/Version.ts +3 -0
  265. package/src/Xdg.ts +18 -0
  266. package/src/main.ts +171 -12
  267. package/build/dts/DeviceAuth.d.ts +0 -87
  268. package/build/dts/DeviceAuth.d.ts.map +0 -1
  269. package/build/esm/DeviceAuth.js +0 -127
  270. package/build/esm/DeviceAuth.js.map +0 -1
  271. package/src/DeviceAuth.ts +0 -193
@@ -0,0 +1,385 @@
1
+ import { Array, Config, Console, DateTime, Effect, Either, Schema } from 'effect'
2
+ import { ManualAuthorizationApiError } from '../ApiClient.js'
3
+ import {
4
+ ManualAuthorization,
5
+ ManualAuthorizationCredentialSaveFailed,
6
+ ManualAuthorizationDenied,
7
+ ManualAuthorizationInput,
8
+ ManualAuthorizationRefused,
9
+ } from '../Auth/ManualAuthorization.js'
10
+ import { SetupGrantAuthorization, SetupGrantAuthorizationError } from '../Auth/SetupGrantAuthorization.js'
11
+ import { Authentication } from '../Authentication.js'
12
+ import { CredentialStoreInsecure } from '../Config.js'
13
+ import {
14
+ ALREADY_AUTHENTICATED_MESSAGE,
15
+ AUTHORIZATION_CONSUMED_MESSAGE,
16
+ AUTHORIZATION_REQUIRED_MESSAGE,
17
+ type InitHarness,
18
+ type InitRequest,
19
+ InitResult,
20
+ PARTIAL_CONFIGURATION_MESSAGE,
21
+ } from './Domain.js'
22
+ import type { HarnessInspection } from './HarnessAdapter.js'
23
+ import { HarnessRegistry, UnsupportedInitHarnessError } from './HarnessRegistry.js'
24
+
25
+ const InitServiceErrorCode = Schema.Literal(
26
+ 'authorization_failed',
27
+ 'setup_token_rejected',
28
+ 'setup_unreachable',
29
+ 'stored_credential_conflict',
30
+ 'authorization_unreachable',
31
+ 'authorization_denied',
32
+ 'authorization_refused',
33
+ 'credential_health_failed',
34
+ 'credential_save_failed',
35
+ 'credential_storage_insecure',
36
+ 'unsupported_harness',
37
+ 'inspection_failed',
38
+ 'configuration_failed',
39
+ 'policy_blocked',
40
+ 'invalid_adapter_result',
41
+ )
42
+
43
+ export class InitServiceError extends Schema.TaggedError<InitServiceError>()('InitServiceError', {
44
+ code: InitServiceErrorCode,
45
+ recoverable: Schema.Boolean,
46
+ }) {
47
+ get message() {
48
+ switch (this.code) {
49
+ case 'authorization_failed':
50
+ return 'Authorization could not be completed. Start authorization again.'
51
+ // A setup token is single-use and is spent by the attempt that failed, so
52
+ // the only way forward is a new prompt — never a retry of the same one.
53
+ case 'setup_token_rejected':
54
+ return 'That setup token was rejected and is now spent. Do not retry it. Ask the user for a fresh setup prompt from the Expand dashboard, or continue without one to authorize in a browser.'
55
+ // Distinct from a rejection: the token was never redeemed, so the same
56
+ // one is still good once the connection works.
57
+ case 'setup_unreachable':
58
+ return 'Could not reach Expand to redeem the setup token. Nothing was sent, so the same token is still good — check the network and run the same command again.'
59
+ // Refused before redemption, so unlike every other setup-token failure the
60
+ // prompt is still good — the user clears the stored key and runs it again.
61
+ case 'stored_credential_conflict':
62
+ return 'An Expand API key is already stored on this machine, and completing setup would replace it with no way to recover it. Nothing was changed and the setup prompt is still valid. Tell the user to run `expandai logout` to clear the stored key, then run the same setup command again.'
63
+ // Reached both when starting an authorization and when polling one that
64
+ // is already pending — either way nothing was changed, so "start again"
65
+ // is the one piece of advice guaranteed not to help.
66
+ case 'authorization_unreachable':
67
+ return 'Could not reach the Expand API. Nothing was changed: any pending authorization is still pending, and if none existed, none was started. Do not retry the same command until the connection is fixed. Check that this machine can reach the API — if you are running inside an agent sandbox, its shell may have network access disabled — and check `EXPAND_API_URL` if it is set. Then run the same command again.'
68
+ // A server-side refusal that a new code cannot fix. The one authorization
69
+ // failure where "run it again" is actively wrong: each retry costs the
70
+ // user another approval and fails identically.
71
+ case 'authorization_refused':
72
+ return 'Expand refused to complete the authorization for a reason a new code cannot fix — most likely an OAuth client, resource, or scope misconfiguration on the server. Do not run setup again; each retry asks the user to approve and then fails the same way. Report the error under `code`.'
73
+ // The user clicked Deny. Rerunning creates a fresh request; nothing is stuck.
74
+ case 'authorization_denied':
75
+ return 'The authorization was denied in the browser. If that was unintended, run the same command again for a fresh approval code.'
76
+ case 'credential_health_failed':
77
+ return 'Stored credentials could not be validated safely.'
78
+ case 'credential_save_failed':
79
+ return 'Authorization completed, but credentials could not be saved. Start authorization again.'
80
+ case 'credential_storage_insecure':
81
+ return 'Credential storage is not secure enough to continue.'
82
+ case 'unsupported_harness':
83
+ return 'This harness is not supported by init.'
84
+ case 'inspection_failed':
85
+ return 'The harness configuration could not be inspected safely.'
86
+ case 'configuration_failed':
87
+ return 'The harness configuration could not be updated safely.'
88
+ case 'policy_blocked':
89
+ return 'A managed harness policy blocks this configuration.'
90
+ case 'invalid_adapter_result':
91
+ return 'The harness adapter returned an invalid configuration result.'
92
+ }
93
+ }
94
+ }
95
+
96
+ function serviceError(code: typeof InitServiceErrorCode.Type, recoverable: boolean): InitServiceError {
97
+ return new InitServiceError({ code, recoverable })
98
+ }
99
+
100
+ function withoutInstallerOutput<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
101
+ return Console.consoleWith((base) =>
102
+ effect.pipe(
103
+ Console.withConsole({
104
+ ...base,
105
+ log: () => Effect.void,
106
+ }),
107
+ ),
108
+ )
109
+ }
110
+
111
+ function unknownField(input: unknown, field: string): unknown {
112
+ if (typeof input !== 'object' || input === null || !(field in input)) return undefined
113
+ return Object.getOwnPropertyDescriptor(input, field)?.value
114
+ }
115
+
116
+ function stringField(input: unknown, field: string): string | undefined {
117
+ const value = unknownField(input, field)
118
+ return typeof value === 'string' ? value : undefined
119
+ }
120
+
121
+ function booleanField(input: unknown, field: string): boolean | undefined {
122
+ const value = unknownField(input, field)
123
+ return typeof value === 'boolean' ? value : undefined
124
+ }
125
+
126
+ function mapCredentialHealthError(error: unknown): InitServiceError {
127
+ return error instanceof CredentialStoreInsecure
128
+ ? serviceError('credential_storage_insecure', false)
129
+ : serviceError('credential_health_failed', false)
130
+ }
131
+
132
+ function mapAuthorizationError(error: unknown): InitServiceError {
133
+ if (error instanceof CredentialStoreInsecure) return serviceError('credential_storage_insecure', false)
134
+ if (error instanceof ManualAuthorizationDenied) return serviceError('authorization_denied', true)
135
+ if (error instanceof ManualAuthorizationRefused) return serviceError('authorization_refused', false)
136
+ if (error instanceof ManualAuthorizationCredentialSaveFailed) return serviceError('credential_save_failed', false)
137
+ // A lost poll *response* self-heals: the next run finds the code spent and
138
+ // transparently starts a fresh request, so unlike the old exchange there is
139
+ // no state to warn about — only the request-never-left case needs its name.
140
+ if (error instanceof ManualAuthorizationApiError && error.kind === 'request_failed') {
141
+ return serviceError('authorization_unreachable', true)
142
+ }
143
+ return serviceError('authorization_failed', booleanField(error, 'recoverable') ?? true)
144
+ }
145
+
146
+ /**
147
+ * Setup-token failures need their own codes. Collapsing them into
148
+ * `authorization_failed` told an agent only that something went wrong, and it
149
+ * filled the gap by guessing — reporting an expiry that had not happened, and
150
+ * retrying a token that redemption had already spent.
151
+ */
152
+ function mapSetupGrantError(error: unknown): InitServiceError {
153
+ if (error instanceof CredentialStoreInsecure) return serviceError('credential_storage_insecure', false)
154
+ if (error instanceof SetupGrantAuthorizationError) {
155
+ // The one setup-token failure where the token is *not* spent.
156
+ if (error.code === 'stored_credential_conflict') return serviceError('stored_credential_conflict', true)
157
+ if (error.code === 'request_failed') return serviceError('setup_unreachable', true)
158
+ if (error.code === 'denied') return serviceError('authorization_denied', true)
159
+ // The token redeemed but the local write failed — a fresh prompt cannot
160
+ // help until storage is fixed, so don't report it as a token problem.
161
+ if (error.code === 'credential_save_failed') return serviceError('credential_save_failed', false)
162
+ // Same server-side refusal as the browser path; a fresh prompt cannot help.
163
+ if (error.code === 'refused') return serviceError('authorization_refused', false)
164
+ // Nothing was redeemed, so the prompt is still good once the file is fixed.
165
+ if (error.code === 'config_unreadable') return serviceError('credential_storage_insecure', true)
166
+ return serviceError('setup_token_rejected', true)
167
+ }
168
+ return serviceError('authorization_failed', booleanField(error, 'recoverable') ?? true)
169
+ }
170
+
171
+ function mapConfigurationError(error: unknown): InitServiceError {
172
+ if (error instanceof UnsupportedInitHarnessError) return serviceError('unsupported_harness', false)
173
+ const nested = unknownField(error, 'error') ?? error
174
+ const tag = stringField(nested, '_tag')
175
+ if (tag === 'ClaudeProfilePolicyBlockedError') return serviceError('policy_blocked', false)
176
+ if (tag === 'ClaudeAdapterOperationError' && stringField(nested, 'code') === 'inspection_failed') {
177
+ return serviceError('inspection_failed', true)
178
+ }
179
+ return serviceError('configuration_failed', true)
180
+ }
181
+
182
+ function manualInput(request: InitRequest): ManualAuthorizationInput {
183
+ return new ManualAuthorizationInput({ harness: 'agent-harnesses', scope: request.scope })
184
+ }
185
+
186
+ function completedRuntimeState(inspection: HarnessInspection): 'unverified' | 'reload_required' {
187
+ return inspection.runtimeState === 'reload_required' ? 'reload_required' : 'unverified'
188
+ }
189
+
190
+ interface CompleteHarnessInspection extends HarnessInspection {
191
+ readonly changed: boolean
192
+ readonly integrationVersion: string
193
+ }
194
+
195
+ function isCompleteHarnessInspection(
196
+ harness: InitHarness,
197
+ inspection: HarnessInspection,
198
+ ): inspection is CompleteHarnessInspection {
199
+ if (!inspection.configured || typeof inspection.changed !== 'boolean') return false
200
+ if (inspection.integrationVersion === undefined || inspection.integrationVersion.trim().length === 0) return false
201
+ if (inspection.nextAction.trim().length === 0) return false
202
+ const components = new Set(inspection.components)
203
+ const expectsHook = harness === 'claude-code' || harness === 'opencode'
204
+ return (
205
+ components.size === inspection.components.length &&
206
+ components.has('runtime-skill') &&
207
+ components.has('mcp') &&
208
+ (expectsHook ? components.has('webfetch-hook') && components.size === 3 : components.size === 2)
209
+ )
210
+ }
211
+
212
+ export class InitService extends Effect.Service<InitService>()('@expandai/cli/Init/Service', {
213
+ dependencies: [
214
+ Authentication.Default,
215
+ ManualAuthorization.Default,
216
+ SetupGrantAuthorization.Default,
217
+ HarnessRegistry.Default,
218
+ ],
219
+ effect: Effect.gen(function* () {
220
+ const authentication = yield* Authentication
221
+ const authorization = yield* ManualAuthorization
222
+ const setupGrantAuthorization = yield* SetupGrantAuthorization
223
+ const registry = yield* HarnessRegistry
224
+
225
+ const configure = Effect.fnUntraced(function* (request: InitRequest, resumedAuthorization: boolean) {
226
+ // One pass per harness, and every harness gets its pass. Both stages used
227
+ // to be `Effect.forEach` with no options — sequential and fail-fast — so a
228
+ // failure on harness 2 of 4 left harness 1 configured on disk, skipped 3
229
+ // and 4 entirely, and told the caller only `configuration_failed` with no
230
+ // harness named, though the error carries one. Folding validation into the
231
+ // same pass keeps each harness's outcome attributable.
232
+ //
233
+ // Deliberately not rolled back across harnesses: undoing a working Cursor
234
+ // install because Codex failed is worse than reporting the difference.
235
+ // Re-running is safe — the adapters gate on `if (!configured)`.
236
+ // Concurrent: each adapter writes only inside its own harness namespace
237
+ // (`.claude/`, `.cursor/`, `.codex/`, `.opencode/`), and installer output
238
+ // is suppressed above — so there is no shared write target and no
239
+ // interleaved logging to order. Sequential here was incidental rather
240
+ // than deliberate.
241
+ //
242
+ // The Claude adapter does shell out — `expandai --version`, to check the
243
+ // installed CLI is on PATH — but it is a read-only probe in its own
244
+ // process, so running four of them at once is fine.
245
+ const outcomes = yield* Effect.forEach(
246
+ request.harnesses,
247
+ (harness) =>
248
+ withoutInstallerOutput(registry.configure(harness, request.scope)).pipe(
249
+ Effect.withTracerEnabled(false),
250
+ Effect.mapError(mapConfigurationError),
251
+ Effect.flatMap((inspection) => {
252
+ if (inspection.policyBlock !== undefined || inspection.runtimeState === 'blocked') {
253
+ return serviceError('policy_blocked', false)
254
+ }
255
+ if (!isCompleteHarnessInspection(harness, inspection)) {
256
+ return serviceError('invalid_adapter_result', false)
257
+ }
258
+ return Effect.succeed({
259
+ harness,
260
+ configured: inspection.configured,
261
+ changed: inspection.changed,
262
+ components: inspection.components,
263
+ version: inspection.integrationVersion,
264
+ verification: {
265
+ runtimeState: completedRuntimeState(inspection),
266
+ nextAction: inspection.nextAction,
267
+ },
268
+ })
269
+ }),
270
+ Effect.either,
271
+ Effect.map((outcome) => ({ harness, outcome })),
272
+ ),
273
+ { concurrency: 'unbounded' },
274
+ )
275
+ const installations = Array.getRights(outcomes.map(({ outcome }) => outcome))
276
+ const failures = outcomes.flatMap(({ harness, outcome }) =>
277
+ Either.isLeft(outcome) ? [{ harness, code: outcome.left.code, recoverable: outcome.left.recoverable }] : [],
278
+ )
279
+ // Nothing landed, so there is no partial result to report — this is the
280
+ // same plain failure it has always been, with the first harness's reason.
281
+ const firstFailure = failures[0]
282
+ if (installations.length === 0 && firstFailure !== undefined) {
283
+ return yield* serviceError(firstFailure.code, firstFailure.recoverable)
284
+ }
285
+ // Same resolution the installers use, so the reported path is the one
286
+ // that was actually written to rather than an educated guess.
287
+ const projectPath = yield* Config.string('PWD').pipe(
288
+ Config.withDefault(process.cwd()),
289
+ // `Config.withDefault` is `orElseIf(..., ConfigError.isMissingDataOnly)`,
290
+ // so it recovers a *missing* PWD and nothing else. This catches the rest
291
+ // — provider and source failures — keeping `ConfigError` out of a service
292
+ // whose callers type against `InitServiceError` alone.
293
+ Effect.orElseSucceed(() => process.cwd()),
294
+ )
295
+ const candidate = {
296
+ schemaVersion: 1 as const,
297
+ status: 'completed' as const,
298
+ harnesses: request.harnesses,
299
+ scope: request.scope,
300
+ // A partial run outranks "authorization consumed": the agent relays this
301
+ // verbatim, and the thing the user needs to know is that a harness they
302
+ // picked is not set up, not how the credential was obtained.
303
+ userMessage:
304
+ failures.length > 0
305
+ ? PARTIAL_CONFIGURATION_MESSAGE
306
+ : resumedAuthorization
307
+ ? AUTHORIZATION_CONSUMED_MESSAGE
308
+ : ALREADY_AUTHENTICATED_MESSAGE,
309
+ auth: { healthy: true },
310
+ ...(request.scope === 'project' ? { projectPath } : {}),
311
+ installations,
312
+ ...(failures.length > 0 ? { failures } : {}),
313
+ }
314
+ const result = yield* Schema.decodeUnknown(InitResult)(candidate).pipe(
315
+ Effect.mapError(() => serviceError('invalid_adapter_result', false)),
316
+ )
317
+ yield* Effect.annotateCurrentSpan({
318
+ status: result.status,
319
+ harnessCount: installations.length,
320
+ componentCount: installations.reduce((count, installation) => count + installation.components.length, 0),
321
+ })
322
+ return result
323
+ })
324
+
325
+ const run = Effect.fn('InitService.run')(function* (request: InitRequest) {
326
+ yield* Effect.annotateCurrentSpan({
327
+ harnessCount: request.harnesses.length,
328
+ scope: request.scope,
329
+ })
330
+ return yield* Effect.gen(function* () {
331
+ const initiallyHealthy = yield* authentication
332
+ .hasLocallyUsableCredentials()
333
+ .pipe(Effect.withTracerEnabled(false), Effect.mapError(mapCredentialHealthError))
334
+
335
+ if (request.setupToken !== undefined) {
336
+ yield* setupGrantAuthorization
337
+ .authorize({ setupToken: request.setupToken, harness: 'agent-harnesses', scope: request.scope })
338
+ .pipe(Effect.withTracerEnabled(false), Effect.mapError(mapSetupGrantError))
339
+ const setupHealthy = yield* authentication
340
+ .hasLocallyUsableCredentials()
341
+ .pipe(Effect.withTracerEnabled(false), Effect.mapError(mapCredentialHealthError))
342
+ if (!setupHealthy) return yield* serviceError('authorization_failed', true)
343
+ return yield* configure(request, true)
344
+ }
345
+
346
+ if (initiallyHealthy) return yield* configure(request, false)
347
+
348
+ // One advance per invocation: create the device authorization on the
349
+ // first run, poll it once on every later run. The agent drives the
350
+ // RFC 8628 loop by re-invoking; this process never waits.
351
+ const outcome = yield* authorization
352
+ .advance(manualInput(request))
353
+ .pipe(Effect.withTracerEnabled(false), Effect.mapError(mapAuthorizationError))
354
+
355
+ if (outcome._tag === 'TokensSaved') {
356
+ const resumedHealthy = yield* authentication
357
+ .hasLocallyUsableCredentials()
358
+ .pipe(Effect.withTracerEnabled(false), Effect.mapError(mapCredentialHealthError))
359
+ if (!resumedHealthy) return yield* serviceError('authorization_failed', true)
360
+ return yield* configure(request, true)
361
+ }
362
+
363
+ const candidate = {
364
+ schemaVersion: 1 as const,
365
+ status: 'authorization_required' as const,
366
+ harnesses: request.harnesses,
367
+ scope: request.scope,
368
+ userMessage: AUTHORIZATION_REQUIRED_MESSAGE,
369
+ authorization: {
370
+ url: outcome.pending.verificationUri,
371
+ userCode: outcome.pending.userCode,
372
+ approvalExpiresAt: DateTime.formatIso(outcome.pending.approvalExpiresAt),
373
+ },
374
+ }
375
+ const result = yield* Schema.decodeUnknown(InitResult)(candidate).pipe(
376
+ Effect.mapError(() => serviceError('authorization_failed', false)),
377
+ )
378
+ yield* Effect.annotateCurrentSpan({ status: result.status, componentCount: 0 })
379
+ return result
380
+ })
381
+ })
382
+
383
+ return { run } as const
384
+ }),
385
+ }) {}
@@ -0,0 +1,141 @@
1
+ import { FileSystem } from '@effect/platform'
2
+ import { NodeFileSystem } from '@effect/platform-node'
3
+ import { Effect } from 'effect'
4
+ import { CodexMcp } from '../Install/Codex/Mcp.js'
5
+ import { CodexSkill } from '../Install/Codex/Skill.js'
6
+ import { CursorMcp } from '../Install/Cursor/Mcp.js'
7
+ import { CursorSkill } from '../Install/Cursor/Skill.js'
8
+ import { OpencodeHook } from '../Install/Opencode/Hook.js'
9
+ import { OpencodeMcp } from '../Install/Opencode/Mcp.js'
10
+ import { OpencodeSkill } from '../Install/Opencode/Skill.js'
11
+ import { CLI_VERSION } from '../Version.js'
12
+ import type { InitComponent, InitHarness, InitScope } from './Domain.js'
13
+ import { HarnessConfigurationError, type HarnessInspection, HarnessInspectionError } from './HarnessAdapter.js'
14
+
15
+ interface StandardHarnessOperations {
16
+ readonly harness: InitHarness
17
+ readonly components: InitComponent[]
18
+ /** Per component, so a partial install is reported as one — see `inspection`. */
19
+ readonly inspectComponents: (scope: InitScope) => Effect.Effect<ReadonlyArray<InitComponent>, unknown>
20
+ readonly install: (scope: InitScope) => Effect.Effect<unknown, unknown>
21
+ }
22
+
23
+ /**
24
+ * Reports what is actually present rather than collapsing everything into one
25
+ * boolean. Deriving `components` from a single flag meant that after a CLI
26
+ * upgrade which only changes `SKILL.md`, a perfectly valid `~/.cursor/mcp.json`
27
+ * was reported as absent — and `integrationVersion` and `runtimeState` degraded
28
+ * with it, so the whole record went blank over one stale file.
29
+ */
30
+ function inspection(
31
+ operations: StandardHarnessOperations,
32
+ present: ReadonlyArray<InitComponent>,
33
+ changed: boolean,
34
+ ): HarnessInspection {
35
+ const configured = operations.components.every((component) => present.includes(component))
36
+ return {
37
+ configured,
38
+ changed,
39
+ components: [...present],
40
+ integrationVersion: configured ? CLI_VERSION : undefined,
41
+ runtimeState: configured ? 'reload_required' : 'unverified',
42
+ nextAction: configured ? 'restart-or-reload-harness-then-use-expand' : 'configure-harness',
43
+ }
44
+ }
45
+
46
+ function makeAdapter(operations: StandardHarnessOperations) {
47
+ const inspect = Effect.fn(`${operations.harness}.inspect`)(function* (scope: InitScope) {
48
+ return yield* operations.inspectComponents(scope).pipe(
49
+ Effect.map((present) => inspection(operations, present, false)),
50
+ Effect.mapError((error) => new HarnessInspectionError({ harness: operations.harness, scope, error })),
51
+ )
52
+ })
53
+ const configure = Effect.fn(`${operations.harness}.configure`)(function* (scope: InitScope) {
54
+ return yield* Effect.gen(function* () {
55
+ const before = yield* operations.inspectComponents(scope)
56
+ const complete = operations.components.every((component) => before.includes(component))
57
+ if (!complete) yield* operations.install(scope)
58
+ const verified = yield* operations.inspectComponents(scope)
59
+ return inspection(operations, verified, !complete)
60
+ }).pipe(Effect.mapError((error) => new HarnessConfigurationError({ harness: operations.harness, scope, error })))
61
+ })
62
+ return { harness: operations.harness, inspect, configure } as const
63
+ }
64
+
65
+ export class CursorAdapter extends Effect.Service<CursorAdapter>()('@expandai/cli/Init/CursorAdapter', {
66
+ dependencies: [CursorMcp.Default, CursorSkill.Default],
67
+ effect: Effect.gen(function* () {
68
+ const mcp = yield* CursorMcp
69
+ const skill = yield* CursorSkill
70
+ return makeAdapter({
71
+ harness: 'cursor',
72
+ components: ['runtime-skill', 'mcp'],
73
+ inspectComponents: (scope) =>
74
+ Effect.all([
75
+ mcp.isInstalled(scope),
76
+ // Content, not presence: a CLI upgrade ships new skill text, and gating on
77
+ // "the file exists" would keep serving the previous instructions forever.
78
+ skill.isCurrent(scope),
79
+ ]).pipe(
80
+ Effect.map(([mcpInstalled, skillCurrent]) => [
81
+ ...(skillCurrent ? (['runtime-skill'] as const) : []),
82
+ ...(mcpInstalled ? (['mcp'] as const) : []),
83
+ ]),
84
+ ),
85
+ install: (scope) => Effect.all([mcp.install(scope), skill.install(scope)]),
86
+ })
87
+ }),
88
+ }) {}
89
+
90
+ export class CodexAdapter extends Effect.Service<CodexAdapter>()('@expandai/cli/Init/CodexAdapter', {
91
+ dependencies: [CodexMcp.Default, CodexSkill.Default],
92
+ effect: Effect.gen(function* () {
93
+ const mcp = yield* CodexMcp
94
+ const skill = yield* CodexSkill
95
+ return makeAdapter({
96
+ harness: 'codex',
97
+ components: ['runtime-skill', 'mcp'],
98
+ inspectComponents: (scope) =>
99
+ Effect.all([
100
+ mcp.isInstalled(scope),
101
+ // Content, not presence: a CLI upgrade ships new skill text, and gating on
102
+ // "the file exists" would keep serving the previous instructions forever.
103
+ skill.isCurrent(scope),
104
+ ]).pipe(
105
+ Effect.map(([mcpInstalled, skillCurrent]) => [
106
+ ...(skillCurrent ? (['runtime-skill'] as const) : []),
107
+ ...(mcpInstalled ? (['mcp'] as const) : []),
108
+ ]),
109
+ ),
110
+ install: (scope) => Effect.all([mcp.install(scope), skill.install(scope)]),
111
+ })
112
+ }),
113
+ }) {}
114
+
115
+ export class OpencodeAdapter extends Effect.Service<OpencodeAdapter>()('@expandai/cli/Init/OpencodeAdapter', {
116
+ dependencies: [NodeFileSystem.layer, OpencodeHook.Default, OpencodeMcp.Default, OpencodeSkill.Default],
117
+ effect: Effect.gen(function* () {
118
+ const fileSystem = yield* FileSystem.FileSystem
119
+ const hook = yield* OpencodeHook
120
+ const mcp = yield* OpencodeMcp
121
+ const skill = yield* OpencodeSkill
122
+ return makeAdapter({
123
+ harness: 'opencode',
124
+ components: ['webfetch-hook', 'runtime-skill', 'mcp'],
125
+ inspectComponents: (scope) =>
126
+ Effect.all([
127
+ mcp.isInstalled(scope),
128
+ hook.pathFor(scope).pipe(Effect.flatMap(fileSystem.exists)),
129
+ // Content, not presence — see the note on the other standard adapters.
130
+ skill.isCurrent(scope),
131
+ ]).pipe(
132
+ Effect.map(([mcpInstalled, hookInstalled, skillCurrent]) => [
133
+ ...(hookInstalled ? (['webfetch-hook'] as const) : []),
134
+ ...(skillCurrent ? (['runtime-skill'] as const) : []),
135
+ ...(mcpInstalled ? (['mcp'] as const) : []),
136
+ ]),
137
+ ),
138
+ install: (scope) => Effect.all([mcp.install(scope), hook.install(scope), skill.install(scope)]),
139
+ })
140
+ }),
141
+ }) {}