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,92 @@
1
+ import { Args, Command, Options } from '@effect/cli'
2
+ import { Console, Effect, Layer, Match, Schema } from 'effect'
3
+ import { ApiClient } from '../ApiClient.js'
4
+ import { ConfigManager } from '../Config.js'
5
+ import { renderSnippets } from '../Snippets.js'
6
+
7
+ export class SnapshotSnippetsUnavailable extends Schema.TaggedError<SnapshotSnippetsUnavailable>()(
8
+ 'SnapshotSnippetsUnavailable',
9
+ {
10
+ snapshotId: Schema.String,
11
+ query: Schema.String,
12
+ },
13
+ ) {
14
+ get message() {
15
+ return `No snippets in snapshot ${this.snapshotId} matched '${this.query}'`
16
+ }
17
+ }
18
+
19
+ const snapshotId = Args.text({ name: 'snapshotId' }).pipe(
20
+ Args.withDescription('Snapshot handle from a previous fetch (meta.snapshotId, or the WebFetch hook note)'),
21
+ )
22
+
23
+ const query = Args.text({ name: 'query' }).pipe(Args.withDescription('Semantic search query'))
24
+
25
+ const format = Options.text('format').pipe(
26
+ Options.withDefault('text'),
27
+ Options.withDescription('Output format (text or json)'),
28
+ Options.withSchema(Schema.Literal('text', 'json')),
29
+ )
30
+
31
+ const maxResults = Options.integer('max-results').pipe(
32
+ Options.withDefault(5),
33
+ Options.withDescription('Maximum snippets to return (default: 5)'),
34
+ Options.withSchema(Schema.Int.pipe(Schema.greaterThanOrEqualTo(1), Schema.lessThanOrEqualTo(50))),
35
+ )
36
+
37
+ const minScore = Options.float('min-score').pipe(
38
+ Options.withDefault(0.6),
39
+ Options.withDescription('Minimum snippet relevance score from 0 to 1 (default: 0.6)'),
40
+ Options.withSchema(Schema.Number.pipe(Schema.greaterThanOrEqualTo(0), Schema.lessThanOrEqualTo(1))),
41
+ )
42
+
43
+ const searchConfig = {
44
+ snapshotId,
45
+ query,
46
+ format,
47
+ maxResults,
48
+ minScore,
49
+ }
50
+
51
+ interface SearchCommandConfig {
52
+ readonly snapshotId: string
53
+ readonly query: string
54
+ readonly format: 'text' | 'json'
55
+ readonly maxResults: number
56
+ readonly minScore: number
57
+ }
58
+
59
+ export const SearchCommand: Command.Command<'search', never, unknown, SearchCommandConfig> = Command.make(
60
+ 'search',
61
+ searchConfig,
62
+ ).pipe(
63
+ Command.withDescription('Search a previously fetched page by its snapshot handle — no new capture'),
64
+ Command.withHandler(
65
+ Effect.fnUntraced(
66
+ function* ({ format, maxResults, minScore, query, snapshotId }) {
67
+ const client = yield* ApiClient
68
+ const configManager = yield* ConfigManager
69
+ const host = yield* configManager.getPlaygroundHost()
70
+ const result = yield* client.fetchFetchSearch({
71
+ snapshotId,
72
+ search: { query, maxResults, minScore },
73
+ include: { markdown: true, json: true },
74
+ })
75
+
76
+ const output = yield* Match.value(format).pipe(
77
+ Match.when('text', () => {
78
+ const snippets = result.search.snippets
79
+ return snippets.length === 0
80
+ ? Effect.fail(new SnapshotSnippetsUnavailable({ snapshotId, query }))
81
+ : Effect.succeed(renderSnippets(snapshotId, host, snippets))
82
+ }),
83
+ Match.when('json', () => Schema.encode(Schema.parseJson(Schema.Unknown, { space: 2 }))(result)),
84
+ Match.exhaustive,
85
+ )
86
+
87
+ yield* Console.log(output)
88
+ },
89
+ Effect.provide(Layer.mergeAll(ApiClient.Default, ConfigManager.Default)),
90
+ ),
91
+ ),
92
+ )
@@ -0,0 +1,30 @@
1
+ import { Args, Command } from '@effect/cli'
2
+ import { Effect } from 'effect'
3
+ import { Skill } from '../Install/Skill.js'
4
+
5
+ const hostArg = Args.choice([
6
+ ['claude-code', 'claude-code' as const],
7
+ ['cursor', 'cursor' as const],
8
+ ['opencode', 'opencode' as const],
9
+ ['codex', 'codex' as const],
10
+ ])
11
+
12
+ const scopeArg = Args.choice([
13
+ ['global', 'global' as const],
14
+ ['project', 'project' as const],
15
+ ])
16
+
17
+ const InstallCommand = Command.make('install', { host: hostArg, scope: scopeArg }).pipe(
18
+ Command.withDescription('Install the expandai skill into the chosen host + scope'),
19
+ Command.withHandler(({ host, scope }) => Effect.flatMap(Skill, (skill) => skill.install(host, scope))),
20
+ )
21
+
22
+ const UninstallCommand = Command.make('uninstall', { host: hostArg, scope: scopeArg }).pipe(
23
+ Command.withDescription('Remove the expandai skill from the chosen host + scope'),
24
+ Command.withHandler(({ host, scope }) => Effect.flatMap(Skill, (skill) => skill.uninstall(host, scope))),
25
+ )
26
+
27
+ export const SkillCommand = Command.make('skill').pipe(
28
+ Command.withDescription('Manage the expandai agent skill'),
29
+ Command.withSubcommands([InstallCommand, UninstallCommand]),
30
+ )
@@ -0,0 +1,38 @@
1
+ import { Args, Command } from '@effect/cli'
2
+ import { Effect, Option } from 'effect'
3
+ import { Uninstall } from '../Install/Uninstall.js'
4
+
5
+ const targetArg = Args.choice([
6
+ ['all', 'all' as const],
7
+ ['hook', 'hook' as const],
8
+ ['skill', 'skill' as const],
9
+ ['mcp', 'mcp' as const],
10
+ ]).pipe(Args.optional)
11
+
12
+ const hostArg = Args.choice([
13
+ ['claude-code', 'claude-code' as const],
14
+ ['cursor', 'cursor' as const],
15
+ ['opencode', 'opencode' as const],
16
+ ['codex', 'codex' as const],
17
+ ]).pipe(Args.optional)
18
+
19
+ const scopeArg = Args.choice([
20
+ ['global', 'global' as const],
21
+ ['project', 'project' as const],
22
+ ]).pipe(Args.optional)
23
+
24
+ export const UninstallCommand = Command.make('uninstall', { target: targetArg, host: hostArg, scope: scopeArg }).pipe(
25
+ Command.withDescription(
26
+ 'Remove expandai hooks, skills, and MCP server entries. Defaults to `all` across every host and scope when target/host/scope are omitted.',
27
+ ),
28
+ Command.withHandler(
29
+ Effect.fnUntraced(function* ({ target, host, scope }) {
30
+ const uninstall = yield* Uninstall
31
+ yield* uninstall.uninstall({
32
+ target: Option.getOrUndefined(target),
33
+ host: Option.getOrUndefined(host),
34
+ scope: Option.getOrUndefined(scope),
35
+ })
36
+ }),
37
+ ),
38
+ )
package/src/Config.ts ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * CLI configuration. Persisted at `$XDG_CONFIG_HOME/expandai/config.json`
3
+ * (default `~/.config/expandai/config.json`). The `EXPAND_API_KEY` and
4
+ * `EXPAND_API_URL` environment variables override the file.
5
+ */
6
+ import { homedir } from 'node:os'
7
+ import { FileSystem, Path } from '@effect/platform'
8
+ import { NodeFileSystem, NodePath } from '@effect/platform-node'
9
+ import { DEFAULT_PLAYGROUND_HOST } from '@expandai/sdk/Playground'
10
+ import { Config, DateTime, Effect, Option, Schema } from 'effect'
11
+
12
+ /** Production API endpoint used when neither `EXPAND_API_URL` nor the config file overrides it. */
13
+ export const DEFAULT_API_URL = 'https://api.expand.ai'
14
+
15
+ export const ApiKey = Schema.Redacted(Schema.NonEmptyTrimmedString.pipe(Schema.brand('ApiKey')))
16
+
17
+ export const ApiKeyConfig = Schema.TaggedStruct('ApiKey', { apiKey: ApiKey })
18
+ export const OAuthConfig = Schema.TaggedStruct('OAuth', {
19
+ accessToken: Schema.Redacted(Schema.String),
20
+ refreshToken: Schema.optional(Schema.Redacted(Schema.String)),
21
+ expiresAt: Schema.DateTimeUtc,
22
+ })
23
+
24
+ export class ExpandCliConfig extends Schema.Class<ExpandCliConfig>('ExpandCliConfig')({
25
+ apiUrl: Schema.optional(Schema.URL),
26
+ authMethod: Schema.optional(Schema.Union(ApiKeyConfig, OAuthConfig)),
27
+ }) {
28
+ static readonly Json = Schema.parseJson(ExpandCliConfig)
29
+ }
30
+
31
+ export class ConfigNotWritable extends Schema.TaggedError<ConfigNotWritable>()('ConfigNotWritable', {
32
+ path: Schema.String,
33
+ }) {
34
+ get message() {
35
+ return `Cannot write to ${this.path}. Check filesystem permissions on the config directory.`
36
+ }
37
+ }
38
+
39
+ export class ConfigParseError extends Schema.TaggedError<ConfigParseError>()('ConfigParseError', {
40
+ path: Schema.String,
41
+ error: Schema.Defect,
42
+ }) {
43
+ get message() {
44
+ return `Could not parse ${this.path}. Delete the file and run \`expandai login\` to recreate it.`
45
+ }
46
+ }
47
+
48
+ export class NotAuthenticated extends Schema.TaggedError<NotAuthenticated>()('NotAuthenticated', {}) {
49
+ get message() {
50
+ return 'Not authenticated. Run `expandai login` to authenticate.'
51
+ }
52
+ }
53
+
54
+ export class OAuthTokenExpired extends Schema.TaggedError<OAuthTokenExpired>()('OAuthTokenExpired', {
55
+ expiredAt: Schema.DateTimeUtc,
56
+ }) {
57
+ get message() {
58
+ return `Your OAuth token expired at ${DateTime.formatIso(this.expiredAt)}. Run \`expandai login\` to re-authenticate.`
59
+ }
60
+ }
61
+
62
+ const Environment = Effect.gen(function* () {
63
+ const apiKey = yield* Schema.Config('EXPAND_API_KEY', ApiKey).pipe(Config.option)
64
+ const apiUrl = yield* Config.url('EXPAND_API_URL').pipe(Config.option)
65
+
66
+ return ExpandCliConfig.make({
67
+ apiUrl: Option.getOrUndefined(apiUrl),
68
+ authMethod: Option.getOrUndefined(Option.map(apiKey, (apiKey) => ({ _tag: 'ApiKey' as const, apiKey }))),
69
+ })
70
+ })
71
+
72
+ export class ConfigManager extends Effect.Service<ConfigManager>()('@expandai/cli/Config/ConfigManager', {
73
+ dependencies: [NodeFileSystem.layer, NodePath.layer],
74
+ effect: Effect.gen(function* () {
75
+ const fs = yield* FileSystem.FileSystem
76
+ const path = yield* Path.Path
77
+
78
+ const configPath = yield* Config.string('XDG_CONFIG_HOME').pipe(
79
+ Config.withDefault(path.join(homedir(), '.config')),
80
+ Config.map((dir) => path.join(dir, 'expandai', 'config.json')),
81
+ )
82
+
83
+ const EnvironmentFromConfigFile = Effect.gen(function* () {
84
+ const exists = yield* fs.exists(configPath)
85
+ if (!exists) {
86
+ return ExpandCliConfig.make({})
87
+ }
88
+
89
+ const string = yield* fs.readFileString(configPath)
90
+ return yield* Schema.decode(ExpandCliConfig.Json)(string).pipe(
91
+ Effect.mapError((error) => new ConfigParseError({ path: configPath, error })),
92
+ )
93
+ })
94
+
95
+ const loadConfig = Effect.fn('loadConfig')(function* () {
96
+ const env = yield* Environment
97
+ const file = yield* EnvironmentFromConfigFile
98
+
99
+ const apiUrl = {
100
+ env: Option.fromNullable(env.apiUrl),
101
+ file: Option.fromNullable(file.apiUrl),
102
+ }
103
+ const authMethod = {
104
+ env: Option.fromNullable(env.authMethod),
105
+ file: Option.fromNullable(file.authMethod),
106
+ }
107
+
108
+ yield* Effect.logWarning(`EXPAND_API_URL overrides apiUrl from ${configPath}`).pipe(
109
+ Effect.when(() =>
110
+ Option.zipWith(apiUrl.env, apiUrl.file, (env, file) => env.toString() !== file.toString()).pipe(
111
+ Option.getOrElse(() => false),
112
+ ),
113
+ ),
114
+ )
115
+ yield* Effect.logWarning(`EXPAND_API_KEY overrides the auth method stored in ${configPath}`).pipe(
116
+ Effect.when(() => Option.isSome(authMethod.env) && Option.isSome(authMethod.file)),
117
+ )
118
+
119
+ return yield* Option.match(
120
+ Option.orElse(authMethod.env, () => authMethod.file),
121
+ {
122
+ onNone: () => Effect.fail(new NotAuthenticated()),
123
+ onSome: (authMethod) =>
124
+ Effect.succeed({
125
+ apiUrl: Option.firstSomeOf([apiUrl.env, apiUrl.file]).pipe(
126
+ Option.getOrElse(() => new URL(DEFAULT_API_URL)),
127
+ ),
128
+ authMethod,
129
+ }),
130
+ },
131
+ )
132
+ })
133
+
134
+ const saveConfig = Effect.fn('saveConfig')(function* (config: ExpandCliConfig) {
135
+ yield* fs
136
+ .makeDirectory(path.dirname(configPath), { recursive: true })
137
+ .pipe(Effect.mapError(() => new ConfigNotWritable({ path: configPath })))
138
+ const encoded = yield* Schema.encode(ExpandCliConfig.Json)(config)
139
+ yield* fs
140
+ .writeFileString(configPath, encoded, { mode: 0o600 })
141
+ .pipe(Effect.mapError(() => new ConfigNotWritable({ path: configPath })))
142
+ // `mode` only applies on creation; chmod handles a pre-existing file.
143
+ // Failures are non-fatal (e.g. chmod is a no-op on Windows, or the file
144
+ // is owned by another user) but surfaced at debug level so they're
145
+ // inspectable when DEBUG=true.
146
+ yield* fs.chmod(configPath, 0o600).pipe(
147
+ Effect.tapErrorCause((cause) =>
148
+ Effect.logDebug(`chmod 0o600 ${configPath} failed (file mode may not enforce 0600)`).pipe(
149
+ Effect.annotateLogs({ cause }),
150
+ ),
151
+ ),
152
+ Effect.ignore,
153
+ )
154
+ })
155
+
156
+ const updateConfig = Effect.fn('updateConfig')(function* (patch: Partial<ExpandCliConfig>) {
157
+ const existing = yield* EnvironmentFromConfigFile.pipe(Effect.orElseSucceed(() => ExpandCliConfig.make({})))
158
+ yield* saveConfig(ExpandCliConfig.make({ ...existing, ...patch }))
159
+ })
160
+
161
+ const clearConfig = Effect.fn('clearConfig')(function* () {
162
+ yield* saveConfig(ExpandCliConfig.make({}))
163
+ })
164
+
165
+ const getApiUrl = Effect.fn('getApiUrl')(function* () {
166
+ const env = yield* Environment
167
+ const file = yield* EnvironmentFromConfigFile
168
+
169
+ const apiUrl = {
170
+ env: Option.fromNullable(env.apiUrl),
171
+ file: Option.fromNullable(file.apiUrl),
172
+ }
173
+
174
+ return Option.firstSomeOf([apiUrl.env, apiUrl.file]).pipe(Option.getOrElse(() => new URL(DEFAULT_API_URL)))
175
+ })
176
+
177
+ // Public playground host for citation links. Reads `EXPAND_PLAYGROUND_HOST` — the same env
178
+ // var the API / cdp-server bake into `meta.playground` — so local/staging set one host for
179
+ // both server and CLI. The fetch path prefers the server-resolved `meta.playground` origin;
180
+ // only the snapshot-search path (no `meta.playground`) falls back to this.
181
+ const getPlaygroundHost = Effect.fn('getPlaygroundHost')(function* () {
182
+ return yield* Config.string('EXPAND_PLAYGROUND_HOST').pipe(Config.withDefault(DEFAULT_PLAYGROUND_HOST))
183
+ })
184
+
185
+ return {
186
+ configPath,
187
+ loadConfig,
188
+ saveConfig,
189
+ updateConfig,
190
+ clearConfig,
191
+ getApiUrl,
192
+ getPlaygroundHost,
193
+ } as const
194
+ }),
195
+ }) {}
@@ -0,0 +1,193 @@
1
+ import { HttpClient, HttpClientRequest, HttpClientResponse } from '@effect/platform'
2
+ import { NodeHttpClient } from '@effect/platform-node'
3
+ import { DateTime, Duration, Effect, Inspectable, Match, Option, Redacted, Ref, Schema, String } from 'effect'
4
+ import { ConfigManager, OAuthConfig } from './Config.js'
5
+
6
+ const FormattedUserCode = Schema.transform(Schema.String, Schema.String, {
7
+ strict: true,
8
+ decode: (raw) => {
9
+ const upper = String.toUpperCase(raw)
10
+ return upper.length > 4 ? `${String.takeLeft(upper, 4)}-${String.takeRight(upper, upper.length - 4)}` : upper
11
+ },
12
+ encode: (formatted) => formatted.replace('-', ''),
13
+ })
14
+
15
+ const DurationFromSeconds = Schema.transform(Schema.Number, Schema.DurationFromSelf, {
16
+ strict: true,
17
+ decode: Duration.seconds,
18
+ encode: Duration.toSeconds,
19
+ })
20
+
21
+ export class DeviceCodeResponse extends Schema.Class<DeviceCodeResponse>('DeviceCodeResponse')({
22
+ device_code: Schema.String,
23
+ user_code: FormattedUserCode,
24
+ verification_uri: Schema.String,
25
+ verification_uri_complete: Schema.String,
26
+ expires_in: DurationFromSeconds,
27
+ interval: DurationFromSeconds,
28
+ }) {}
29
+
30
+ export class TokenResponse extends Schema.Class<TokenResponse>('TokenResponse')({
31
+ access_token: Schema.String,
32
+ expires_in: DurationFromSeconds,
33
+ token_type: Schema.String,
34
+ scope: Schema.optional(Schema.String),
35
+ }) {}
36
+
37
+ export class DeviceAuthRequestFailed extends Schema.TaggedError<DeviceAuthRequestFailed>()('DeviceAuthRequestFailed', {
38
+ endpoint: Schema.String,
39
+ error: Schema.Defect,
40
+ status: Schema.optional(Schema.Number),
41
+ }) {
42
+ get message() {
43
+ return `Device auth request to ${this.endpoint} failed${this.status ? ` (${this.status})` : ''}: ${Inspectable.format(this.error)}`
44
+ }
45
+ }
46
+
47
+ export class DeviceAuthorizationDenied extends Schema.TaggedError<DeviceAuthorizationDenied>()(
48
+ 'DeviceAuthorizationDenied',
49
+ {},
50
+ ) {
51
+ get message() {
52
+ return 'Authorization was denied by the user'
53
+ }
54
+ }
55
+
56
+ export class DeviceAuthorizationExpired extends Schema.TaggedError<DeviceAuthorizationExpired>()(
57
+ 'DeviceAuthorizationExpired',
58
+ {},
59
+ ) {
60
+ get message() {
61
+ return 'Device authorization code expired'
62
+ }
63
+ }
64
+
65
+ const CLIENT_ID = 'expandai-cli'
66
+
67
+ export class DeviceAuthService extends Effect.Service<DeviceAuthService>()('@expandai/cli/DeviceAuthService', {
68
+ dependencies: [NodeHttpClient.layer, ConfigManager.Default],
69
+ effect: Effect.gen(function* () {
70
+ const httpClient = yield* HttpClient.HttpClient
71
+ const configManager = yield* ConfigManager
72
+ const apiUrl = yield* configManager.getApiUrl()
73
+
74
+ const requestDeviceCode = Effect.fn('DeviceAuthService.requestDeviceCode')(function* () {
75
+ const DEVICE_AUTH_CODE_ENDPOINT = '/api/auth/device/code'
76
+
77
+ const request = yield* HttpClientRequest.post(new URL(DEVICE_AUTH_CODE_ENDPOINT, apiUrl)).pipe(
78
+ HttpClientRequest.bodyJson({ client_id: CLIENT_ID }),
79
+ )
80
+
81
+ return yield* httpClient.execute(request).pipe(
82
+ Effect.flatMap(HttpClientResponse.schemaBodyJson(DeviceCodeResponse)),
83
+ Effect.mapError(
84
+ (error) =>
85
+ new DeviceAuthRequestFailed({
86
+ endpoint: DEVICE_AUTH_CODE_ENDPOINT,
87
+ error,
88
+ }),
89
+ ),
90
+ )
91
+ })
92
+
93
+ const pollForToken = Effect.fn('DeviceAuthService.pollForToken')(function* (
94
+ deviceCode: string,
95
+ interval: Duration.Duration,
96
+ expiresIn: Duration.Duration,
97
+ ) {
98
+ const DEVICE_AUTH_TOKEN_ENDPOINT = '/api/auth/device/token'
99
+ // RFC 8628 §3.5: on `slow_down`, the polling interval MUST be increased by 5
100
+ // seconds for this and all subsequent requests.
101
+ const SLOW_DOWN_BUMP = Duration.seconds(5)
102
+ const intervalRef = yield* Ref.make(interval)
103
+
104
+ const PollErrorBody = Schema.Struct({
105
+ error: Schema.String,
106
+ error_description: Schema.optional(Schema.String),
107
+ })
108
+
109
+ // better-auth's `deviceAuthorization` plugin accepts JSON; RFC 8628 §3.4
110
+ // would normally require `application/x-www-form-urlencoded`. If we ever
111
+ // target a non-better-auth server, switch to `HttpClientRequest.bodyUrlParams`.
112
+ const singlePoll = HttpClientRequest.post(new URL(DEVICE_AUTH_TOKEN_ENDPOINT, apiUrl)).pipe(
113
+ HttpClientRequest.bodyJson({
114
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
115
+ device_code: deviceCode,
116
+ client_id: CLIENT_ID,
117
+ }),
118
+ Effect.flatMap(httpClient.execute),
119
+ Effect.flatMap(
120
+ HttpClientResponse.matchStatus({
121
+ '200': (response) =>
122
+ HttpClientResponse.schemaBodyJson(TokenResponse)(response).pipe(Effect.map(Option.some)),
123
+ orElse: (response) =>
124
+ HttpClientResponse.schemaBodyJson(PollErrorBody)(response).pipe(
125
+ Effect.flatMap(({ error, error_description }) =>
126
+ Match.value(error).pipe(
127
+ Match.when('authorization_pending', () =>
128
+ Ref.get(intervalRef).pipe(Effect.flatMap(Effect.sleep), Effect.as(Option.none<TokenResponse>())),
129
+ ),
130
+ Match.when('slow_down', () =>
131
+ Ref.updateAndGet(intervalRef, (current) => Duration.sum(current, SLOW_DOWN_BUMP)).pipe(
132
+ Effect.flatMap(Effect.sleep),
133
+ Effect.as(Option.none<TokenResponse>()),
134
+ ),
135
+ ),
136
+ Match.when('access_denied', () => Effect.fail(new DeviceAuthorizationDenied())),
137
+ Match.when('expired_token', () => Effect.fail(new DeviceAuthorizationExpired())),
138
+ Match.orElse(() =>
139
+ Effect.fail(
140
+ new DeviceAuthRequestFailed({
141
+ endpoint: DEVICE_AUTH_TOKEN_ENDPOINT,
142
+ status: response.status,
143
+ error: error_description ?? error,
144
+ }),
145
+ ),
146
+ ),
147
+ ),
148
+ ),
149
+ ),
150
+ }),
151
+ ),
152
+ Effect.catchTags({
153
+ RequestError: (error) => new DeviceAuthRequestFailed({ endpoint: DEVICE_AUTH_TOKEN_ENDPOINT, error }),
154
+ ResponseError: (error) => new DeviceAuthRequestFailed({ endpoint: DEVICE_AUTH_TOKEN_ENDPOINT, error }),
155
+ HttpBodyError: (error) => new DeviceAuthRequestFailed({ endpoint: DEVICE_AUTH_TOKEN_ENDPOINT, error }),
156
+ ParseError: (error) => new DeviceAuthRequestFailed({ endpoint: DEVICE_AUTH_TOKEN_ENDPOINT, error }),
157
+ }),
158
+ )
159
+
160
+ const result = yield* Effect.repeat(singlePoll, { until: Option.isSome }).pipe(
161
+ Effect.timeoutFail({
162
+ duration: expiresIn,
163
+ onTimeout: () => new DeviceAuthorizationExpired(),
164
+ }),
165
+ )
166
+
167
+ return yield* Option.match(result, {
168
+ onNone: () => new DeviceAuthorizationExpired(),
169
+ onSome: Effect.succeed,
170
+ })
171
+ })
172
+
173
+ // INFO: better-auth's `deviceAuthorization` plugin issues access tokens
174
+ // with no refresh flow — `/api/auth/device/token` only accepts
175
+ // `grant_type=urn:ietf:params:oauth:grant-type:device_code`. Refresh is
176
+ // not part of the auth surface we're using, so we don't persist the
177
+ // refresh_token at all; ApiClient pre-empts the post-expiry 401 with
178
+ // `OAuthTokenExpired` and asks the user to re-login.
179
+ const saveToken = Effect.fn('DeviceAuthService.saveToken')(function* (tokenResponse: TokenResponse) {
180
+ const now = yield* DateTime.now
181
+ const expiresAt = DateTime.addDuration(now, tokenResponse.expires_in)
182
+
183
+ yield* configManager.updateConfig({
184
+ authMethod: OAuthConfig.make({
185
+ accessToken: Redacted.make(tokenResponse.access_token),
186
+ expiresAt,
187
+ }),
188
+ })
189
+ })
190
+
191
+ return { requestDeviceCode, pollForToken, saveToken } as const
192
+ }),
193
+ }) {}
@@ -0,0 +1,81 @@
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 claude-code`. Reads a Claude Code PreToolUse
10
+ * payload from stdin, fetches the requested URL via expand.ai, and writes
11
+ * a `hookSpecificOutput` payload that substitutes the markdown for what
12
+ * WebFetch would have produced.
13
+ *
14
+ * Substitution mechanism: `permissionDecision: "deny"` blocks the WebFetch,
15
+ * and the page markdown is delivered via `additionalContext` (the documented
16
+ * channel for injecting page content). `permissionDecisionReason` carries
17
+ * a short label only — the agent SDK has no documented cap on it, but
18
+ * `additionalContext` is the semantically correct field for the body.
19
+ */
20
+
21
+ const WEBFETCH_TOOL_NAME = 'WebFetch'
22
+
23
+ class WebFetchToolInput extends Schema.Class<WebFetchToolInput>('WebFetchToolInput')({
24
+ url: Schema.String,
25
+ prompt: Schema.optional(Schema.String),
26
+ }) {}
27
+
28
+ class PreToolUseHookPayload extends Schema.Class<PreToolUseHookPayload>('PreToolUseHookPayload')({
29
+ hook_event_name: Schema.Literal('PreToolUse'),
30
+ tool_name: Schema.String,
31
+ tool_input: WebFetchToolInput,
32
+ }) {}
33
+
34
+ const decodeHookPayload = Schema.decode(Schema.parseJson(PreToolUseHookPayload))
35
+ const encodeHookOutput = Schema.encode(Schema.parseJson(Schema.Unknown))
36
+
37
+ const readStdin = NodeStream.toString(() => process.stdin, {
38
+ onFailure: (error) => new StdinReadError({ error }),
39
+ encoding: 'utf8',
40
+ })
41
+
42
+ export class ClaudeHook extends Effect.Service<ClaudeHook>()('@expandai/cli/Hook/Claude', {
43
+ dependencies: [ApiClient.Default],
44
+ effect: Effect.gen(function* () {
45
+ const apiClient = yield* ApiClient
46
+
47
+ const runProtocol = Effect.fn('ClaudeHook.runProtocol')(function* () {
48
+ const raw = yield* readStdin.pipe(Effect.mapError((error) => new HookProtocolFailed({ error })))
49
+ const payload = yield* decodeHookPayload(raw).pipe(Effect.mapError((error) => new HookProtocolFailed({ error })))
50
+
51
+ // Defensive — settings.json matcher already scopes us to WebFetch.
52
+ if (payload.tool_name !== WEBFETCH_TOOL_NAME) return
53
+
54
+ const url = payload.tool_input.url
55
+ yield* Effect.gen(function* () {
56
+ // Object mode (`/v1/fetch/json`) over markdown mode so the response carries
57
+ // `meta.snapshotId` — the handle the agent uses to search this same capture
58
+ // via `fetch_search` instead of fetching the URL again.
59
+ const result = yield* apiClient.fetchFetchResult({
60
+ params: {},
61
+ payload: { url, include: { markdown: true } },
62
+ })
63
+ if (String.isEmpty(result.markdown)) {
64
+ return yield* new MarkdownUnavailable({ url })
65
+ }
66
+
67
+ const output = {
68
+ hookSpecificOutput: {
69
+ hookEventName: 'PreToolUse',
70
+ permissionDecision: 'deny',
71
+ permissionDecisionReason: `Fetched via expand.ai — page markdown injected as additionalContext.`,
72
+ additionalContext: withSnapshotNote(result.markdown, result.meta.snapshotId, result.meta.playground),
73
+ },
74
+ }
75
+ yield* Console.log(yield* encodeHookOutput(output))
76
+ }).pipe(Effect.mapError((error) => new HookProtocolFailed({ error, url })))
77
+ })
78
+
79
+ return { runProtocol } as const
80
+ }),
81
+ }) {}
@@ -0,0 +1,19 @@
1
+ import { Schema } from 'effect'
2
+
3
+ export class StdinReadError extends Schema.TaggedError<StdinReadError>()('StdinReadError', {
4
+ error: Schema.Defect,
5
+ }) {
6
+ get message() {
7
+ return 'Failed to read hook payload from stdin.'
8
+ }
9
+ }
10
+
11
+ export class HookProtocolFailed extends Schema.TaggedError<HookProtocolFailed>()('HookProtocolFailed', {
12
+ error: Schema.Defect,
13
+ url: Schema.optional(Schema.String),
14
+ }) {
15
+ get message() {
16
+ const inner = this.error instanceof Error ? this.error.message : 'Hook protocol failed.'
17
+ return this.url ? `${inner} (url: ${this.url})` : inner
18
+ }
19
+ }