expandai 0.0.0 → 0.0.2

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 (209) hide show
  1. package/README.md +241 -6
  2. package/build/dts/ApiClient.d.ts +231 -0
  3. package/build/dts/ApiClient.d.ts.map +1 -0
  4. package/build/dts/Authentication.d.ts +23 -0
  5. package/build/dts/Authentication.d.ts.map +1 -0
  6. package/build/dts/BrowserOAuth.d.ts +99 -0
  7. package/build/dts/BrowserOAuth.d.ts.map +1 -0
  8. package/build/dts/Commands/Fetch.d.ts +30 -0
  9. package/build/dts/Commands/Fetch.d.ts.map +1 -0
  10. package/build/dts/Commands/Hook.d.ts +17 -0
  11. package/build/dts/Commands/Hook.d.ts.map +1 -0
  12. package/build/dts/Commands/Mcp.d.ts +11 -0
  13. package/build/dts/Commands/Mcp.d.ts.map +1 -0
  14. package/build/dts/Commands/McpRun.d.ts +5 -0
  15. package/build/dts/Commands/McpRun.d.ts.map +1 -0
  16. package/build/dts/Commands/Search.d.ts +21 -0
  17. package/build/dts/Commands/Search.d.ts.map +1 -0
  18. package/build/dts/Commands/Skill.d.ts +12 -0
  19. package/build/dts/Commands/Skill.d.ts.map +1 -0
  20. package/build/dts/Commands/Uninstall.d.ts +9 -0
  21. package/build/dts/Commands/Uninstall.d.ts.map +1 -0
  22. package/build/dts/Config.d.ts +105 -0
  23. package/build/dts/Config.d.ts.map +1 -0
  24. package/build/dts/DeviceAuth.d.ts +87 -0
  25. package/build/dts/DeviceAuth.d.ts.map +1 -0
  26. package/build/dts/Hook/Claude.d.ts +13 -0
  27. package/build/dts/Hook/Claude.d.ts.map +1 -0
  28. package/build/dts/Hook/Errors.d.ts +20 -0
  29. package/build/dts/Hook/Errors.d.ts.map +1 -0
  30. package/build/dts/Hook/Opencode.d.ts +13 -0
  31. package/build/dts/Hook/Opencode.d.ts.map +1 -0
  32. package/build/dts/Hook/SnapshotNote.d.ts +8 -0
  33. package/build/dts/Hook/SnapshotNote.d.ts.map +1 -0
  34. package/build/dts/Install/Claude/Hook.d.ts +60 -0
  35. package/build/dts/Install/Claude/Hook.d.ts.map +1 -0
  36. package/build/dts/Install/Claude/Mcp.d.ts +45 -0
  37. package/build/dts/Install/Claude/Mcp.d.ts.map +1 -0
  38. package/build/dts/Install/Claude/Skill.d.ts +24 -0
  39. package/build/dts/Install/Claude/Skill.d.ts.map +1 -0
  40. package/build/dts/Install/Codex/Mcp.d.ts +39 -0
  41. package/build/dts/Install/Codex/Mcp.d.ts.map +1 -0
  42. package/build/dts/Install/Codex/Skill.d.ts +20 -0
  43. package/build/dts/Install/Codex/Skill.d.ts.map +1 -0
  44. package/build/dts/Install/Cursor/Mcp.d.ts +45 -0
  45. package/build/dts/Install/Cursor/Mcp.d.ts.map +1 -0
  46. package/build/dts/Install/Cursor/Skill.d.ts +27 -0
  47. package/build/dts/Install/Cursor/Skill.d.ts.map +1 -0
  48. package/build/dts/Install/Domain.d.ts +73 -0
  49. package/build/dts/Install/Domain.d.ts.map +1 -0
  50. package/build/dts/Install/Hook.d.ts +30 -0
  51. package/build/dts/Install/Hook.d.ts.map +1 -0
  52. package/build/dts/Install/Mcp.d.ts +28 -0
  53. package/build/dts/Install/Mcp.d.ts.map +1 -0
  54. package/build/dts/Install/Opencode/Hook.d.ts +23 -0
  55. package/build/dts/Install/Opencode/Hook.d.ts.map +1 -0
  56. package/build/dts/Install/Opencode/Mcp.d.ts +47 -0
  57. package/build/dts/Install/Opencode/Mcp.d.ts.map +1 -0
  58. package/build/dts/Install/Opencode/Skill.d.ts +24 -0
  59. package/build/dts/Install/Opencode/Skill.d.ts.map +1 -0
  60. package/build/dts/Install/Skill.d.ts +32 -0
  61. package/build/dts/Install/Skill.d.ts.map +1 -0
  62. package/build/dts/Install/Uninstall.d.ts +32 -0
  63. package/build/dts/Install/Uninstall.d.ts.map +1 -0
  64. package/build/dts/Mcp/ExpandDocs.d.ts +7 -0
  65. package/build/dts/Mcp/ExpandDocs.d.ts.map +1 -0
  66. package/build/dts/Mcp/RequestHeaders.d.ts +15 -0
  67. package/build/dts/Mcp/RequestHeaders.d.ts.map +1 -0
  68. package/build/dts/Mcp/Server.d.ts +10 -0
  69. package/build/dts/Mcp/Server.d.ts.map +1 -0
  70. package/build/dts/Mcp/Toolkit.d.ts +3 -0
  71. package/build/dts/Mcp/Toolkit.d.ts.map +1 -0
  72. package/build/dts/Mcp/tools/Citations.d.ts +237 -0
  73. package/build/dts/Mcp/tools/Citations.d.ts.map +1 -0
  74. package/build/dts/Mcp/tools/Fetch.d.ts +167 -0
  75. package/build/dts/Mcp/tools/Fetch.d.ts.map +1 -0
  76. package/build/dts/Snippets.d.ts +19 -0
  77. package/build/dts/Snippets.d.ts.map +1 -0
  78. package/build/dts/index.d.ts +2 -0
  79. package/build/dts/index.d.ts.map +1 -0
  80. package/build/dts/main.d.ts +3 -0
  81. package/build/dts/main.d.ts.map +1 -0
  82. package/build/esm/ApiClient.js +182 -0
  83. package/build/esm/ApiClient.js.map +1 -0
  84. package/build/esm/Authentication.js +112 -0
  85. package/build/esm/Authentication.js.map +1 -0
  86. package/build/esm/BrowserOAuth.js +254 -0
  87. package/build/esm/BrowserOAuth.js.map +1 -0
  88. package/build/esm/Commands/Fetch.js +95 -0
  89. package/build/esm/Commands/Fetch.js.map +1 -0
  90. package/build/esm/Commands/Hook.js +20 -0
  91. package/build/esm/Commands/Hook.js.map +1 -0
  92. package/build/esm/Commands/Mcp.js +19 -0
  93. package/build/esm/Commands/Mcp.js.map +1 -0
  94. package/build/esm/Commands/McpRun.js +15 -0
  95. package/build/esm/Commands/McpRun.js.map +1 -0
  96. package/build/esm/Commands/Search.js +43 -0
  97. package/build/esm/Commands/Search.js.map +1 -0
  98. package/build/esm/Commands/Skill.js +17 -0
  99. package/build/esm/Commands/Skill.js.map +1 -0
  100. package/build/esm/Commands/Uninstall.js +28 -0
  101. package/build/esm/Commands/Uninstall.js.map +1 -0
  102. package/build/esm/Config.js +145 -0
  103. package/build/esm/Config.js.map +1 -0
  104. package/build/esm/DeviceAuth.js +127 -0
  105. package/build/esm/DeviceAuth.js.map +1 -0
  106. package/build/esm/Hook/Claude.js +74 -0
  107. package/build/esm/Hook/Claude.js.map +1 -0
  108. package/build/esm/Hook/Errors.js +18 -0
  109. package/build/esm/Hook/Errors.js.map +1 -0
  110. package/build/esm/Hook/Opencode.js +50 -0
  111. package/build/esm/Hook/Opencode.js.map +1 -0
  112. package/build/esm/Hook/SnapshotNote.js +18 -0
  113. package/build/esm/Hook/SnapshotNote.js.map +1 -0
  114. package/build/esm/Install/Claude/Hook.js +89 -0
  115. package/build/esm/Install/Claude/Hook.js.map +1 -0
  116. package/build/esm/Install/Claude/Mcp.js +71 -0
  117. package/build/esm/Install/Claude/Mcp.js.map +1 -0
  118. package/build/esm/Install/Claude/Skill.js +34 -0
  119. package/build/esm/Install/Claude/Skill.js.map +1 -0
  120. package/build/esm/Install/Codex/Mcp.js +92 -0
  121. package/build/esm/Install/Codex/Mcp.js.map +1 -0
  122. package/build/esm/Install/Codex/Skill.js +30 -0
  123. package/build/esm/Install/Codex/Skill.js.map +1 -0
  124. package/build/esm/Install/Cursor/Mcp.js +71 -0
  125. package/build/esm/Install/Cursor/Mcp.js.map +1 -0
  126. package/build/esm/Install/Cursor/Skill.js +37 -0
  127. package/build/esm/Install/Cursor/Skill.js.map +1 -0
  128. package/build/esm/Install/Domain.js +105 -0
  129. package/build/esm/Install/Domain.js.map +1 -0
  130. package/build/esm/Install/Hook.js +38 -0
  131. package/build/esm/Install/Hook.js.map +1 -0
  132. package/build/esm/Install/Mcp.js +40 -0
  133. package/build/esm/Install/Mcp.js.map +1 -0
  134. package/build/esm/Install/Opencode/Hook.js +44 -0
  135. package/build/esm/Install/Opencode/Hook.js.map +1 -0
  136. package/build/esm/Install/Opencode/Mcp.js +73 -0
  137. package/build/esm/Install/Opencode/Mcp.js.map +1 -0
  138. package/build/esm/Install/Opencode/Skill.js +34 -0
  139. package/build/esm/Install/Opencode/Skill.js.map +1 -0
  140. package/build/esm/Install/Opencode/expandai-webfetch.js +38 -0
  141. package/build/esm/Install/Skill.js +41 -0
  142. package/build/esm/Install/Skill.js.map +1 -0
  143. package/build/esm/Install/Uninstall.js +49 -0
  144. package/build/esm/Install/Uninstall.js.map +1 -0
  145. package/build/esm/Mcp/ExpandDocs.js +64 -0
  146. package/build/esm/Mcp/ExpandDocs.js.map +1 -0
  147. package/build/esm/Mcp/RequestHeaders.js +13 -0
  148. package/build/esm/Mcp/RequestHeaders.js.map +1 -0
  149. package/build/esm/Mcp/Server.js +11 -0
  150. package/build/esm/Mcp/Server.js.map +1 -0
  151. package/build/esm/Mcp/Toolkit.js +16 -0
  152. package/build/esm/Mcp/Toolkit.js.map +1 -0
  153. package/build/esm/Mcp/tools/Citations.js +92 -0
  154. package/build/esm/Mcp/tools/Citations.js.map +1 -0
  155. package/build/esm/Mcp/tools/Fetch.js +141 -0
  156. package/build/esm/Mcp/tools/Fetch.js.map +1 -0
  157. package/build/esm/Snippets.js +18 -0
  158. package/build/esm/Snippets.js.map +1 -0
  159. package/build/esm/index.js +2 -0
  160. package/build/esm/index.js.map +1 -0
  161. package/build/esm/main.js +97 -0
  162. package/build/esm/main.js.map +1 -0
  163. package/build/skills/expandai/SKILL.md +72 -0
  164. package/package.json +72 -10
  165. package/src/ApiClient.ts +257 -0
  166. package/src/Authentication.ts +164 -0
  167. package/src/BrowserOAuth.ts +361 -0
  168. package/src/Commands/Fetch.ts +157 -0
  169. package/src/Commands/Hook.ts +48 -0
  170. package/src/Commands/Mcp.ts +30 -0
  171. package/src/Commands/McpRun.ts +25 -0
  172. package/src/Commands/Search.ts +92 -0
  173. package/src/Commands/Skill.ts +30 -0
  174. package/src/Commands/Uninstall.ts +38 -0
  175. package/src/Config.ts +195 -0
  176. package/src/DeviceAuth.ts +193 -0
  177. package/src/Hook/Claude.ts +81 -0
  178. package/src/Hook/Errors.ts +19 -0
  179. package/src/Hook/Opencode.ts +58 -0
  180. package/src/Hook/SnapshotNote.ts +18 -0
  181. package/src/Install/Claude/Hook.ts +133 -0
  182. package/src/Install/Claude/Mcp.ts +82 -0
  183. package/src/Install/Claude/Skill.ts +44 -0
  184. package/src/Install/Codex/Mcp.ts +109 -0
  185. package/src/Install/Codex/Skill.ts +40 -0
  186. package/src/Install/Cursor/Mcp.ts +82 -0
  187. package/src/Install/Cursor/Skill.ts +47 -0
  188. package/src/Install/Domain.ts +148 -0
  189. package/src/Install/Hook.ts +50 -0
  190. package/src/Install/Mcp.ts +71 -0
  191. package/src/Install/Opencode/Hook.ts +55 -0
  192. package/src/Install/Opencode/Mcp.ts +84 -0
  193. package/src/Install/Opencode/Skill.ts +44 -0
  194. package/src/Install/Opencode/expandai-webfetch.d.ts +23 -0
  195. package/src/Install/Opencode/expandai-webfetch.js +38 -0
  196. package/src/Install/Skill.ts +57 -0
  197. package/src/Install/Uninstall.ts +82 -0
  198. package/src/Mcp/ExpandDocs.ts +71 -0
  199. package/src/Mcp/RequestHeaders.ts +16 -0
  200. package/src/Mcp/Server.ts +14 -0
  201. package/src/Mcp/Toolkit.ts +21 -0
  202. package/src/Mcp/tools/Citations.ts +110 -0
  203. package/src/Mcp/tools/Fetch.ts +177 -0
  204. package/src/Snippets.ts +32 -0
  205. package/src/index.ts +1 -0
  206. package/src/main.ts +146 -0
  207. package/bun.lockb +0 -0
  208. package/index.ts +0 -1
  209. package/tsconfig.json +0 -27
@@ -0,0 +1,58 @@
1
+ import { NodeStream } from '@effect/platform-node'
2
+ import { Console, Effect, Schema, String } from 'effect'
3
+ import { ApiClient } from '../ApiClient.js'
4
+ import { MarkdownUnavailable } from '../Commands/Fetch.js'
5
+ import { HookProtocolFailed, StdinReadError } from './Errors.js'
6
+ import { withSnapshotNote } from './SnapshotNote.js'
7
+
8
+ /**
9
+ * Runner for `expandai hook opencode`. Reads `{ url }` from stdin (sent by
10
+ * the OpenCode plugin), fetches via expand.ai, and writes `{ markdown }`
11
+ * to stdout for the plugin to splice back into webfetch's result.
12
+ */
13
+
14
+ class ProtocolInput extends Schema.Class<ProtocolInput>('OpencodeWebfetchInput')({
15
+ url: Schema.String,
16
+ }) {}
17
+
18
+ const decodeProtocolInput = Schema.decode(Schema.parseJson(ProtocolInput))
19
+ const encodeProtocolOutput = Schema.encode(Schema.parseJson(Schema.Unknown))
20
+
21
+ const readStdin = NodeStream.toString(() => process.stdin, {
22
+ onFailure: (error) => new StdinReadError({ error }),
23
+ encoding: 'utf8',
24
+ })
25
+
26
+ export class OpencodeHook extends Effect.Service<OpencodeHook>()('@expandai/cli/Hook/Opencode', {
27
+ dependencies: [ApiClient.Default],
28
+ effect: Effect.gen(function* () {
29
+ const apiClient = yield* ApiClient
30
+
31
+ const runProtocol = Effect.fn('OpencodeHook.runProtocol')(function* () {
32
+ const raw = yield* readStdin.pipe(Effect.mapError((error) => new HookProtocolFailed({ error })))
33
+ const payload = yield* decodeProtocolInput(raw).pipe(
34
+ Effect.mapError((error) => new HookProtocolFailed({ error })),
35
+ )
36
+ const url = payload.url
37
+
38
+ yield* Effect.gen(function* () {
39
+ // Object mode (`/v1/fetch/json`) over markdown mode so the response carries
40
+ // `meta.snapshotId` — the handle the agent uses to search this same capture
41
+ // via `fetch_search` instead of fetching the URL again. The note rides inside
42
+ // `markdown`, so the installed plugin needs no protocol change.
43
+ const result = yield* apiClient.fetchFetchResult({
44
+ params: {},
45
+ payload: { url, include: { markdown: true } },
46
+ })
47
+ if (String.isEmpty(result.markdown)) {
48
+ return yield* new MarkdownUnavailable({ url })
49
+ }
50
+
51
+ const markdown = withSnapshotNote(result.markdown, result.meta.snapshotId, result.meta.playground)
52
+ yield* Console.log(yield* encodeProtocolOutput({ markdown }))
53
+ }).pipe(Effect.mapError((error) => new HookProtocolFailed({ error, url })))
54
+ })
55
+
56
+ return { runProtocol } as const
57
+ }),
58
+ }) {}
@@ -0,0 +1,18 @@
1
+ import { String } from 'effect'
2
+
3
+ /**
4
+ * Prepends a short note to fetched markdown carrying the snapshot handle and the public
5
+ * playground link, and telling the agent how to search the already-captured page by handle
6
+ * (rather than fetching the URL again) and cite specific passages back to the user. Both the
7
+ * Claude Code and OpenCode hooks inject this so the surfaced guidance stays identical.
8
+ */
9
+ export function withSnapshotNote(markdown: string, snapshotId: string, playgroundUrl: string): string {
10
+ const note = String.stripMargin(`
11
+ |> Fetched via expand.ai (snapshot \`${snapshotId}\`). View or share this capture at
12
+ |> ${playgroundUrl}
13
+ |> To extract specific passages, search the snapshot by handle — call the \`fetch_search\`
14
+ |> tool with \`snapshotId: "${snapshotId}"\`, or run \`expandai search ${snapshotId} "<query>"\`.
15
+ |> Each snippet returns a \`citationUrl\` you can hand the user to audit the exact source.
16
+ |> Do not fetch this URL again.`)
17
+ return `${note}\n\n---\n\n${markdown}`
18
+ }
@@ -0,0 +1,133 @@
1
+ import { homedir } from 'node:os'
2
+ import { Array, Config, Console, Effect, Match, Schema } from 'effect'
3
+ import { ConfigIo } from '../Domain.js'
4
+ import type { Scope } from '../Mcp.js'
5
+
6
+ /**
7
+ * Claude Code hook config — `~/.claude/settings.json` (global) or
8
+ * `<cwd>/.claude/settings.json` (project). PreToolUse hooks fire on built-in
9
+ * tools; we register the WebFetch matcher to route to `expandai hook claude-code`.
10
+ */
11
+
12
+ export const HOOK_COMMAND = 'expandai hook claude-code'
13
+ export const WEBFETCH_MATCHER = 'WebFetch'
14
+
15
+ export const HookCommandEntry = Schema.Struct({
16
+ type: Schema.Literal('command'),
17
+ command: Schema.String,
18
+ })
19
+
20
+ export const HookMatcher = Schema.Struct({
21
+ matcher: Schema.optional(Schema.String),
22
+ hooks: Schema.Array(HookCommandEntry),
23
+ })
24
+
25
+ const HooksSection = Schema.Struct(
26
+ { PreToolUse: Schema.optional(Schema.Array(HookMatcher)) },
27
+ Schema.Record({ key: Schema.String, value: Schema.Unknown }),
28
+ )
29
+
30
+ export const SettingsSchema = Schema.parseJson(
31
+ Schema.Struct(
32
+ { hooks: Schema.optionalWith(HooksSection, { default: () => ({}) as typeof HooksSection.Type }) },
33
+ Schema.Record({ key: Schema.String, value: Schema.Unknown }),
34
+ ),
35
+ { space: 2 },
36
+ )
37
+
38
+ export const expandaiMatcher = {
39
+ matcher: WEBFETCH_MATCHER,
40
+ hooks: [{ type: 'command' as const, command: HOOK_COMMAND }],
41
+ }
42
+
43
+ export class ClaudeHook extends Effect.Service<ClaudeHook>()('@expandai/cli/Install/Claude/Hook', {
44
+ dependencies: [ConfigIo.Default],
45
+ effect: Effect.gen(function* () {
46
+ const io = yield* ConfigIo
47
+ const home = yield* Config.string('HOME').pipe(Config.withDefault(homedir()))
48
+ const cwd = yield* Config.string('PWD').pipe(Config.withDefault(process.cwd()))
49
+
50
+ const pathFor = Effect.fn(function* (scope: Scope) {
51
+ return yield* Match.value(scope).pipe(
52
+ Match.when('global', () => Effect.succeed(`${home}/.claude/settings.json`)),
53
+ Match.when('project', () => Effect.succeed(`${cwd}/.claude/settings.json`)),
54
+ Match.exhaustive,
55
+ )
56
+ })
57
+
58
+ const install = Effect.fn('ClaudeHook.install')(function* (scope: Scope) {
59
+ const target = yield* pathFor(scope)
60
+ const settings = yield* io.read(target, SettingsSchema)
61
+
62
+ const preToolUse = settings.hooks.PreToolUse ?? []
63
+ const alreadyInstalled = preToolUse.some(
64
+ (entry) => entry.matcher === WEBFETCH_MATCHER && entry.hooks.some((hook) => hook.command === HOOK_COMMAND),
65
+ )
66
+
67
+ if (alreadyInstalled) {
68
+ yield* Console.log(`expandai WebFetch hook already installed at ${target}`)
69
+ return
70
+ }
71
+
72
+ yield* io.write(
73
+ target,
74
+ {
75
+ ...settings,
76
+ hooks: {
77
+ ...settings.hooks,
78
+ PreToolUse: [...preToolUse, expandaiMatcher],
79
+ },
80
+ },
81
+ SettingsSchema,
82
+ )
83
+ yield* Console.log(`Installed expandai WebFetch hook at ${target}`)
84
+ })
85
+
86
+ const uninstall = Effect.fn('ClaudeHook.uninstall')(function* (scope: Scope) {
87
+ const target = yield* pathFor(scope)
88
+ const exists = yield* io.exists(target)
89
+ if (!exists) {
90
+ return
91
+ }
92
+
93
+ const settings = yield* io.read(target, SettingsSchema)
94
+ const preToolUse = settings.hooks.PreToolUse ?? []
95
+ const updatedPreToolUse = Array.filter(
96
+ Array.map(preToolUse, (entry) => ({
97
+ ...entry,
98
+ hooks: Array.filter(entry.hooks, (hook) => hook.command !== HOOK_COMMAND),
99
+ })),
100
+ (entry) => entry.matcher !== WEBFETCH_MATCHER || entry.hooks.length > 0,
101
+ )
102
+
103
+ // Compare total hook-command count rather than outer-array length:
104
+ // our HOOK_COMMAND can sit alongside other hooks in a shared matcher
105
+ // entry (e.g. a user merged it in by hand), in which case the entry
106
+ // stays after filtering but the inner hook count drops.
107
+ const countCommands = Effect.fn(function* (entries: typeof preToolUse) {
108
+ return Array.reduce(entries, 0, (n, entry) => n + entry.hooks.length)
109
+ })
110
+
111
+ const before = yield* countCommands(preToolUse)
112
+ const after = yield* countCommands(updatedPreToolUse)
113
+ if (before === after) {
114
+ return
115
+ }
116
+
117
+ yield* io.write(
118
+ target,
119
+ {
120
+ ...settings,
121
+ hooks: {
122
+ ...settings.hooks,
123
+ PreToolUse: updatedPreToolUse,
124
+ },
125
+ },
126
+ SettingsSchema,
127
+ )
128
+ yield* Console.log(`Removed expandai WebFetch hook from ${target}`)
129
+ })
130
+
131
+ return { install, uninstall } as const
132
+ }),
133
+ }) {}
@@ -0,0 +1,82 @@
1
+ import { homedir } from 'node:os'
2
+ import { Config, Console, Effect, Match, Record, Schema } from 'effect'
3
+ import { ConfigIo, McpParseError, McpServersConfigSchema, McpWriteError, SERVER_KEY } from '../Domain.js'
4
+ import type { Scope } from '../Mcp.js'
5
+
6
+ /**
7
+ * Claude Code MCP descriptor — `mcpServers` shape (de facto Claude Desktop
8
+ * convention; also read by Cursor and other clients).
9
+ *
10
+ * - global → `~/.claude.json` (Claude Code's user-scope store)
11
+ * - project → `<cwd>/.mcp.json` (the agent-agnostic project file)
12
+ */
13
+ export class Entry extends Schema.Class<Entry>('ClaudeMcpEntry')({
14
+ type: Schema.Literal('stdio'),
15
+ command: Schema.NonEmptyTrimmedString,
16
+ args: Schema.Array(Schema.String),
17
+ env: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.String })),
18
+ }) {}
19
+
20
+ export class ClaudeMcp extends Effect.Service<ClaudeMcp>()('@expandai/cli/Install/Claude/Mcp', {
21
+ dependencies: [ConfigIo.Default],
22
+ effect: Effect.gen(function* () {
23
+ const io = yield* ConfigIo
24
+ const home = yield* Config.string('HOME').pipe(Config.withDefault(homedir()))
25
+ const cwd = yield* Config.string('PWD').pipe(Config.withDefault(process.cwd()))
26
+
27
+ const pathFor = Effect.fn(function* (scope: Scope) {
28
+ return yield* Match.value(scope).pipe(
29
+ Match.when('global', () => Effect.succeed(`${home}/.claude.json`)),
30
+ Match.when('project', () => Effect.succeed(`${cwd}/.mcp.json`)),
31
+ Match.exhaustive,
32
+ )
33
+ })
34
+
35
+ const readConfig = Effect.fn(function* (file: string) {
36
+ return yield* io
37
+ .read(file, McpServersConfigSchema)
38
+ .pipe(Effect.mapError((error) => new McpParseError({ path: file, error })))
39
+ })
40
+
41
+ const writeConfig = Effect.fn(function* (file: string, value: unknown) {
42
+ yield* io
43
+ .write(file, value, McpServersConfigSchema)
44
+ .pipe(Effect.mapError((error) => new McpWriteError({ path: file, error })))
45
+ })
46
+
47
+ const install = Effect.fn(function* (scope: Scope) {
48
+ const file = yield* pathFor(scope)
49
+ const config = yield* readConfig(file)
50
+ yield* writeConfig(file, {
51
+ ...config,
52
+ mcpServers: {
53
+ ...config.mcpServers,
54
+ [SERVER_KEY]: new Entry({
55
+ type: 'stdio',
56
+ command: 'expandai',
57
+ args: ['mcp', 'run'],
58
+ }),
59
+ },
60
+ })
61
+ yield* Console.log(`Installed expandai MCP server into ${file}`)
62
+ })
63
+
64
+ const uninstall = Effect.fn(function* (scope: Scope) {
65
+ const file = yield* pathFor(scope)
66
+ const config = yield* readConfig(file)
67
+ if (!(SERVER_KEY in config.mcpServers)) {
68
+ return
69
+ }
70
+ yield* writeConfig(file, { ...config, mcpServers: Record.remove(config.mcpServers, SERVER_KEY) })
71
+ yield* Console.log(`Removed expandai MCP server from ${file}`)
72
+ })
73
+
74
+ const isInstalled = Effect.fn(function* (scope: Scope) {
75
+ const file = yield* pathFor(scope)
76
+ const config = yield* readConfig(file)
77
+ return SERVER_KEY in config.mcpServers
78
+ })
79
+
80
+ return { install, uninstall, isInstalled, pathFor } as const
81
+ }),
82
+ }) {}
@@ -0,0 +1,44 @@
1
+ import { homedir } from 'node:os'
2
+ import { Config, Effect, Match } from 'effect'
3
+ import { SkillIo } from '../Domain.js'
4
+ import type { Scope } from '../Mcp.js'
5
+
6
+ /**
7
+ * Claude Code skills live under `<scope>/.claude/skills/<name>/SKILL.md`.
8
+ *
9
+ * - global → `~/.claude/skills/expandai/SKILL.md`
10
+ * - project → `<cwd>/.claude/skills/expandai/SKILL.md`
11
+ *
12
+ * (Globally-scoped Claude Code skills are also reachable via `npx skills`,
13
+ * but file-copy gives us a uniform install path with OpenCode and works
14
+ * without npm.)
15
+ */
16
+
17
+ export const SKILL_NAME = 'expandai'
18
+
19
+ export class ClaudeSkill extends Effect.Service<ClaudeSkill>()('@expandai/cli/Install/Claude/Skill', {
20
+ dependencies: [SkillIo.Default],
21
+ effect: Effect.gen(function* () {
22
+ const io = yield* SkillIo
23
+ const home = yield* Config.string('HOME').pipe(Config.withDefault(homedir()))
24
+ const cwd = yield* Config.string('PWD').pipe(Config.withDefault(process.cwd()))
25
+
26
+ const pathFor = Effect.fn(function* (scope: Scope) {
27
+ return yield* Match.value(scope).pipe(
28
+ Match.when('global', () => Effect.succeed(`${home}/.claude/skills/${SKILL_NAME}`)),
29
+ Match.when('project', () => Effect.succeed(`${cwd}/.claude/skills/${SKILL_NAME}`)),
30
+ Match.exhaustive,
31
+ )
32
+ })
33
+
34
+ const install = Effect.fn(function* (scope: Scope) {
35
+ return yield* io.install(yield* pathFor(scope))
36
+ })
37
+
38
+ const uninstall = Effect.fn(function* (scope: Scope) {
39
+ return yield* io.uninstall(yield* pathFor(scope))
40
+ })
41
+
42
+ return { install, uninstall } as const
43
+ }),
44
+ }) {}
@@ -0,0 +1,109 @@
1
+ import { homedir } from 'node:os'
2
+ import { Config, Console, Effect, Match, Record, Schema } from 'effect'
3
+ import { parse, stringify, type TomlTableWithoutBigInt } from 'smol-toml'
4
+ import { ConfigIo, McpParseError, McpWriteError, SERVER_KEY } from '../Domain.js'
5
+ import type { Scope } from '../Mcp.js'
6
+
7
+ export class Entry extends Schema.Class<Entry>('CodexMcpEntry')({
8
+ command: Schema.NonEmptyTrimmedString,
9
+ args: Schema.Array(Schema.String),
10
+ enabled: Schema.Boolean,
11
+ }) {}
12
+
13
+ function isRecord(value: unknown): value is Record<string, unknown> {
14
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
15
+ }
16
+
17
+ const getMcpServers = Effect.fn(function* (config: TomlTableWithoutBigInt) {
18
+ const servers = config.mcp_servers
19
+ return isRecord(servers) ? servers : {}
20
+ })
21
+
22
+ function toStringifyValue(value: TomlTableWithoutBigInt) {
23
+ // smol-toml's stringify type accepts any; keep the unsafe boundary local.
24
+ return value as unknown as Record<string, unknown>
25
+ }
26
+
27
+ /**
28
+ * Codex MCP descriptor — Codex stores MCP servers in TOML config under
29
+ * `[mcp_servers.<name>]`.
30
+ *
31
+ * - global → `~/.codex/config.toml`
32
+ * - project → `<cwd>/.codex/config.toml`
33
+ */
34
+ export class CodexMcp extends Effect.Service<CodexMcp>()('@expandai/cli/Install/Codex/Mcp', {
35
+ dependencies: [ConfigIo.Default],
36
+ effect: Effect.gen(function* () {
37
+ const io = yield* ConfigIo
38
+ const home = yield* Config.string('HOME').pipe(Config.withDefault(homedir()))
39
+ const cwd = yield* Config.string('PWD').pipe(Config.withDefault(process.cwd()))
40
+
41
+ const pathFor = Effect.fn(function* (scope: Scope) {
42
+ return yield* Match.value(scope).pipe(
43
+ Match.when('global', () => Effect.succeed(`${home}/.codex/config.toml`)),
44
+ Match.when('project', () => Effect.succeed(`${cwd}/.codex/config.toml`)),
45
+ Match.exhaustive,
46
+ )
47
+ })
48
+
49
+ const readConfig = Effect.fn(function* (file: string) {
50
+ const text = yield* io.readText(file)
51
+ return yield* Effect.try({
52
+ try: () => parse(text, { integersAsBigInt: false }),
53
+ catch: (error) => new McpParseError({ path: file, error }),
54
+ })
55
+ })
56
+
57
+ const writeConfig = Effect.fn(function* (file: string, value: TomlTableWithoutBigInt) {
58
+ const encoded = yield* Effect.try({
59
+ try: () => stringify(toStringifyValue(value)),
60
+ catch: (error) => new McpWriteError({ path: file, error }),
61
+ })
62
+ yield* io.writeText(file, encoded).pipe(Effect.mapError((error) => new McpWriteError({ path: file, error })))
63
+ })
64
+
65
+ const install = Effect.fn(function* (scope: Scope) {
66
+ const file = yield* pathFor(scope)
67
+ const config = yield* readConfig(file)
68
+ const servers = yield* getMcpServers(config)
69
+
70
+ const entryModel = new Entry({
71
+ command: 'expandai',
72
+ args: ['mcp', 'run'],
73
+ enabled: true,
74
+ })
75
+
76
+ const entry: TomlTableWithoutBigInt = {
77
+ command: entryModel.command,
78
+ args: [...entryModel.args],
79
+ enabled: entryModel.enabled,
80
+ }
81
+
82
+ yield* writeConfig(file, {
83
+ ...config,
84
+ mcp_servers: Record.set(servers, SERVER_KEY, entry),
85
+ })
86
+ yield* Console.log(`Installed expandai MCP server into ${file}`)
87
+ })
88
+
89
+ const uninstall = Effect.fn(function* (scope: Scope) {
90
+ const file = yield* pathFor(scope)
91
+ const config = yield* readConfig(file)
92
+ const servers = yield* getMcpServers(config)
93
+ if (!(SERVER_KEY in servers)) {
94
+ return
95
+ }
96
+ yield* writeConfig(file, { ...config, mcp_servers: Record.remove(servers, SERVER_KEY) })
97
+ yield* Console.log(`Removed expandai MCP server from ${file}`)
98
+ })
99
+
100
+ const isInstalled = Effect.fn(function* (scope: Scope) {
101
+ const file = yield* pathFor(scope)
102
+ const config = yield* readConfig(file)
103
+ const servers = yield* getMcpServers(config)
104
+ return SERVER_KEY in servers
105
+ })
106
+
107
+ return { install, uninstall, isInstalled, pathFor } as const
108
+ }),
109
+ }) {}
@@ -0,0 +1,40 @@
1
+ import { homedir } from 'node:os'
2
+ import { Config, Effect, Match } from 'effect'
3
+ import { SkillIo } from '../Domain.js'
4
+ import type { Scope } from '../Mcp.js'
5
+
6
+ /**
7
+ * Codex skills use the open agent skills locations:
8
+ *
9
+ * - global → `~/.agents/skills/<name>/`
10
+ * - project → `<cwd>/.agents/skills/<name>/`
11
+ */
12
+
13
+ export const SKILL_NAME = 'expandai'
14
+
15
+ export class CodexSkill extends Effect.Service<CodexSkill>()('@expandai/cli/Install/Codex/Skill', {
16
+ dependencies: [SkillIo.Default],
17
+ effect: Effect.gen(function* () {
18
+ const io = yield* SkillIo
19
+ const home = yield* Config.string('HOME').pipe(Config.withDefault(homedir()))
20
+ const cwd = yield* Config.string('PWD').pipe(Config.withDefault(process.cwd()))
21
+
22
+ const pathFor = Effect.fn(function* (scope: Scope) {
23
+ return yield* Match.value(scope).pipe(
24
+ Match.when('global', () => Effect.succeed(`${home}/.agents/skills/${SKILL_NAME}`)),
25
+ Match.when('project', () => Effect.succeed(`${cwd}/.agents/skills/${SKILL_NAME}`)),
26
+ Match.exhaustive,
27
+ )
28
+ })
29
+
30
+ const install = Effect.fn(function* (scope: Scope) {
31
+ return yield* io.install(yield* pathFor(scope))
32
+ })
33
+
34
+ const uninstall = Effect.fn(function* (scope: Scope) {
35
+ return yield* io.uninstall(yield* pathFor(scope))
36
+ })
37
+
38
+ return { install, uninstall } as const
39
+ }),
40
+ }) {}
@@ -0,0 +1,82 @@
1
+ import { homedir } from 'node:os'
2
+ import { Config, Console, Effect, Match, Record, Schema } from 'effect'
3
+ import { ConfigIo, McpParseError, McpServersConfigSchema, McpWriteError, SERVER_KEY } from '../Domain.js'
4
+ import type { Scope } from '../Mcp.js'
5
+
6
+ /**
7
+ * Cursor MCP descriptor — same `mcpServers` shape as Claude Code, different
8
+ * paths.
9
+ *
10
+ * - global → `~/.cursor/mcp.json`
11
+ * - project → `<cwd>/.cursor/mcp.json`
12
+ */
13
+ export class Entry extends Schema.Class<Entry>('CursorMcpEntry')({
14
+ type: Schema.Literal('stdio'),
15
+ command: Schema.NonEmptyTrimmedString,
16
+ args: Schema.Array(Schema.String),
17
+ env: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.String })),
18
+ }) {}
19
+
20
+ export class CursorMcp extends Effect.Service<CursorMcp>()('@expandai/cli/Install/Cursor/Mcp', {
21
+ dependencies: [ConfigIo.Default],
22
+ effect: Effect.gen(function* () {
23
+ const io = yield* ConfigIo
24
+ const home = yield* Config.string('HOME').pipe(Config.withDefault(homedir()))
25
+ const cwd = yield* Config.string('PWD').pipe(Config.withDefault(process.cwd()))
26
+
27
+ const pathFor = Effect.fn(function* (scope: Scope) {
28
+ return yield* Match.value(scope).pipe(
29
+ Match.when('global', () => Effect.succeed(`${home}/.cursor/mcp.json`)),
30
+ Match.when('project', () => Effect.succeed(`${cwd}/.cursor/mcp.json`)),
31
+ Match.exhaustive,
32
+ )
33
+ })
34
+
35
+ const readConfig = Effect.fn(function* (file: string) {
36
+ return yield* io
37
+ .read(file, McpServersConfigSchema)
38
+ .pipe(Effect.mapError((error) => new McpParseError({ path: file, error })))
39
+ })
40
+
41
+ const writeConfig = Effect.fn(function* (file: string, value: unknown) {
42
+ yield* io
43
+ .write(file, value, McpServersConfigSchema)
44
+ .pipe(Effect.mapError((error) => new McpWriteError({ path: file, error })))
45
+ })
46
+
47
+ const install = Effect.fn(function* (scope: Scope) {
48
+ const file = yield* pathFor(scope)
49
+ const config = yield* readConfig(file)
50
+ yield* writeConfig(file, {
51
+ ...config,
52
+ mcpServers: {
53
+ ...config.mcpServers,
54
+ [SERVER_KEY]: new Entry({
55
+ type: 'stdio',
56
+ command: 'expandai',
57
+ args: ['mcp', 'run'],
58
+ }),
59
+ },
60
+ })
61
+ yield* Console.log(`Installed expandai MCP server into ${file}`)
62
+ })
63
+
64
+ const uninstall = Effect.fn(function* (scope: Scope) {
65
+ const file = yield* pathFor(scope)
66
+ const config = yield* readConfig(file)
67
+ if (!(SERVER_KEY in config.mcpServers)) {
68
+ return
69
+ }
70
+ yield* writeConfig(file, { ...config, mcpServers: Record.remove(config.mcpServers, SERVER_KEY) })
71
+ yield* Console.log(`Removed expandai MCP server from ${file}`)
72
+ })
73
+
74
+ const isInstalled = Effect.fn(function* (scope: Scope) {
75
+ const file = yield* pathFor(scope)
76
+ const config = yield* readConfig(file)
77
+ return SERVER_KEY in config.mcpServers
78
+ })
79
+
80
+ return { install, uninstall, isInstalled, pathFor } as const
81
+ }),
82
+ }) {}
@@ -0,0 +1,47 @@
1
+ import { homedir } from 'node:os'
2
+ import { Config, Effect, Match } from 'effect'
3
+ import { SkillIo } from '../Domain.js'
4
+ import type { Scope } from '../Mcp.js'
5
+
6
+ /**
7
+ * Cursor skills are auto-loaded from these locations (per
8
+ * cursor.com/docs/skills):
9
+ *
10
+ * - global → `~/.cursor/skills/<name>/`
11
+ * - project → `<cwd>/.cursor/skills/<name>/`
12
+ *
13
+ * Cursor also compat-loads `.claude/skills/`, `.codex/skills/`, `.agents/skills/`
14
+ * at both project and user scope, but we ship to Cursor's native paths.
15
+ *
16
+ * Format is the same SKILL.md + YAML frontmatter open standard as Claude Code
17
+ * and OpenCode, so the bundled `skills/expandai/` directory copies in unchanged.
18
+ */
19
+
20
+ export const SKILL_NAME = 'expandai'
21
+
22
+ export class CursorSkill extends Effect.Service<CursorSkill>()('@expandai/cli/Install/Cursor/Skill', {
23
+ dependencies: [SkillIo.Default],
24
+ effect: Effect.gen(function* () {
25
+ const io = yield* SkillIo
26
+ const home = yield* Config.string('HOME').pipe(Config.withDefault(homedir()))
27
+ const cwd = yield* Config.string('PWD').pipe(Config.withDefault(process.cwd()))
28
+
29
+ const pathFor = Effect.fn(function* (scope: Scope) {
30
+ return yield* Match.value(scope).pipe(
31
+ Match.when('global', () => Effect.succeed(`${home}/.cursor/skills/${SKILL_NAME}`)),
32
+ Match.when('project', () => Effect.succeed(`${cwd}/.cursor/skills/${SKILL_NAME}`)),
33
+ Match.exhaustive,
34
+ )
35
+ })
36
+
37
+ const install = Effect.fn(function* (scope: Scope) {
38
+ return yield* io.install(yield* pathFor(scope))
39
+ })
40
+
41
+ const uninstall = Effect.fn(function* (scope: Scope) {
42
+ return yield* io.uninstall(yield* pathFor(scope))
43
+ })
44
+
45
+ return { install, uninstall } as const
46
+ }),
47
+ }) {}