expandai 0.0.2 → 0.0.4

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 +77 -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 +57 -29
  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 +344 -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 +52 -64
  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 +37 -8
  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 +453 -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 +81 -91
  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
@@ -2,6 +2,7 @@ import { Tool } from '@effect/ai'
2
2
  import { Effect, Schema, String } from 'effect'
3
3
  import { ApiClient } from '../../ApiClient.js'
4
4
  import { ConfigManager } from '../../Config.js'
5
+ import { Telemetry } from '../../Telemetry.js'
5
6
  import {
6
7
  CitedObjectModeResult,
7
8
  CitedSnapshotSearchResult,
@@ -69,11 +70,15 @@ export const FetchTool = Tool.make('fetch', {
69
70
  description: String.stripMargin(`
70
71
  |Fetch and extract content from any URL.
71
72
  |
73
+ |You MUST cite what you take from the result. The result carries a ready-made
74
+ |'citationBlock' — append it verbatim to the end of your answer. Do not retype it, do not
75
+ |substitute the URL you fetched, and do not build a link yourself: the citation points at
76
+ |expand.ai's stored capture, not at the original site. Drop only the markers you did not use.
77
+ |A 'Verify it' link without '?id=' is not a citation. Never invent or reuse an evidence id.
78
+ |
72
79
  |Returns object-mode output with markdown, front-matter metadata, and optional extracted JSON.
73
80
  |When 'search' is set, the response also carries ranked snippets under data.search.snippets.
74
- |Each snippet has a 'citationUrl' — a public playground link auditing that exact passage — and,
75
- |for State JSON sources, the extracted 'json' value. Return the citationUrl to the user so they
76
- |can verify the source.`),
81
+ |For State JSON sources each snippet also carries the extracted 'json' value.`),
77
82
  parameters: FetchParams.fields,
78
83
  success: CitedObjectModeResult,
79
84
  failure: Schema.Defect,
@@ -86,10 +91,15 @@ export const FetchSearchTool = Tool.make('fetch_search', {
86
91
  description: String.stripMargin(`
87
92
  |Search a previous fetch by snapshotId without starting a new capture.
88
93
  |
89
- |Returns ranked snippets from stored markdown, appendix, or State JSON artifacts. Each snippet
90
- |has a 'citationUrl' — a public playground link auditing that exact passage — and, for State JSON
91
- |sources, the extracted 'json' value. The result also includes a whole-snapshot 'playground' link.
92
- |Return the citationUrl to the user so they can verify the source.`),
94
+ |You MUST cite what you take from the result. The result carries a ready-made
95
+ |'citationBlock' — append it verbatim to the end of your answer. Do not retype it, do not
96
+ |substitute the URL you fetched, and do not build a link yourself: the citation points at
97
+ |expand.ai's stored capture, not at the original site. Drop only the markers you did not use.
98
+ |A 'Verify it' link without '?id=' is not a citation. Never invent or reuse an evidence id.
99
+ |
100
+ |Returns ranked snippets from stored markdown, appendix, or State JSON artifacts. For State JSON
101
+ |sources each snippet also carries the extracted 'json' value. The result also includes a
102
+ |whole-snapshot 'playground' link.`),
93
103
  parameters: FetchSnapshotSearchParams.fields,
94
104
  success: CitedSnapshotSearchResult,
95
105
  failure: Schema.Defect,
@@ -104,10 +114,11 @@ export interface FetchService {
104
114
  }
105
115
 
106
116
  export class Fetch extends Effect.Service<Fetch>()('@expandai/cli/Mcp/Fetch', {
107
- dependencies: [ApiClient.Default, ConfigManager.Default],
117
+ dependencies: [ApiClient.Default, ConfigManager.Default, Telemetry.Default],
108
118
  scoped: Effect.gen(function* () {
109
119
  const client = yield* ApiClient
110
120
  const configManager = yield* ConfigManager
121
+ const telemetry = yield* Telemetry
111
122
  const playgroundHost = yield* configManager.getPlaygroundHost()
112
123
 
113
124
  const fetch: FetchService['fetch'] = Effect.fn('Fetch.fetch')(function* ({
@@ -117,9 +128,16 @@ export class Fetch extends Effect.Service<Fetch>()('@expandai/cli/Mcp/Fetch', {
117
128
  includeAppendix,
118
129
  includeJson,
119
130
  }: FetchParams) {
120
- yield* Effect.logDebug(`Fetching: ${url}`).pipe(
121
- Effect.annotateLogs({ search: search?.query, includeMeta, includeAppendix, includeJson }),
131
+ yield* Effect.logDebug('MCP fetch started').pipe(
132
+ Effect.annotateLogs({
133
+ operation: 'fetch',
134
+ hasSearch: search !== undefined,
135
+ includeMeta,
136
+ includeAppendix,
137
+ includeJson,
138
+ }),
122
139
  )
140
+ yield* telemetry.capture('mcp_tool_called', { tool: 'fetch', has_search: search !== undefined })
123
141
 
124
142
  const includeParam =
125
143
  includeAppendix && includeJson
@@ -143,7 +161,14 @@ export class Fetch extends Effect.Service<Fetch>()('@expandai/cli/Mcp/Fetch', {
143
161
  },
144
162
  })
145
163
 
146
- yield* Effect.logDebug(`Fetched successfully: ${result.meta.url}`)
164
+ yield* Effect.logDebug('MCP fetch completed').pipe(
165
+ Effect.annotateLogs({
166
+ operation: 'fetch',
167
+ hasSearch: search !== undefined,
168
+ searchResultCount: result.data?.search?.snippets.length ?? 0,
169
+ jsonEvidenceCount: result.json.length,
170
+ }),
171
+ )
147
172
 
148
173
  return enrichObjectModeResult(result, playgroundHost)
149
174
  })
@@ -155,9 +180,10 @@ export class Fetch extends Effect.Service<Fetch>()('@expandai/cli/Mcp/Fetch', {
155
180
  includeAppendix,
156
181
  includeJson,
157
182
  }: FetchSnapshotSearchParams) {
158
- yield* Effect.logDebug(`Searching fetch snapshot: ${snapshotId}`).pipe(
159
- Effect.annotateLogs({ search: search.query, includeMarkdown, includeAppendix, includeJson }),
183
+ yield* Effect.logDebug('MCP snapshot search started').pipe(
184
+ Effect.annotateLogs({ operation: 'fetch_search', includeMarkdown, includeAppendix, includeJson }),
160
185
  )
186
+ yield* telemetry.capture('mcp_tool_called', { tool: 'fetch_search', has_search: true })
161
187
 
162
188
  const result = yield* client.fetchFetchSearch({
163
189
  snapshotId,
@@ -169,7 +195,7 @@ export class Fetch extends Effect.Service<Fetch>()('@expandai/cli/Mcp/Fetch', {
169
195
  },
170
196
  })
171
197
 
172
- return enrichSnapshotSearchResult(result, playgroundHost)
198
+ return enrichSnapshotSearchResult(result, playgroundHost, 'mcp-fetch-search')
173
199
  })
174
200
 
175
201
  return { fetch, fetchSearch } satisfies FetchService
@@ -0,0 +1,70 @@
1
+ import { FileSystem, Error as PlatformError } from '@effect/platform'
2
+ import { Effect, Exit, Option } from 'effect'
3
+
4
+ export interface CreatedFileIdentity {
5
+ readonly dev: number
6
+ readonly ino?: number
7
+ readonly mode: number
8
+ readonly type: FileSystem.File.Type
9
+ }
10
+
11
+ export interface ExclusiveFileCreation {
12
+ readonly identity: CreatedFileIdentity
13
+ readonly writeExit: Exit.Exit<void, PlatformError.PlatformError>
14
+ readonly pathMatchesHandle: boolean
15
+ }
16
+
17
+ /** Creates a file exclusively and binds its identity to the opened handle before writing bytes. */
18
+ export function createExclusiveFile(
19
+ fileSystem: FileSystem.FileSystem,
20
+ targetPath: string,
21
+ contents: string,
22
+ mode: number,
23
+ ): Effect.Effect<ExclusiveFileCreation, PlatformError.PlatformError> {
24
+ return Effect.scoped(
25
+ Effect.gen(function* () {
26
+ const file = yield* fileSystem.open(targetPath, { flag: 'wx', mode })
27
+ const info = yield* file.stat
28
+ const identity: CreatedFileIdentity = {
29
+ dev: info.dev,
30
+ ino: Option.getOrUndefined(info.ino),
31
+ mode: info.mode,
32
+ type: info.type,
33
+ }
34
+ const writeExit = yield* Effect.exit(
35
+ file.writeAll(new TextEncoder().encode(contents)).pipe(Effect.zipRight(file.sync)),
36
+ )
37
+ if (Exit.isFailure(writeExit)) return { identity, writeExit, pathMatchesHandle: false } as const
38
+
39
+ // Validate while the creating handle is still open. In particular, Windows
40
+ // may reuse a deleted file's numeric identifier immediately after its final
41
+ // handle closes, making a later pathname-only comparison ambiguous.
42
+ const pathInfo = yield* fileSystem.stat(targetPath)
43
+ const pathMatchesHandle = matchesCreatedFile(identity, {
44
+ exists: true,
45
+ dev: pathInfo.dev,
46
+ ino: Option.getOrUndefined(pathInfo.ino),
47
+ type: pathInfo.type,
48
+ })
49
+ return { identity, writeExit, pathMatchesHandle } as const
50
+ }),
51
+ )
52
+ }
53
+
54
+ /** Compares a pathname snapshot with identity captured from the creating file handle. */
55
+ export function matchesCreatedFile(
56
+ identity: CreatedFileIdentity,
57
+ pathIdentity: {
58
+ readonly exists: boolean
59
+ readonly dev?: number
60
+ readonly ino?: number
61
+ readonly type?: FileSystem.File.Type
62
+ },
63
+ ): boolean {
64
+ return (
65
+ pathIdentity.exists &&
66
+ pathIdentity.dev === identity.dev &&
67
+ pathIdentity.ino === identity.ino &&
68
+ pathIdentity.type === identity.type
69
+ )
70
+ }
package/src/Snippets.ts CHANGED
@@ -1,19 +1,26 @@
1
- import { type CitationSource, citationUrl } from '@expandai/sdk/Playground'
1
+ import type { FetchObjectModeResultWithJson } from '@expandai/sdk/FetchJson'
2
+ import { citationUrl } from '@expandai/sdk/Playground'
2
3
  import { Schema } from 'effect'
3
4
 
4
5
  const encodeJson = Schema.encodeSync(Schema.parseJson(Schema.Unknown, { space: 2 }))
5
6
 
6
- interface RenderableSnippetLocation {
7
- readonly nodeIds?: ReadonlyArray<number> | null
8
- readonly stateJsonSourceId?: number | null
9
- }
7
+ type SearchResultWithJson = NonNullable<NonNullable<FetchObjectModeResultWithJson['data']>['search']>
8
+ type RenderableSnippet = SearchResultWithJson['snippets'][number]
10
9
 
11
- interface RenderableSnippet {
12
- readonly source: CitationSource
13
- readonly text: string
14
- readonly score: number
15
- readonly location?: RenderableSnippetLocation | null
16
- readonly json?: unknown
10
+ /**
11
+ * Attaches the public citation link to each snippet. JSON consumers get the same
12
+ * auditable URL the text renderer prints in its header — without it, a caller has
13
+ * the snapshot and the evidence id but no host to build a link against.
14
+ */
15
+ export function withCitationUrls<A extends RenderableSnippet>(
16
+ snapshotId: string,
17
+ host: string,
18
+ snippets: ReadonlyArray<A>,
19
+ ): Array<A & { readonly citationUrl: string }> {
20
+ return snippets.map((snippet) => ({
21
+ ...snippet,
22
+ citationUrl: citationUrl(snapshotId, snippet.location, host),
23
+ }))
17
24
  }
18
25
 
19
26
  /**
@@ -23,7 +30,7 @@ interface RenderableSnippet {
23
30
  export function renderSnippets(snapshotId: string, host: string, snippets: ReadonlyArray<RenderableSnippet>): string {
24
31
  return snippets
25
32
  .map((snippet) => {
26
- const url = citationUrl(snapshotId, snippet.source, snippet.location, host)
33
+ const url = citationUrl(snapshotId, snippet.location, host)
27
34
  const header = `[${snippet.source} · score ${snippet.score.toFixed(2)}] ${url}`
28
35
  const json = snippet.json == null ? '' : `\n\n${encodeJson(snippet.json)}`
29
36
  return `${header}\n\n${snippet.text}${json}`
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Anonymous usage telemetry (PostHog). Events carry a persisted machine UUID,
3
+ * the invoking harness, and coarse feature usage — never URLs, queries,
4
+ * keys, or tokens.
5
+ *
6
+ * Delivery is fire-and-forget: each capture forks into a FiberSet and `flush`
7
+ * caps shutdown at one second, so telemetry can never block a command, delay
8
+ * an MCP/hook payload, or write to stdout (stdout is a protocol surface).
9
+ *
10
+ * Settings live in the config file (`telemetry.enabled` / `distinctId` /
11
+ * `noticeShown`). `DO_NOT_TRACK` / `EXPANDAI_NO_TELEMETRY`, or a non-default
12
+ * effective API URL (env or config file — dev traffic), disable capture per
13
+ * invocation without touching the file. A config file that fails to parse
14
+ * also disables telemetry for the invocation and is never written to.
15
+ */
16
+ import { randomUUID } from 'node:crypto'
17
+ import { HttpClient, HttpClientRequest } from '@effect/platform'
18
+ import { NodeHttpClient } from '@effect/platform-node'
19
+ import { Config, Console, Effect, FiberSet, Layer, Option } from 'effect'
20
+ import { ConfigManager, DEFAULT_API_URL, envFlag, envLiteral } from './Config.js'
21
+ import { Harness } from './Install/Domain.js'
22
+ import { CLI_VERSION } from './Version.js'
23
+
24
+ // Every field reads leniently (envFlag / envLiteral / plain strings) so a
25
+ // malformed env var can never fail service construction — telemetry must
26
+ // silently degrade, not crash the CLI.
27
+ export const TelemetryEnvironment = Config.all({
28
+ // PostHog project write-only key — safe to embed as the default (it ships
29
+ // in every browser bundle that uses posthog-js) and only permits ingestion.
30
+ apiKey: Config.string('POSTHOG_API_KEY').pipe(Config.withDefault('phc_sffcV4skT33mMKmo0BenNoE7t7AghUrOugyW9qSx43B')),
31
+ host: Config.string('POSTHOG_HOST').pipe(Config.withDefault('https://us.i.posthog.com')),
32
+ doNotTrack: envFlag('DO_NOT_TRACK'),
33
+ noTelemetry: envFlag('EXPANDAI_NO_TELEMETRY'),
34
+ apiUrl: Config.string('EXPAND_API_URL').pipe(Config.option),
35
+ // Baked into MCP/hook configs at install time; wins over ambient signals.
36
+ // An unrecognized value (e.g. written by a newer installer) reads as None.
37
+ harnessOverride: envLiteral('EXPANDAI_HARNESS', Harness),
38
+ claudeCode: envFlag('CLAUDECODE'),
39
+ cursor: Config.string('CURSOR_TRACE_ID').pipe(Config.option, Config.map(Option.isSome)),
40
+ codex: Config.string('CODEX_RUN_ID').pipe(Config.option, Config.map(Option.isSome)),
41
+ })
42
+
43
+ type TelemetryEnvironment = Config.Config.Success<typeof TelemetryEnvironment>
44
+
45
+ function detectHarness(environment: TelemetryEnvironment): Harness {
46
+ if (Option.isSome(environment.harnessOverride)) return environment.harnessOverride.value
47
+ if (environment.claudeCode) return 'claude-code'
48
+ if (environment.cursor) return 'cursor'
49
+ if (environment.codex) return 'codex'
50
+ return 'unknown'
51
+ }
52
+
53
+ const generateDistinctId = Effect.sync(() => randomUUID())
54
+
55
+ // Lenient normalization for the non-default-API comparison: `new URL()` adds
56
+ // the trailing slash a bare origin string lacks, and a value that doesn't
57
+ // parse compares as non-default — never crash construction, and treat unknown
58
+ // endpoints as non-production traffic.
59
+ function normalizeApiUrl(url: string): string {
60
+ try {
61
+ return new URL(url).toString()
62
+ } catch {
63
+ return `unparseable:${url}`
64
+ }
65
+ }
66
+
67
+ export class Telemetry extends Effect.Service<Telemetry>()('@expandai/cli/Telemetry', {
68
+ dependencies: [ConfigManager.Default, NodeHttpClient.layer],
69
+ scoped: Effect.gen(function* () {
70
+ const configManager = yield* ConfigManager
71
+ const httpClient = yield* HttpClient.HttpClient
72
+ const fibers = yield* FiberSet.make()
73
+
74
+ const environment = yield* TelemetryEnvironment
75
+ // EXPAND_API_KEY overrides the config file for requests (see ConfigManager),
76
+ // so it must win here too or env-key users all report 'none'.
77
+ const envApiKey = yield* Config.string('EXPAND_API_KEY').pipe(Config.option, Config.map(Option.isSome))
78
+
79
+ // The config file is read best-effort: telemetry must work (or silently
80
+ // no-op) before login and survive an unreadable config. A parse failure
81
+ // disables capture for the invocation — an unreadable file must never be
82
+ // mistaken for an empty one, and never be written to.
83
+ const { config, parseFailed } = yield* configManager.readConfigFile()
84
+ const settings = config.telemetry
85
+
86
+ // Same env-over-file precedence as ConfigManager: a self-hosted apiUrl in
87
+ // the config file is dev traffic too. Deliberately not Config.url /
88
+ // getApiUrl — those fail on a malformed env value, and telemetry must
89
+ // degrade instead of crashing the CLI.
90
+ const effectiveApiUrl = Option.firstSomeOf([
91
+ environment.apiUrl,
92
+ Option.map(Option.fromNullable(config.apiUrl), (url) => url.toString()),
93
+ ])
94
+ const nonDefaultApi = Option.match(effectiveApiUrl, {
95
+ onNone: () => false,
96
+ onSome: (url) => normalizeApiUrl(url) !== normalizeApiUrl(DEFAULT_API_URL),
97
+ })
98
+
99
+ const enabled =
100
+ !environment.doNotTrack &&
101
+ !environment.noTelemetry &&
102
+ !nonDefaultApi &&
103
+ !parseFailed &&
104
+ settings?.enabled !== false
105
+
106
+ const harness = detectHarness(environment)
107
+ const authMethod = envApiKey
108
+ ? 'api-key'
109
+ : config.authMethod?._tag === 'ApiKey'
110
+ ? 'api-key'
111
+ : config.authMethod?._tag === 'OAuth'
112
+ ? 'oauth'
113
+ : 'none'
114
+
115
+ const persistNewDistinctId = Effect.fnUntraced(function* () {
116
+ const generated = yield* generateDistinctId
117
+ // Best-effort persistence — an unwritable config yields a per-run id.
118
+ yield* configManager.updateConfig({ telemetry: { ...settings, distinctId: generated } }).pipe(Effect.ignore)
119
+ return generated
120
+ })
121
+
122
+ // Disabled runs never write: DO_NOT_TRACK users don't get an id persisted,
123
+ // and after a parse failure a write would clobber the original file.
124
+ const distinctId = settings?.distinctId ?? (enabled ? yield* persistNewDistinctId() : yield* generateDistinctId)
125
+
126
+ const capture = Effect.fn('Telemetry.capture')(function* (
127
+ event: string,
128
+ properties: Record<string, string | number | boolean>,
129
+ ) {
130
+ if (!enabled) return
131
+ const post = HttpClientRequest.post(`${environment.host}/i/v0/e`).pipe(
132
+ HttpClientRequest.bodyJson({
133
+ api_key: environment.apiKey,
134
+ event,
135
+ distinct_id: distinctId,
136
+ timestamp: new Date().toISOString(),
137
+ properties: {
138
+ $lib: 'expandai-cli',
139
+ $lib_version: CLI_VERSION,
140
+ version: CLI_VERSION,
141
+ harness,
142
+ auth_method: authMethod,
143
+ ...properties,
144
+ },
145
+ }),
146
+ )
147
+ yield* FiberSet.run(
148
+ fibers,
149
+ post.pipe(
150
+ Effect.flatMap((request) => httpClient.execute(request)),
151
+ Effect.scoped,
152
+ Effect.timeout('2 seconds'),
153
+ Effect.ignore,
154
+ ),
155
+ )
156
+ })
157
+
158
+ // Cap delivery at flush time so short-lived processes get events out
159
+ // without ever holding the process open noticeably.
160
+ const flush = FiberSet.awaitEmpty(fibers).pipe(Effect.timeout('1 second'), Effect.ignore)
161
+
162
+ const showNoticeIfNeeded = Effect.fn('Telemetry.showNoticeIfNeeded')(function* () {
163
+ if (!enabled || settings?.noticeShown === true) return
164
+ yield* Console.error(
165
+ 'expandai collects anonymous usage data to improve the CLI. Opt out with `expandai telemetry disable` or EXPANDAI_NO_TELEMETRY=1.',
166
+ )
167
+ yield* configManager
168
+ .updateConfig({ telemetry: { ...settings, distinctId, noticeShown: true } })
169
+ .pipe(Effect.ignore)
170
+ })
171
+
172
+ return { capture, flush, showNoticeIfNeeded, enabled, distinctId } as const
173
+ }),
174
+ }) {
175
+ /** A side-effect-free telemetry service for commands whose contract is read-only. */
176
+ static readonly DISABLED = Layer.succeed(
177
+ Telemetry,
178
+ Telemetry.make({
179
+ capture: () => Effect.void,
180
+ flush: Effect.void,
181
+ showNoticeIfNeeded: () => Effect.void,
182
+ enabled: false,
183
+ distinctId: 'disabled',
184
+ }),
185
+ )
186
+ }
package/src/Version.ts ADDED
@@ -0,0 +1,3 @@
1
+ // Single source of truth for the CLI version. Keep in sync with package.json —
2
+ // the compiled binary cannot read package.json at runtime.
3
+ export const CLI_VERSION = '0.0.4'
package/src/Xdg.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { homedir } from 'node:os'
2
+ import { Path } from '@effect/platform'
3
+ import { Config, Effect } from 'effect'
4
+
5
+ /**
6
+ * XDG base-directory lookup, shared by everything that persists CLI state so
7
+ * the fallback rules cannot drift apart. The spec treats an unset and an empty
8
+ * variable identically: fall back to `$HOME/.config`. Without the empty check,
9
+ * `XDG_CONFIG_HOME=` resolves paths relative to the working directory — which
10
+ * would drop credentials inside whatever project the CLI happens to run in.
11
+ */
12
+ export const xdgConfigDirectory = Effect.gen(function* () {
13
+ const path = yield* Path.Path
14
+ return yield* Config.string('XDG_CONFIG_HOME').pipe(
15
+ Config.withDefault(''),
16
+ Config.map((directory) => (directory.trim() === '' ? path.join(homedir(), '.config') : directory)),
17
+ )
18
+ })