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,148 @@
1
+ import { FileSystem, Path } from '@effect/platform'
2
+ import { NodeFileSystem, NodePath } from '@effect/platform-node'
3
+ import { Effect, Inspectable, Schema } from 'effect'
4
+
5
+ export class McpParseError extends Schema.TaggedError<McpParseError>()('McpParseError', {
6
+ path: Schema.String,
7
+ error: Schema.Defect,
8
+ }) {
9
+ get message() {
10
+ return `Failed to parse MCP config at ${this.path}: ${Inspectable.format(this.error)}`
11
+ }
12
+ }
13
+
14
+ export class McpWriteError extends Schema.TaggedError<McpWriteError>()('McpWriteError', {
15
+ path: Schema.String,
16
+ error: Schema.Defect,
17
+ }) {
18
+ get message() {
19
+ return `Failed to write MCP config at ${this.path}: ${Inspectable.format(this.error)}`
20
+ }
21
+ }
22
+
23
+ export const Host = Schema.Literal('claude-code', 'cursor', 'opencode', 'codex')
24
+ export type Host = typeof Host.Type
25
+
26
+ export const Scope = Schema.Literal('project', 'global')
27
+ export type Scope = typeof Scope.Type
28
+
29
+ const McpServerMap = Schema.Record({ key: Schema.String, value: Schema.Unknown })
30
+
31
+ export const McpServersConfigSchema = Schema.parseJson(
32
+ Schema.Struct({ mcpServers: Schema.optionalWith(McpServerMap, { default: () => ({}) }) }, McpServerMap),
33
+ { space: 2 },
34
+ )
35
+
36
+ export const OpencodeConfigSchema = Schema.parseJson(
37
+ Schema.Struct({ mcp: Schema.optionalWith(McpServerMap, { default: () => ({}) }) }, McpServerMap),
38
+ { space: 2 },
39
+ )
40
+
41
+ export const SERVER_KEY = 'expandai'
42
+
43
+ export const readConfigText = Effect.fn(function* (file: string) {
44
+ const fs = yield* FileSystem.FileSystem
45
+ const exists = yield* fs.exists(file)
46
+ if (!exists) {
47
+ return '{}'
48
+ }
49
+ return yield* fs.readFileString(file)
50
+ })
51
+
52
+ export const writeConfigText = Effect.fn(function* (file: string, text: string) {
53
+ const fs = yield* FileSystem.FileSystem
54
+ const path = yield* Path.Path
55
+ yield* fs.makeDirectory(path.dirname(file), { recursive: true })
56
+ yield* fs.writeFileString(file, `${text}\n`)
57
+ })
58
+
59
+ /**
60
+ * Captures `FileSystem` + `Path` once and exposes string-encoded JSON read/
61
+ * write methods that don't re-yield them on every call. Use this from inside
62
+ * an `Effect.Service` gen block so the install/uninstall methods don't have
63
+ * to re-thread `provideService` calls.
64
+ */
65
+ export class ConfigIo extends Effect.Service<ConfigIo>()('@expandai/cli/Install/ConfigIo', {
66
+ dependencies: [NodeFileSystem.layer, NodePath.layer],
67
+ effect: Effect.gen(function* () {
68
+ const fs = yield* FileSystem.FileSystem
69
+ const path = yield* Path.Path
70
+
71
+ const read = Effect.fn('ConfigIo.read')(function* <A>(file: string, schema: Schema.Schema<A, string>) {
72
+ const text = yield* readConfigText(file).pipe(Effect.provideService(FileSystem.FileSystem, fs))
73
+ return yield* Schema.decode(schema)(text)
74
+ })
75
+
76
+ const write = Effect.fn('ConfigIo.write')(function* <A>(
77
+ file: string,
78
+ value: unknown,
79
+ schema: Schema.Schema<A, string>,
80
+ ) {
81
+ const encoded = yield* Schema.encodeUnknown(schema)(value)
82
+ yield* writeConfigText(file, encoded).pipe(
83
+ Effect.provideService(FileSystem.FileSystem, fs),
84
+ Effect.provideService(Path.Path, path),
85
+ )
86
+ })
87
+
88
+ const exists = Effect.fn('ConfigIo.exists')(function* (file: string) {
89
+ return yield* fs.exists(file)
90
+ })
91
+
92
+ // Raw text I/O for formats `ConfigIo.read`/`write` can't handle through a
93
+ // `Schema.parseJson(...)` schema (e.g. Codex's TOML). Missing file reads
94
+ // as an empty string — callers attach their own parser-specific default.
95
+ const readText = Effect.fn('ConfigIo.readText')(function* (file: string) {
96
+ const present = yield* fs.exists(file)
97
+ return present ? yield* fs.readFileString(file) : ''
98
+ })
99
+
100
+ const writeText = Effect.fn('ConfigIo.writeText')(function* (file: string, text: string) {
101
+ yield* writeConfigText(file, text).pipe(
102
+ Effect.provideService(FileSystem.FileSystem, fs),
103
+ Effect.provideService(Path.Path, path),
104
+ )
105
+ })
106
+
107
+ return { read, write, exists, readText, writeText } as const
108
+ }),
109
+ }) {}
110
+
111
+ /**
112
+ * Captures `FileSystem` + `Path` once and exposes skill directory copy/remove
113
+ * operations. The bundled `SKILL.md` lives at `<cli>/skills/expandai/` (or
114
+ * `<build>/skills/expandai/` in production); this service computes that path
115
+ * from its own `import.meta.url`, so per-host skill descriptors only need to
116
+ * provide `pathFor` and call `io.install(target)` / `io.uninstall(target)`.
117
+ */
118
+ export class SkillIo extends Effect.Service<SkillIo>()('@expandai/cli/Install/SkillIo', {
119
+ dependencies: [NodeFileSystem.layer, NodePath.layer],
120
+ effect: Effect.gen(function* () {
121
+ const fs = yield* FileSystem.FileSystem
122
+ const path = yield* Path.Path
123
+
124
+ const sourceDir = path.resolve(
125
+ path.dirname(yield* path.fromFileUrl(new URL(import.meta.url))),
126
+ '..',
127
+ '..',
128
+ 'skills',
129
+ 'expandai',
130
+ )
131
+
132
+ const install = Effect.fn('SkillIo.install')(function* (target: string) {
133
+ yield* fs.makeDirectory(path.dirname(target), { recursive: true })
134
+ yield* fs.copy(sourceDir, target, { overwrite: true })
135
+ return target
136
+ })
137
+
138
+ const uninstall = Effect.fn('SkillIo.uninstall')(function* (target: string) {
139
+ const exists = yield* fs.exists(target)
140
+ if (exists) {
141
+ yield* fs.remove(target, { recursive: true })
142
+ }
143
+ return target
144
+ })
145
+
146
+ return { install, uninstall } as const
147
+ }),
148
+ }) {}
@@ -0,0 +1,50 @@
1
+ import { Effect, Match, Schema } from 'effect'
2
+ import { ClaudeHook } from './Claude/Hook.js'
3
+ import { Mcp, type Scope } from './Mcp.js'
4
+ import { OpencodeHook } from './Opencode/Hook.js'
5
+
6
+ /**
7
+ * Hook install dispatcher. Bundles MCP install + per-host hook config:
8
+ *
9
+ * - claude-code: writes the matcher into `~/.claude/settings.json` (global)
10
+ * or `<cwd>/.claude/settings.json` (project), then registers expandai as
11
+ * an MCP server at the matching scope.
12
+ * - opencode: writes the webfetch plugin file into `.opencode/plugins/`
13
+ * (project) or `~/.config/opencode/plugins/` (global), plus registers
14
+ * expandai as an MCP server.
15
+ *
16
+ * Cursor has no hook system per opencode.ai/docs and Cursor's docs, so it's
17
+ * absent from this Host union (compare with `Mcp.Host` which includes Cursor).
18
+ */
19
+ export const HookHost = Schema.Literal('claude-code', 'opencode')
20
+ export type HookHost = typeof HookHost.Type
21
+
22
+ export class HookInstaller extends Effect.Service<HookInstaller>()('@expandai/cli/Install/Hook', {
23
+ dependencies: [Mcp.Default, ClaudeHook.Default, OpencodeHook.Default],
24
+ effect: Effect.gen(function* () {
25
+ const mcp = yield* Mcp
26
+ const claudeHook = yield* ClaudeHook
27
+ const opencodeHook = yield* OpencodeHook
28
+
29
+ const install = Effect.fn('HookInstaller.install')(function* (host: HookHost, scope: Scope) {
30
+ // Bundle: MCP install first, then host-specific hook config.
31
+ yield* mcp.install(host, scope)
32
+ yield* Match.value(host).pipe(
33
+ Match.when('claude-code', () => claudeHook.install(scope)),
34
+ Match.when('opencode', () => opencodeHook.install(scope)),
35
+ Match.exhaustive,
36
+ )
37
+ })
38
+
39
+ const uninstall = Effect.fn('HookInstaller.uninstall')(function* (host: HookHost, scope: Scope) {
40
+ yield* Match.value(host).pipe(
41
+ Match.when('claude-code', () => claudeHook.uninstall(scope)),
42
+ Match.when('opencode', () => opencodeHook.uninstall(scope)),
43
+ Match.exhaustive,
44
+ )
45
+ yield* mcp.uninstall(host, scope)
46
+ })
47
+
48
+ return { install, uninstall } as const
49
+ }),
50
+ }) {}
@@ -0,0 +1,71 @@
1
+ import { Effect, Match } from 'effect'
2
+ import { ClaudeMcp } from './Claude/Mcp.js'
3
+ import { CodexMcp } from './Codex/Mcp.js'
4
+ import { CursorMcp } from './Cursor/Mcp.js'
5
+ import type { Host, Scope } from './Domain.js'
6
+ import { OpencodeMcp } from './Opencode/Mcp.js'
7
+
8
+ export { Host, McpParseError, McpWriteError, Scope } from './Domain.js'
9
+
10
+ /**
11
+ * MCP install dispatcher. Per-host descriptors live in `Install/<Host>/Mcp.ts`
12
+ * and contribute their path resolution + entry shape; this service handles
13
+ * the file I/O (read existing config, merge, write back) and routes by host.
14
+ *
15
+ * - claude-code (~/.claude.json | <cwd>/.mcp.json) → mcpServers shape
16
+ * - cursor (~/.cursor/mcp.json | <cwd>/.cursor/mcp.json) → mcpServers
17
+ * - opencode (~/.config/opencode/opencode.json | <cwd>/opencode.json) → opencode `mcp` shape
18
+ * - codex (~/.codex/config.toml | <cwd>/.codex/config.toml) → Codex TOML `mcp_servers`
19
+ */
20
+
21
+ export class Mcp extends Effect.Service<Mcp>()('@expandai/cli/Install/Mcp', {
22
+ dependencies: [ClaudeMcp.Default, CursorMcp.Default, OpencodeMcp.Default, CodexMcp.Default],
23
+ effect: Effect.gen(function* () {
24
+ const claude = yield* ClaudeMcp
25
+ const cursor = yield* CursorMcp
26
+ const opencode = yield* OpencodeMcp
27
+ const codex = yield* CodexMcp
28
+
29
+ const pathFor = Effect.fn('Mcp.pathFor')(function* (host: Host, scope: Scope) {
30
+ return yield* Match.value(host).pipe(
31
+ Match.when('claude-code', () => claude.pathFor(scope)),
32
+ Match.when('cursor', () => cursor.pathFor(scope)),
33
+ Match.when('opencode', () => opencode.pathFor(scope)),
34
+ Match.when('codex', () => codex.pathFor(scope)),
35
+ Match.exhaustive,
36
+ )
37
+ })
38
+
39
+ const install = Effect.fn('Mcp.install')(function* (host: Host, scope: Scope) {
40
+ yield* Match.value(host).pipe(
41
+ Match.when('claude-code', () => claude.install(scope)),
42
+ Match.when('cursor', () => cursor.install(scope)),
43
+ Match.when('opencode', () => opencode.install(scope)),
44
+ Match.when('codex', () => codex.install(scope)),
45
+ Match.exhaustive,
46
+ )
47
+ })
48
+
49
+ const uninstall = Effect.fn('Mcp.uninstall')(function* (host: Host, scope: Scope) {
50
+ yield* Match.value(host).pipe(
51
+ Match.when('claude-code', () => claude.uninstall(scope)),
52
+ Match.when('cursor', () => cursor.uninstall(scope)),
53
+ Match.when('opencode', () => opencode.uninstall(scope)),
54
+ Match.when('codex', () => codex.uninstall(scope)),
55
+ Match.exhaustive,
56
+ )
57
+ })
58
+
59
+ const isInstalled = Effect.fn('Mcp.isInstalled')(function* (host: Host, scope: Scope) {
60
+ return yield* Match.value(host).pipe(
61
+ Match.when('claude-code', () => claude.isInstalled(scope)),
62
+ Match.when('cursor', () => cursor.isInstalled(scope)),
63
+ Match.when('opencode', () => opencode.isInstalled(scope)),
64
+ Match.when('codex', () => codex.isInstalled(scope)),
65
+ Match.exhaustive,
66
+ )
67
+ })
68
+
69
+ return { install, uninstall, isInstalled, pathFor } as const
70
+ }),
71
+ }) {}
@@ -0,0 +1,55 @@
1
+ import { homedir } from 'node:os'
2
+ import { FileSystem, Path } from '@effect/platform'
3
+ import { NodeFileSystem, NodePath } from '@effect/platform-node'
4
+ import { Config, Console, Effect, Match } from 'effect'
5
+ import type { Scope } from '../Mcp.js'
6
+
7
+ /**
8
+ * OpenCode hook = a JS plugin file at `.opencode/plugins/` (project) or
9
+ * `~/.config/opencode/plugins/` (global). The plugin shells out to
10
+ * `expandai hook opencode` so all fetch logic stays in the CLI.
11
+ *
12
+ * The plugin uses `tool.execute.after` (the only hook whose contract lets us
13
+ * replace the rendered result via `output.output`); `tool.execute.before` is
14
+ * limited to mutating tool args. See @opencode-ai/plugin's dist/index.d.ts.
15
+ */
16
+
17
+ export const PLUGIN_FILE = 'expandai-webfetch.js'
18
+
19
+ export class OpencodeHook extends Effect.Service<OpencodeHook>()('@expandai/cli/Install/Opencode/Hook', {
20
+ dependencies: [NodeFileSystem.layer, NodePath.layer],
21
+ effect: Effect.gen(function* () {
22
+ const fs = yield* FileSystem.FileSystem
23
+ const path = yield* Path.Path
24
+ const source = path.join(path.dirname(yield* path.fromFileUrl(new URL(import.meta.url))), PLUGIN_FILE)
25
+
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}/.config/opencode/plugins/${PLUGIN_FILE}`)),
32
+ Match.when('project', () => Effect.succeed(`${cwd}/.opencode/plugins/${PLUGIN_FILE}`)),
33
+ Match.exhaustive,
34
+ )
35
+ })
36
+
37
+ const install = Effect.fn('OpencodeHook.install')(function* (scope: Scope) {
38
+ const target = yield* pathFor(scope)
39
+ yield* fs.makeDirectory(path.dirname(target), { recursive: true })
40
+ yield* fs.copyFile(source, target)
41
+ yield* Console.log(`Installed expandai webfetch plugin at ${target}`)
42
+ })
43
+
44
+ const uninstall = Effect.fn('OpencodeHook.uninstall')(function* (scope: Scope) {
45
+ const target = yield* pathFor(scope)
46
+ const exists = yield* fs.exists(target)
47
+ if (exists) {
48
+ yield* fs.remove(target)
49
+ yield* Console.log(`Removed expandai webfetch plugin from ${target}`)
50
+ }
51
+ })
52
+
53
+ return { install, uninstall } as const
54
+ }),
55
+ }) {}
@@ -0,0 +1,84 @@
1
+ import { homedir } from 'node:os'
2
+ import { Config, Console, Effect, Match, Record, Schema } from 'effect'
3
+ import { ConfigIo, McpParseError, McpWriteError, OpencodeConfigSchema, SERVER_KEY } from '../Domain.js'
4
+ import type { Scope } from '../Mcp.js'
5
+
6
+ /**
7
+ * OpenCode MCP descriptor — its own shape (per opencode.ai/docs/mcp-servers):
8
+ * top-level key is `mcp` (not `mcpServers`), `command` is an array (no
9
+ * separate args), env is called `environment`, and `type` uses
10
+ * `local` / `remote` rather than `stdio` / `http`.
11
+ *
12
+ * - global → `~/.config/opencode/opencode.json`
13
+ * - project → `<cwd>/opencode.json`
14
+ */
15
+ export class Entry extends Schema.Class<Entry>('OpencodeMcpEntry')({
16
+ type: Schema.Literal('local'),
17
+ command: Schema.Array(Schema.String),
18
+ environment: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.String })),
19
+ enabled: Schema.optional(Schema.Boolean),
20
+ }) {}
21
+
22
+ export class OpencodeMcp extends Effect.Service<OpencodeMcp>()('@expandai/cli/Install/Opencode/Mcp', {
23
+ dependencies: [ConfigIo.Default],
24
+ effect: Effect.gen(function* () {
25
+ const io = yield* ConfigIo
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}/.config/opencode/opencode.json`)),
32
+ Match.when('project', () => Effect.succeed(`${cwd}/opencode.json`)),
33
+ Match.exhaustive,
34
+ )
35
+ })
36
+
37
+ const readConfig = Effect.fn(function* (file: string) {
38
+ return yield* io
39
+ .read(file, OpencodeConfigSchema)
40
+ .pipe(Effect.mapError((error) => new McpParseError({ path: file, error })))
41
+ })
42
+
43
+ const writeConfig = Effect.fn(function* (file: string, value: unknown) {
44
+ yield* io
45
+ .write(file, value, OpencodeConfigSchema)
46
+ .pipe(Effect.mapError((error) => new McpWriteError({ path: file, error })))
47
+ })
48
+
49
+ const install = Effect.fn(function* (scope: Scope) {
50
+ const file = yield* pathFor(scope)
51
+ const config = yield* readConfig(file)
52
+ yield* writeConfig(file, {
53
+ ...config,
54
+ mcp: {
55
+ ...config.mcp,
56
+ [SERVER_KEY]: new Entry({
57
+ type: 'local',
58
+ command: ['expandai', 'mcp', 'run'],
59
+ enabled: true,
60
+ }),
61
+ },
62
+ })
63
+ yield* Console.log(`Installed expandai MCP server into ${file}`)
64
+ })
65
+
66
+ const uninstall = Effect.fn(function* (scope: Scope) {
67
+ const file = yield* pathFor(scope)
68
+ const config = yield* readConfig(file)
69
+ if (!(SERVER_KEY in config.mcp)) {
70
+ return
71
+ }
72
+ yield* writeConfig(file, { ...config, mcp: Record.remove(config.mcp, SERVER_KEY) })
73
+ yield* Console.log(`Removed expandai MCP server from ${file}`)
74
+ })
75
+
76
+ const isInstalled = Effect.fn(function* (scope: Scope) {
77
+ const file = yield* pathFor(scope)
78
+ const config = yield* readConfig(file)
79
+ return SERVER_KEY in config.mcp
80
+ })
81
+
82
+ return { install, uninstall, isInstalled, pathFor } as const
83
+ }),
84
+ }) {}
@@ -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
+ * OpenCode skills are Claude-Code-compatible Markdown files. Per
8
+ * opencode.ai/docs/skills, OpenCode auto-discovers them under either
9
+ * `.opencode/skills/<name>/`, `.claude/skills/<name>/`, or
10
+ * `.agents/skills/<name>/` (and the `~/.config/opencode/...` equivalents
11
+ * for global). We ship to OpenCode-native paths.
12
+ *
13
+ * - global → `~/.config/opencode/skills/expandai/SKILL.md`
14
+ * - project → `<cwd>/.opencode/skills/expandai/SKILL.md`
15
+ */
16
+
17
+ export const SKILL_NAME = 'expandai'
18
+
19
+ export class OpencodeSkill extends Effect.Service<OpencodeSkill>()('@expandai/cli/Install/Opencode/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}/.config/opencode/skills/${SKILL_NAME}`)),
29
+ Match.when('project', () => Effect.succeed(`${cwd}/.opencode/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,23 @@
1
+ export interface OpencodeWebfetchPluginInput {
2
+ readonly tool: string
3
+ readonly sessionID?: string
4
+ readonly callID?: string
5
+ readonly args?: {
6
+ readonly url?: string
7
+ }
8
+ }
9
+
10
+ export interface OpencodeWebfetchPluginOutput {
11
+ title?: string
12
+ output?: string
13
+ metadata?: unknown
14
+ }
15
+
16
+ export interface OpencodeWebfetchPluginHooks {
17
+ readonly 'tool.execute.after': (
18
+ input: OpencodeWebfetchPluginInput,
19
+ output: OpencodeWebfetchPluginOutput,
20
+ ) => Promise<void>
21
+ }
22
+
23
+ export declare const ExpandaiWebfetch: () => Promise<OpencodeWebfetchPluginHooks>
@@ -0,0 +1,38 @@
1
+ // expandai OpenCode plugin - routes webfetch through the expandai CLI.
2
+ // Generated by `expandai hook install opencode <scope>`.
3
+ //
4
+ // We hook `tool.execute.after` rather than `.before` because OpenCode's plugin
5
+ // contract only lets `before` mutate args (`output.args`); replacing the
6
+ // rendered result is only possible from `after`, where `output` is the tool
7
+ // output object (`{ title, output, metadata }`). See @opencode-ai/plugin's
8
+ // dist/index.d.ts.
9
+ import { spawn } from 'node:child_process'
10
+
11
+ export const ExpandaiWebfetch = async () => ({
12
+ 'tool.execute.after': async (input, output) => {
13
+ if (input.tool !== 'webfetch') return
14
+ const url = input.args?.url
15
+ if (typeof url !== 'string' || url.length === 0) return
16
+
17
+ 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))
27
+ if (exitCode !== 0 || stdout.trim().length === 0) return
28
+
29
+ const { markdown } = JSON.parse(stdout)
30
+ if (typeof markdown !== 'string' || markdown.length === 0) return
31
+
32
+ output.output = markdown
33
+ output.title = url
34
+ } catch (error) {
35
+ console.error(`expandai-webfetch plugin failed: ${error?.message ?? error}`)
36
+ }
37
+ },
38
+ })
@@ -0,0 +1,57 @@
1
+ import { Console, Effect, Match } from 'effect'
2
+ import { ClaudeSkill } from './Claude/Skill.js'
3
+ import { CodexSkill } from './Codex/Skill.js'
4
+ import { CursorSkill } from './Cursor/Skill.js'
5
+ import type { Host, Scope } from './Mcp.js'
6
+ import { OpencodeSkill } from './Opencode/Skill.js'
7
+
8
+ /**
9
+ * Skill install dispatcher. Copies the bundled `SKILL.md` from the CLI's
10
+ * own `skills/expandai/` source into the host-specific destination.
11
+ *
12
+ * - claude-code: `~/.claude/skills/expandai/` (global) or
13
+ * `<cwd>/.claude/skills/expandai/` (project)
14
+ * - cursor: `~/.cursor/skills/expandai/` (global) or
15
+ * `<cwd>/.cursor/skills/expandai/` (project)
16
+ * - opencode: `~/.config/opencode/skills/expandai/` (global) or
17
+ * `<cwd>/.opencode/skills/expandai/` (project)
18
+ * - codex: `~/.agents/skills/expandai/` (global) or
19
+ * `<cwd>/.agents/skills/expandai/` (project)
20
+ *
21
+ * All hosts use the same `SKILL.md` + YAML frontmatter open standard,
22
+ * so the bundled skill directory copies in unchanged.
23
+ */
24
+
25
+ export class Skill extends Effect.Service<Skill>()('@expandai/cli/Install/Skill', {
26
+ dependencies: [ClaudeSkill.Default, CursorSkill.Default, OpencodeSkill.Default, CodexSkill.Default],
27
+ effect: Effect.gen(function* () {
28
+ const claude = yield* ClaudeSkill
29
+ const cursor = yield* CursorSkill
30
+ const opencode = yield* OpencodeSkill
31
+ const codex = yield* CodexSkill
32
+
33
+ const install = Effect.fn('Skill.install')(function* (host: Host, scope: Scope) {
34
+ const target = yield* Match.value(host).pipe(
35
+ Match.when('claude-code', () => claude.install(scope)),
36
+ Match.when('cursor', () => cursor.install(scope)),
37
+ Match.when('opencode', () => opencode.install(scope)),
38
+ Match.when('codex', () => codex.install(scope)),
39
+ Match.exhaustive,
40
+ )
41
+ yield* Console.log(`Installed expandai skill into ${target}`)
42
+ })
43
+
44
+ const uninstall = Effect.fn('Skill.uninstall')(function* (host: Host, scope: Scope) {
45
+ const target = yield* Match.value(host).pipe(
46
+ Match.when('claude-code', () => claude.uninstall(scope)),
47
+ Match.when('cursor', () => cursor.uninstall(scope)),
48
+ Match.when('opencode', () => opencode.uninstall(scope)),
49
+ Match.when('codex', () => codex.uninstall(scope)),
50
+ Match.exhaustive,
51
+ )
52
+ yield* Console.log(`Removed expandai skill from ${target}`)
53
+ })
54
+
55
+ return { install, uninstall } as const
56
+ }),
57
+ }) {}
@@ -0,0 +1,82 @@
1
+ import { Array, Effect, Match, Schema } from 'effect'
2
+ import { type HookHost, HookInstaller } from './Hook.js'
3
+ import { Host, Mcp, Scope } from './Mcp.js'
4
+ import { Skill } from './Skill.js'
5
+
6
+ export const UninstallTarget = Schema.Literal('all', 'hook', 'skill', 'mcp')
7
+
8
+ export class UninstallOptions extends Schema.Class<UninstallOptions>('UninstallOptions')({
9
+ target: Schema.optional(UninstallTarget),
10
+ host: Schema.optional(Host),
11
+ scope: Schema.optional(Scope),
12
+ }) {}
13
+
14
+ const SCOPES: (typeof Scope.Type)[] = ['global', 'project']
15
+ const HOSTS: (typeof Host.Type)[] = ['claude-code', 'cursor', 'opencode', 'codex']
16
+
17
+ const isHookHost = (host: typeof Host.Type): host is HookHost => host === 'claude-code' || host === 'opencode'
18
+
19
+ export class Uninstall extends Effect.Service<Uninstall>()('@expandai/cli/Install/Uninstall', {
20
+ dependencies: [HookInstaller.Default, Mcp.Default, Skill.Default],
21
+ effect: Effect.gen(function* () {
22
+ const hook = yield* HookInstaller
23
+ const mcp = yield* Mcp
24
+ const skill = yield* Skill
25
+
26
+ const uninstall = Effect.fn(function* (options: UninstallOptions = new UninstallOptions({})) {
27
+ const target = options.target ?? 'all'
28
+ const hosts = options.host ? [options.host] : HOSTS
29
+ const scopes = options.scope ? [options.scope] : SCOPES
30
+
31
+ // Sequential by default: a future change that merges any two `(host,
32
+ // scope)` config paths into the same file shouldn't silently race.
33
+ // Logout/uninstall happens once per session; throughput isn't the
34
+ // bottleneck.
35
+ const uninstallHooks = Effect.fnUntraced(function* () {
36
+ const hookHosts = Array.filter(hosts, isHookHost)
37
+ yield* Effect.forEach(
38
+ hookHosts,
39
+ (host) => Effect.forEach(scopes, (scope) => hook.uninstall(host, scope), { discard: true }),
40
+ { discard: true },
41
+ )
42
+ })
43
+
44
+ const uninstallSkills = Effect.fnUntraced(function* () {
45
+ yield* Effect.forEach(
46
+ hosts,
47
+ (host) => Effect.forEach(scopes, (scope) => skill.uninstall(host, scope), { discard: true }),
48
+ { discard: true },
49
+ )
50
+ })
51
+
52
+ const uninstallMcp = Effect.fnUntraced(function* () {
53
+ yield* Effect.forEach(
54
+ hosts,
55
+ (host) => Effect.forEach(scopes, (scope) => mcp.uninstall(host, scope), { discard: true }),
56
+ { discard: true },
57
+ )
58
+ })
59
+
60
+ const uninstallMcpWithoutHooks = Effect.fnUntraced(function* () {
61
+ const mcpOnlyHosts = Array.filter(hosts, (host) => !isHookHost(host))
62
+ yield* Effect.forEach(
63
+ mcpOnlyHosts,
64
+ (host) => Effect.forEach(scopes, (scope) => mcp.uninstall(host, scope), { discard: true }),
65
+ { discard: true },
66
+ )
67
+ })
68
+
69
+ yield* Match.value(target).pipe(
70
+ Match.when('all', () =>
71
+ uninstallHooks().pipe(Effect.zipRight(uninstallMcpWithoutHooks()), Effect.zipRight(uninstallSkills())),
72
+ ),
73
+ Match.when('hook', () => uninstallHooks()),
74
+ Match.when('skill', () => uninstallSkills()),
75
+ Match.when('mcp', () => uninstallMcp()),
76
+ Match.exhaustive,
77
+ )
78
+ })
79
+
80
+ return { uninstall } as const
81
+ }),
82
+ }) {}