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
@@ -8,6 +8,32 @@
8
8
  // dist/index.d.ts.
9
9
  import { spawn } from 'node:child_process'
10
10
 
11
+ const runHook = (command, args, payload) =>
12
+ new Promise((resolve, reject) => {
13
+ const child = spawn(command, args, {
14
+ stdio: ['pipe', 'pipe', 'inherit'],
15
+ env: { ...process.env, EXPANDAI_CLI_CONTEXT: 'agent', EXPANDAI_HARNESS: 'opencode' },
16
+ })
17
+ let stdout = ''
18
+ child.stdout.setEncoding('utf8')
19
+ child.stdout.on('data', (chunk) => {
20
+ stdout += chunk
21
+ })
22
+ child.on('error', reject)
23
+ child.on('close', (exitCode) => resolve({ exitCode, stdout }))
24
+ // Writing to stdin of a binary that failed to spawn raises EPIPE; the
25
+ // spawn failure itself is reported through the 'error' handler above.
26
+ child.stdin.on('error', () => {})
27
+ child.stdin.end(payload)
28
+ })
29
+
30
+ // Prefer the installed binary; fall back to `npx -y expandai` so the plugin
31
+ // works for people who have not installed the expandai CLI globally.
32
+ const runExpandai = (args, payload) =>
33
+ runHook('expandai', args, payload).catch((error) =>
34
+ error?.code === 'ENOENT' ? runHook('npx', ['-y', 'expandai', ...args], payload) : Promise.reject(error),
35
+ )
36
+
11
37
  export const ExpandaiWebfetch = async () => ({
12
38
  'tool.execute.after': async (input, output) => {
13
39
  if (input.tool !== 'webfetch') return
@@ -15,15 +41,7 @@ export const ExpandaiWebfetch = async () => ({
15
41
  if (typeof url !== 'string' || url.length === 0) return
16
42
 
17
43
  try {
18
- const child = spawn('expandai', ['hook', 'opencode'], {
19
- stdio: ['pipe', 'pipe', 'inherit'],
20
- env: { ...process.env, EXPANDAI_CLI_CONTEXT: 'agent' },
21
- })
22
- child.stdin.end(JSON.stringify({ url }))
23
- let stdout = ''
24
- child.stdout.setEncoding('utf8')
25
- for await (const chunk of child.stdout) stdout += chunk
26
- const exitCode = await new Promise((resolve) => child.on('close', resolve))
44
+ const { exitCode, stdout } = await runExpandai(['hook', 'opencode'], JSON.stringify({ url }))
27
45
  if (exitCode !== 0 || stdout.trim().length === 0) return
28
46
 
29
47
  const { markdown } = JSON.parse(stdout)
@@ -2,7 +2,7 @@ import { Console, Effect, Match } from 'effect'
2
2
  import { ClaudeSkill } from './Claude/Skill.js'
3
3
  import { CodexSkill } from './Codex/Skill.js'
4
4
  import { CursorSkill } from './Cursor/Skill.js'
5
- import type { Host, Scope } from './Mcp.js'
5
+ import type { Host, InstallScope } from './Mcp.js'
6
6
  import { OpencodeSkill } from './Opencode/Skill.js'
7
7
 
8
8
  /**
@@ -30,7 +30,7 @@ export class Skill extends Effect.Service<Skill>()('@expandai/cli/Install/Skill'
30
30
  const opencode = yield* OpencodeSkill
31
31
  const codex = yield* CodexSkill
32
32
 
33
- const install = Effect.fn('Skill.install')(function* (host: Host, scope: Scope) {
33
+ const install = Effect.fn('Skill.install')(function* (host: Host, scope: InstallScope) {
34
34
  const target = yield* Match.value(host).pipe(
35
35
  Match.when('claude-code', () => claude.install(scope)),
36
36
  Match.when('cursor', () => cursor.install(scope)),
@@ -41,7 +41,7 @@ export class Skill extends Effect.Service<Skill>()('@expandai/cli/Install/Skill'
41
41
  yield* Console.log(`Installed expandai skill into ${target}`)
42
42
  })
43
43
 
44
- const uninstall = Effect.fn('Skill.uninstall')(function* (host: Host, scope: Scope) {
44
+ const uninstall = Effect.fn('Skill.uninstall')(function* (host: Host, scope: InstallScope) {
45
45
  const target = yield* Match.value(host).pipe(
46
46
  Match.when('claude-code', () => claude.uninstall(scope)),
47
47
  Match.when('cursor', () => cursor.uninstall(scope)),
@@ -1,6 +1,6 @@
1
1
  import { Array, Effect, Match, Schema } from 'effect'
2
2
  import { type HookHost, HookInstaller } from './Hook.js'
3
- import { Host, Mcp, Scope } from './Mcp.js'
3
+ import { Host, InstallScope, Mcp } from './Mcp.js'
4
4
  import { Skill } from './Skill.js'
5
5
 
6
6
  export const UninstallTarget = Schema.Literal('all', 'hook', 'skill', 'mcp')
@@ -8,10 +8,10 @@ export const UninstallTarget = Schema.Literal('all', 'hook', 'skill', 'mcp')
8
8
  export class UninstallOptions extends Schema.Class<UninstallOptions>('UninstallOptions')({
9
9
  target: Schema.optional(UninstallTarget),
10
10
  host: Schema.optional(Host),
11
- scope: Schema.optional(Scope),
11
+ scope: Schema.optional(InstallScope),
12
12
  }) {}
13
13
 
14
- const SCOPES: (typeof Scope.Type)[] = ['global', 'project']
14
+ const SCOPES: (typeof InstallScope.Type)[] = ['global', 'project']
15
15
  const HOSTS: (typeof Host.Type)[] = ['claude-code', 'cursor', 'opencode', 'codex']
16
16
 
17
17
  const isHookHost = (host: typeof Host.Type): host is HookHost => host === 'claude-code' || host === 'opencode'
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Zero-dependency ANSI syntax highlighting for markdown printed to an
3
+ * interactive terminal.
4
+ *
5
+ * This is a highlighter, not a renderer: the input text is preserved
6
+ * byte-for-byte and only wrapped in ANSI escape sequences, so copying
7
+ * from the terminal or stripping the escapes yields the exact document
8
+ * the API returned. Piped output (non-TTY) must stay unstyled — gate
9
+ * calls with `shouldColorizeMarkdown`.
10
+ */
11
+
12
+ // Styling is intentionally limited to 4-bit SGR (bold/dim/underline + the 8
13
+ // base colors): it renders on every color-capable terminal and inherits the
14
+ // user's palette. Do NOT introduce 256-color (38;5;n) or truecolor (38;2;r;g;b)
15
+ // sequences without adding capability detection first — truecolor needs
16
+ // COLORTERM=truecolor|24bit (TERM alone is not enough: e.g. ghostty sets
17
+ // TERM=xterm-ghostty), and 256-color needs TERM=*-256color; emitting either
18
+ // tier on a lesser terminal produces garbage.
19
+ const RESET = '\x1b[0m'
20
+ const BOLD = '\x1b[1m'
21
+ const DIM = '\x1b[2m'
22
+ const CYAN = '\x1b[36m'
23
+ const YELLOW = '\x1b[33m'
24
+ const GREEN = '\x1b[32m'
25
+ const BLUE_UNDERLINE = '\x1b[34;4m'
26
+ const HEADING = '\x1b[1;36m'
27
+
28
+ export interface ColorizeEnvironment {
29
+ readonly isTty: boolean
30
+ readonly env: Readonly<Record<string, string | undefined>>
31
+ }
32
+
33
+ /**
34
+ * Standard CLI color negotiation: color only on an interactive terminal,
35
+ * `NO_COLOR` (https://no-color.org) and `TERM=dumb` opt out, a non-zero
36
+ * `FORCE_COLOR` opts in regardless of the terminal.
37
+ */
38
+ export function shouldColorizeMarkdown(
39
+ environment: ColorizeEnvironment = { isTty: process.stdout.isTTY === true, env: process.env },
40
+ ): boolean {
41
+ const { env, isTty } = environment
42
+ const forceColor = env.FORCE_COLOR?.trim()
43
+ if (forceColor !== undefined && forceColor !== '') return forceColor !== '0'
44
+ if (env.NO_COLOR !== undefined && env.NO_COLOR !== '') return false
45
+ if (env.TERM === 'dumb') return false
46
+ return isTty
47
+ }
48
+
49
+ function styleLine(style: string, line: string): string {
50
+ return line === '' ? line : `${style}${line}${RESET}`
51
+ }
52
+
53
+ // Inline code spans are styled first and must be shielded from the link,
54
+ // bold, and table-pipe passes; ANSI escapes never contain \x00, so a
55
+ // NUL-delimited stash is collision-free.
56
+ function styleInline(line: string, options?: { readonly dimPipes: boolean }): string {
57
+ const stash: string[] = []
58
+ let out = line.replace(/`[^`\n]+`/g, (code) => {
59
+ stash.push(`${CYAN}${code}${RESET}`)
60
+ return `\x00${stash.length - 1}\x00`
61
+ })
62
+ if (options?.dimPipes) {
63
+ // While code spans are stashed, so pipes inside `...` keep their
64
+ // code styling instead of being re-wrapped in DIM.
65
+ out = out.replaceAll('|', `${DIM}|${RESET}`)
66
+ }
67
+ out = out.replace(
68
+ /\[([^\]\n]*)\]\(([^)\n]*)\)/g,
69
+ (_match, text: string, target: string) =>
70
+ `${DIM}[${RESET}${BLUE_UNDERLINE}${text}${RESET}${DIM}](${target})${RESET}`,
71
+ )
72
+ out = out.replace(/\*\*([^*\n]+)\*\*/g, (_match, text: string) => `${BOLD}**${text}**${RESET}`)
73
+ return out.replace(/\x00(\d+)\x00/g, (_match, index: string) => stash[Number(index)] ?? '')
74
+ }
75
+
76
+ const FENCE_PATTERN = /^(\s*)(`{3,}|~{3,})(.*)$/
77
+ const HEADING_PATTERN = /^#{1,6}\s/
78
+ const HR_PATTERN = /^ {0,3}(-{3,}|\*{3,}|_{3,})\s*$/
79
+ const LIST_MARKER_PATTERN = /^(\s*)([-*+]|\d{1,9}[.)])(\s+)(.*)$/
80
+ const FRONTMATTER_KEY_PATTERN = /^(\s*)([\w.-]+):(.*)$/
81
+
82
+ /**
83
+ * Wrap a markdown document in ANSI styles for terminal display. Line-based:
84
+ * headings, fences, front matter, blockquotes, list markers, and table pipes
85
+ * get block styling; plain text lines get inline code / link / bold styling.
86
+ */
87
+ export function highlightMarkdownAnsi(markdown: string): string {
88
+ const lines = markdown.split('\n')
89
+ const out: string[] = []
90
+ let inFrontmatter = false
91
+ let fenceClose: RegExp | undefined
92
+
93
+ lines.forEach((line, index) => {
94
+ if (fenceClose) {
95
+ if (FENCE_PATTERN.test(line) && fenceClose.test(line)) {
96
+ fenceClose = undefined
97
+ out.push(styleLine(DIM, line))
98
+ } else {
99
+ out.push(styleLine(YELLOW, line))
100
+ }
101
+ return
102
+ }
103
+
104
+ if (index === 0 && line === '---') {
105
+ inFrontmatter = true
106
+ out.push(styleLine(DIM, line))
107
+ return
108
+ }
109
+ if (inFrontmatter) {
110
+ if (line === '---') {
111
+ inFrontmatter = false
112
+ out.push(styleLine(DIM, line))
113
+ return
114
+ }
115
+ const keyMatch = line.match(FRONTMATTER_KEY_PATTERN)
116
+ out.push(
117
+ keyMatch ? `${keyMatch[1]}${CYAN}${keyMatch[2]}${RESET}${DIM}:${keyMatch[3]}${RESET}` : styleLine(DIM, line),
118
+ )
119
+ return
120
+ }
121
+
122
+ const fenceMatch = line.match(FENCE_PATTERN)
123
+ if (fenceMatch) {
124
+ const marker = fenceMatch[2] ?? '```'
125
+ fenceClose = new RegExp(`^\\s*${marker[0] === '`' ? '`' : '~'}{${marker.length},}\\s*$`)
126
+ out.push(styleLine(DIM, line))
127
+ return
128
+ }
129
+
130
+ if (HEADING_PATTERN.test(line)) {
131
+ out.push(styleLine(HEADING, line))
132
+ return
133
+ }
134
+ if (HR_PATTERN.test(line)) {
135
+ out.push(styleLine(DIM, line))
136
+ return
137
+ }
138
+ if (line.trimStart().startsWith('>')) {
139
+ out.push(styleLine(GREEN, line))
140
+ return
141
+ }
142
+ if (line.trimStart().startsWith('|')) {
143
+ out.push(styleInline(line, { dimPipes: true }))
144
+ return
145
+ }
146
+ const listMatch = line.match(LIST_MARKER_PATTERN)
147
+ if (listMatch) {
148
+ out.push(`${listMatch[1]}${YELLOW}${listMatch[2]}${RESET}${listMatch[3]}${styleInline(listMatch[4] ?? '')}`)
149
+ return
150
+ }
151
+ out.push(styleInline(line))
152
+ })
153
+
154
+ return out.join('\n')
155
+ }
156
+
157
+ /** Strip the escape sequences this module emits; used to verify lossless styling. */
158
+ export function stripAnsi(text: string): string {
159
+ // Only SGR sequences are emitted by this module.
160
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escapes are control characters by definition.
161
+ return text.replace(/\x1b\[[0-9;]*m/g, '')
162
+ }
@@ -1,71 +1,62 @@
1
1
  import { McpServer } from '@effect/ai'
2
2
  import { Effect, Layer, String } from 'effect'
3
+ import { ConfigManager } from '../Config.js'
3
4
 
4
- export const EXPAND_AGENT_SETUP_PROMPT =
5
- 'Read https://expand.ai/start.md and set up Expand for this project. Ask whether I want SDK/API, CLI, or agent-harness integration, then follow the matching instructions.'
5
+ /**
6
+ * These resources are read by an agent that will follow their links, so every
7
+ * URL must resolve to the stack this CLI is configured against — production by
8
+ * default, or whatever `EXPAND_APP_URL` names for local and self-hosted setups.
9
+ */
10
+ export function makeAgentSetupPrompt(appUrl: string): string {
11
+ return `Read ${new URL('/start.md', appUrl).toString()} and set up Expand. Fetch it as raw text with curl — a summarizing web-fetch tool drops the exact commands.`
12
+ }
6
13
 
7
- export const EXPAND_START_GUIDE_MARKDOWN = String.stripMargin(`
8
- |# Expand Start Guide
9
- |
10
- |Use this local copy when an MCP client needs setup guidance without fetching the hosted guide first.
11
- |
12
- |## Agent Setup Prompt
13
- |
14
- |${EXPAND_AGENT_SETUP_PROMPT}
15
- |
16
- |## Required CLI Setup
17
- |
18
- |\`\`\`sh
19
- |expandai login
20
- |\`\`\`
21
- |
22
- |## Claude Code
23
- |
24
- |\`\`\`sh
25
- |expandai hook install claude-code project
26
- |\`\`\`
27
- |
28
- |## OpenCode
29
- |
30
- |\`\`\`sh
31
- |expandai hook install opencode project
32
- |\`\`\`
33
- |
34
- |## Cursor
35
- |
36
- |\`\`\`sh
37
- |expandai skill install cursor project
38
- |expandai mcp install cursor project
39
- |\`\`\`
40
- |
41
- |## Codex
42
- |
43
- |\`\`\`sh
44
- |expandai skill install codex project
45
- |expandai mcp install codex project
46
- |\`\`\`
47
- |`)
14
+ /**
15
+ * Points at the hosted guide instead of restating it. A local copy of the setup
16
+ * procedure drifts silently the moment the commands change, and this resource
17
+ * is read by an agent that can fetch the current one itself.
18
+ */
19
+ export function makeStartGuideMarkdown(appUrl: string): string {
20
+ return String.stripMargin(`
21
+ |# Expand Start Guide
22
+ |
23
+ |The setup procedure is served as a single document. Fetch it and follow it:
24
+ |
25
+ |${new URL('/start.md', appUrl).toString()}
26
+ |
27
+ |## Agent Setup Prompt
28
+ |
29
+ |${makeAgentSetupPrompt(appUrl)}
30
+ |`)
31
+ }
32
+
33
+ export function makeAboutMarkdown(appUrl: string): string {
34
+ return String.stripMargin(`
35
+ |# expand.ai
36
+ |
37
+ |- Website: ${appUrl}
38
+ |- Quickstart: ${new URL('/docs', appUrl).toString()}
39
+ |- Dashboard: ${new URL('/dashboard', appUrl).toString()}
40
+ |`)
41
+ }
42
+
43
+ const appUrl = Effect.gen(function* () {
44
+ const configManager = yield* ConfigManager
45
+ return yield* configManager.getAppUrl()
46
+ })
48
47
 
49
48
  export const ExpandDocs = McpServer.resource({
50
49
  uri: 'expand://about',
51
50
  name: 'About expand.ai',
52
51
  description: 'Key links for the expand.ai platform',
53
- content: Effect.succeed(
54
- String.stripMargin(`
55
- |# expand.ai
56
- |
57
- |- Website: https://expand.ai
58
- |- Quickstart: https://expand.ai/docs
59
- |- Dashboard: https://expand.ai/dashboard
60
- |`),
61
- ),
52
+ content: Effect.map(appUrl, makeAboutMarkdown),
62
53
  })
63
54
 
64
55
  export const ExpandStartDocs = McpServer.resource({
65
56
  uri: 'expand://start',
66
57
  name: 'Expand Start Guide',
67
- description: 'Local copy of the Expand agent and editor setup guide',
68
- content: Effect.succeed(EXPAND_START_GUIDE_MARKDOWN),
58
+ description: 'Where to find the Expand agent setup guide, and the prompt that runs it',
59
+ content: Effect.map(appUrl, makeStartGuideMarkdown),
69
60
  })
70
61
 
71
- export const ExpandDocsLayer = Layer.mergeAll(ExpandDocs, ExpandStartDocs)
62
+ export const ExpandDocsLayer = Layer.mergeAll(ExpandDocs, ExpandStartDocs).pipe(Layer.provide(ConfigManager.Default))
package/src/Mcp/Server.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Layer } from 'effect'
2
2
  import { SERVER_KEY } from '../Install/Domain.js'
3
+ import { CLI_VERSION } from '../Version.js'
3
4
  import { ExpandDocsLayer } from './ExpandDocs.js'
4
5
  import { ExpandToolKit } from './Toolkit.js'
5
6
 
@@ -7,7 +8,7 @@ export { CurrentRequestHeaders } from './RequestHeaders.js'
7
8
 
8
9
  export const ServerInfo = {
9
10
  name: SERVER_KEY,
10
- version: '0.0.1',
11
+ version: CLI_VERSION,
11
12
  } as const
12
13
 
13
14
  export const makeServerLayer = (options: { readonly includeDocs?: boolean } = {}) =>
@@ -8,6 +8,9 @@ import * as Generated from '@expandai/sdk/Generated'
8
8
  import { citationUrl, playgroundBase, resolvePlaygroundHost } from '@expandai/sdk/Playground'
9
9
  import { Schema as S } from 'effect'
10
10
 
11
+ export const FetchInvocationPath = S.Literal('cli-recovery', 'mcp-fetch-search')
12
+ export type FetchInvocationPath = typeof FetchInvocationPath.Type
13
+
11
14
  export class CitedSnippet extends S.Class<CitedSnippet>('CitedSnippet')({
12
15
  type: S.optionalWith(S.Literal('text'), { nullable: true, default: () => 'text' as const }),
13
16
  source: S.Literal('markdown', 'appendix', 'statejson'),
@@ -20,6 +23,86 @@ export class CitedSnippet extends S.Class<CitedSnippet>('CitedSnippet')({
20
23
  }),
21
24
  }) {}
22
25
 
26
+ /**
27
+ * Carried in the tool result itself, not only in `SKILL.md`. A harness may not load
28
+ * skills at all, and the hook's snapshot note never reaches this path — so without
29
+ * this the model receives perfect `citationUrl`s and no instruction to use them, and
30
+ * answers with no citation. Observed on OpenCode before this was added.
31
+ */
32
+ export const CITATION_INSTRUCTIONS =
33
+ 'Cite every claim you take from this result. Append the ready-made `citationBlock` from this ' +
34
+ 'result to the end of your answer. Delete every entry whose marker you did not actually place ' +
35
+ 'in your text, and renumber what remains from [1] — an entry with no matching marker is not a ' +
36
+ 'citation, it is clutter. Do not ' +
37
+ 'retype the URLs, do not substitute the page you fetched, and do not build a link yourself — ' +
38
+ "the citation points at expand.ai's stored capture, not at the original site. Never invent an " +
39
+ 'evidence id and never reuse one from different text. A link without `?id=` is not a citation.'
40
+
41
+ const CitationInstructionsField = S.optionalWith(S.String, { default: () => CITATION_INSTRUCTIONS }).annotations({
42
+ description: 'How to cite this result back to the user. Follow it exactly.',
43
+ })
44
+
45
+ const CitationBlockField = S.optionalWith(S.String, { default: () => '' }).annotations({
46
+ description:
47
+ 'Ready-to-append citation block, one entry per citable snippet. Copy it to the end of your ' +
48
+ 'answer, keeping only the entries whose markers you placed in your text and renumbering ' +
49
+ 'from [1]. You may also correct the page title.',
50
+ })
51
+
52
+ /**
53
+ * Prebuilds the citation markdown instead of asking the model to assemble it.
54
+ * Selecting `citationUrl` out of a JSON result and pasting it correctly is the step
55
+ * weaker models get wrong — observed repeatedly on OpenCode, which substituted the
56
+ * fetched page URL for the capture link. Copying one block has no such failure mode.
57
+ */
58
+ const MAX_CITATION_MARKERS = 3
59
+ const EVIDENCE_MARKER = /\{(\d{1,6})\}/gu
60
+
61
+ /**
62
+ * Citation URLs for a plain fetch with no search. The markdown still stamps evidence
63
+ * ids, so a citation is always possible — without this a `fetch` called without a
64
+ * query produced an empty block and the model answered uncited.
65
+ */
66
+ function markdownCitationUrls(markdown: string, snapshotId: string, host: string): string[] {
67
+ const urls: string[] = []
68
+ for (const match of markdown.matchAll(EVIDENCE_MARKER)) {
69
+ const id = Number(match[1])
70
+ if (!Number.isFinite(id)) continue
71
+ const url = citationUrl(snapshotId, { evidenceId: id }, host)
72
+ if (!urls.includes(url)) urls.push(url)
73
+ if (urls.length >= MAX_CITATION_MARKERS) break
74
+ }
75
+ return urls
76
+ }
77
+
78
+ /**
79
+ * `statejson` snippets are extracted page state — arrays of routes, config blobs — not
80
+ * prose anyone can quote. Offering one as a citable source produced a `Sources` entry
81
+ * pointing at a list of URLs, so only quotable text earns a citation.
82
+ */
83
+ function isQuotable(snippet: CitedSnippet): boolean {
84
+ return snippet.source !== 'statejson'
85
+ }
86
+
87
+ function citationBlock(sourceLabel: string, snippets: ReadonlyArray<CitedSnippet>, fallback: string[] = []): string {
88
+ const urls: string[] = []
89
+ for (const snippet of snippets) {
90
+ if (!isQuotable(snippet)) continue
91
+ if (snippet.citationUrl.length > 0 && !urls.includes(snippet.citationUrl)) urls.push(snippet.citationUrl)
92
+ }
93
+ if (urls.length === 0) urls.push(...fallback)
94
+ if (urls.length === 0) return ''
95
+ // One linked source line per marker.
96
+ // Grouping all the sources first and all the links after loses every link when a
97
+ // model copies only the opening lines — observed on OpenCode.
98
+ const lines = ['**Sources**']
99
+ urls.slice(0, MAX_CITATION_MARKERS).forEach((url, index) => {
100
+ if (index > 0) lines.push('')
101
+ lines.push(`[${index + 1}] [${sourceLabel}](${url})`)
102
+ })
103
+ return lines.join('\n')
104
+ }
105
+
23
106
  export class CitedSearchResult extends S.Class<CitedSearchResult>('CitedSearchResult')({
24
107
  query: S.String,
25
108
  snippets: S.Array(CitedSnippet),
@@ -27,13 +110,15 @@ export class CitedSearchResult extends S.Class<CitedSearchResult>('CitedSearchRe
27
110
  }) {}
28
111
 
29
112
  export class CitedObjectModeResult extends S.Class<CitedObjectModeResult>('CitedObjectModeResult')({
113
+ invocationPath: S.Literal('mcp-fetch-search'),
114
+ citationInstructions: CitationInstructionsField,
115
+ citationBlock: CitationBlockField,
30
116
  meta: Generated.FetchObjectModeMeta,
31
117
  markdown: S.String,
32
118
  json: S.Array(Generated.FetchObjectModeEvidence),
33
119
  data: S.optionalWith(
34
120
  S.Struct({
35
121
  assets: S.optionalWith(S.Array(Generated.FetchAssetReference), { nullable: true }),
36
- captureTelemetry: S.optionalWith(Generated.CaptureTelemetryInfo, { nullable: true }),
37
122
  search: S.optionalWith(CitedSearchResult, { nullable: true }),
38
123
  }),
39
124
  { nullable: true },
@@ -41,6 +126,9 @@ export class CitedObjectModeResult extends S.Class<CitedObjectModeResult>('Cited
41
126
  }) {}
42
127
 
43
128
  export class CitedSnapshotSearchResult extends S.Class<CitedSnapshotSearchResult>('CitedSnapshotSearchResult')({
129
+ invocationPath: FetchInvocationPath,
130
+ citationInstructions: CitationInstructionsField,
131
+ citationBlock: CitationBlockField,
44
132
  snapshotId: S.String.pipe(S.minLength(1)),
45
133
  response: Generated.ResponseInfo,
46
134
  search: CitedSearchResult,
@@ -59,7 +147,7 @@ function toCitedSnippet(snapshotId: string, host: string, snippet: SnippetWithJs
59
147
  json: snippet.json,
60
148
  score: snippet.score,
61
149
  location: snippet.location,
62
- citationUrl: citationUrl(snapshotId, snippet.source, snippet.location, host),
150
+ citationUrl: citationUrl(snapshotId, snippet.location, host),
63
151
  })
64
152
  }
65
153
 
@@ -80,30 +168,51 @@ export function enrichObjectModeResult(
80
168
  // it is somehow not a parseable URL.
81
169
  const host = resolvePlaygroundHost(result.meta.playground, fallbackHost)
82
170
  const data = result.data
171
+ const citedSearch = data?.search == null ? undefined : toCitedSearch(result.meta.snapshotId, host, data.search)
172
+ const block = citationBlock(
173
+ result.meta.url,
174
+ citedSearch?.snippets ?? [],
175
+ markdownCitationUrls(result.markdown, result.meta.snapshotId, host),
176
+ )
83
177
  return new CitedObjectModeResult({
178
+ invocationPath: 'mcp-fetch-search',
179
+ citationInstructions: CITATION_INSTRUCTIONS,
180
+ citationBlock: block,
84
181
  meta: result.meta,
85
- markdown: result.markdown,
182
+ // The block is appended to the content, not just offered as a sibling field.
183
+ // A model summarising `markdown` carries an inline footer through; a separate
184
+ // JSON field it has to notice and copy gets dropped — repeatedly, on OpenCode.
185
+ markdown: block.length === 0 ? result.markdown : `${result.markdown.trimEnd()}\n\n---\n\n${block}\n`,
86
186
  json: result.json,
87
187
  data:
88
188
  data == null
89
189
  ? data
90
190
  : {
91
191
  assets: data.assets,
92
- captureTelemetry: data.captureTelemetry,
93
- search: data.search == null ? data.search : toCitedSearch(result.meta.snapshotId, host, data.search),
192
+ search: citedSearch,
94
193
  },
95
194
  })
96
195
  }
97
196
 
98
- /** Add `citationUrl` to each snippet of a snapshot-search result (no server `meta.playground`). */
197
+ /** Add `citationUrl` to each snippet of a snapshot-search result. */
99
198
  export function enrichSnapshotSearchResult(
100
199
  result: FetchSnapshotSearchResultWithJson,
101
- host: string,
200
+ fallbackHost: string,
201
+ invocationPath: FetchInvocationPath = 'mcp-fetch-search',
102
202
  ): CitedSnapshotSearchResult {
203
+ // `playground` is server-resolved (staging-safe), exactly like `meta.playground`
204
+ // above; fall back to the configured host only if it is somehow not a parseable
205
+ // URL. The playground is a separate deployment from the API, so the server is
206
+ // the only component that can say where its snapshots are publicly served.
207
+ const host = resolvePlaygroundHost(result.playground, fallbackHost)
208
+ const citedSearch = toCitedSearch(result.snapshotId, host, result.search)
103
209
  return new CitedSnapshotSearchResult({
210
+ invocationPath,
211
+ citationInstructions: CITATION_INSTRUCTIONS,
212
+ citationBlock: citationBlock(result.response.url, citedSearch.snippets),
104
213
  snapshotId: result.snapshotId,
105
214
  response: result.response,
106
- search: toCitedSearch(result.snapshotId, host, result.search),
215
+ search: citedSearch,
107
216
  durationMs: result.durationMs,
108
217
  playground: playgroundBase(result.snapshotId, host),
109
218
  })