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.
- package/README.md +241 -6
- package/build/dts/ApiClient.d.ts +231 -0
- package/build/dts/ApiClient.d.ts.map +1 -0
- package/build/dts/Authentication.d.ts +23 -0
- package/build/dts/Authentication.d.ts.map +1 -0
- package/build/dts/BrowserOAuth.d.ts +99 -0
- package/build/dts/BrowserOAuth.d.ts.map +1 -0
- package/build/dts/Commands/Fetch.d.ts +30 -0
- package/build/dts/Commands/Fetch.d.ts.map +1 -0
- package/build/dts/Commands/Hook.d.ts +17 -0
- package/build/dts/Commands/Hook.d.ts.map +1 -0
- package/build/dts/Commands/Mcp.d.ts +11 -0
- package/build/dts/Commands/Mcp.d.ts.map +1 -0
- package/build/dts/Commands/McpRun.d.ts +5 -0
- package/build/dts/Commands/McpRun.d.ts.map +1 -0
- package/build/dts/Commands/Search.d.ts +21 -0
- package/build/dts/Commands/Search.d.ts.map +1 -0
- package/build/dts/Commands/Skill.d.ts +12 -0
- package/build/dts/Commands/Skill.d.ts.map +1 -0
- package/build/dts/Commands/Uninstall.d.ts +9 -0
- package/build/dts/Commands/Uninstall.d.ts.map +1 -0
- package/build/dts/Config.d.ts +105 -0
- package/build/dts/Config.d.ts.map +1 -0
- package/build/dts/DeviceAuth.d.ts +87 -0
- package/build/dts/DeviceAuth.d.ts.map +1 -0
- package/build/dts/Hook/Claude.d.ts +13 -0
- package/build/dts/Hook/Claude.d.ts.map +1 -0
- package/build/dts/Hook/Errors.d.ts +20 -0
- package/build/dts/Hook/Errors.d.ts.map +1 -0
- package/build/dts/Hook/Opencode.d.ts +13 -0
- package/build/dts/Hook/Opencode.d.ts.map +1 -0
- package/build/dts/Hook/SnapshotNote.d.ts +8 -0
- package/build/dts/Hook/SnapshotNote.d.ts.map +1 -0
- package/build/dts/Install/Claude/Hook.d.ts +60 -0
- package/build/dts/Install/Claude/Hook.d.ts.map +1 -0
- package/build/dts/Install/Claude/Mcp.d.ts +45 -0
- package/build/dts/Install/Claude/Mcp.d.ts.map +1 -0
- package/build/dts/Install/Claude/Skill.d.ts +24 -0
- package/build/dts/Install/Claude/Skill.d.ts.map +1 -0
- package/build/dts/Install/Codex/Mcp.d.ts +39 -0
- package/build/dts/Install/Codex/Mcp.d.ts.map +1 -0
- package/build/dts/Install/Codex/Skill.d.ts +20 -0
- package/build/dts/Install/Codex/Skill.d.ts.map +1 -0
- package/build/dts/Install/Cursor/Mcp.d.ts +45 -0
- package/build/dts/Install/Cursor/Mcp.d.ts.map +1 -0
- package/build/dts/Install/Cursor/Skill.d.ts +27 -0
- package/build/dts/Install/Cursor/Skill.d.ts.map +1 -0
- package/build/dts/Install/Domain.d.ts +73 -0
- package/build/dts/Install/Domain.d.ts.map +1 -0
- package/build/dts/Install/Hook.d.ts +30 -0
- package/build/dts/Install/Hook.d.ts.map +1 -0
- package/build/dts/Install/Mcp.d.ts +28 -0
- package/build/dts/Install/Mcp.d.ts.map +1 -0
- package/build/dts/Install/Opencode/Hook.d.ts +23 -0
- package/build/dts/Install/Opencode/Hook.d.ts.map +1 -0
- package/build/dts/Install/Opencode/Mcp.d.ts +47 -0
- package/build/dts/Install/Opencode/Mcp.d.ts.map +1 -0
- package/build/dts/Install/Opencode/Skill.d.ts +24 -0
- package/build/dts/Install/Opencode/Skill.d.ts.map +1 -0
- package/build/dts/Install/Skill.d.ts +32 -0
- package/build/dts/Install/Skill.d.ts.map +1 -0
- package/build/dts/Install/Uninstall.d.ts +32 -0
- package/build/dts/Install/Uninstall.d.ts.map +1 -0
- package/build/dts/Mcp/ExpandDocs.d.ts +7 -0
- package/build/dts/Mcp/ExpandDocs.d.ts.map +1 -0
- package/build/dts/Mcp/RequestHeaders.d.ts +15 -0
- package/build/dts/Mcp/RequestHeaders.d.ts.map +1 -0
- package/build/dts/Mcp/Server.d.ts +10 -0
- package/build/dts/Mcp/Server.d.ts.map +1 -0
- package/build/dts/Mcp/Toolkit.d.ts +3 -0
- package/build/dts/Mcp/Toolkit.d.ts.map +1 -0
- package/build/dts/Mcp/tools/Citations.d.ts +237 -0
- package/build/dts/Mcp/tools/Citations.d.ts.map +1 -0
- package/build/dts/Mcp/tools/Fetch.d.ts +167 -0
- package/build/dts/Mcp/tools/Fetch.d.ts.map +1 -0
- package/build/dts/Snippets.d.ts +19 -0
- package/build/dts/Snippets.d.ts.map +1 -0
- package/build/dts/index.d.ts +2 -0
- package/build/dts/index.d.ts.map +1 -0
- package/build/dts/main.d.ts +3 -0
- package/build/dts/main.d.ts.map +1 -0
- package/build/esm/ApiClient.js +182 -0
- package/build/esm/ApiClient.js.map +1 -0
- package/build/esm/Authentication.js +112 -0
- package/build/esm/Authentication.js.map +1 -0
- package/build/esm/BrowserOAuth.js +254 -0
- package/build/esm/BrowserOAuth.js.map +1 -0
- package/build/esm/Commands/Fetch.js +95 -0
- package/build/esm/Commands/Fetch.js.map +1 -0
- package/build/esm/Commands/Hook.js +20 -0
- package/build/esm/Commands/Hook.js.map +1 -0
- package/build/esm/Commands/Mcp.js +19 -0
- package/build/esm/Commands/Mcp.js.map +1 -0
- package/build/esm/Commands/McpRun.js +15 -0
- package/build/esm/Commands/McpRun.js.map +1 -0
- package/build/esm/Commands/Search.js +43 -0
- package/build/esm/Commands/Search.js.map +1 -0
- package/build/esm/Commands/Skill.js +17 -0
- package/build/esm/Commands/Skill.js.map +1 -0
- package/build/esm/Commands/Uninstall.js +28 -0
- package/build/esm/Commands/Uninstall.js.map +1 -0
- package/build/esm/Config.js +145 -0
- package/build/esm/Config.js.map +1 -0
- package/build/esm/DeviceAuth.js +127 -0
- package/build/esm/DeviceAuth.js.map +1 -0
- package/build/esm/Hook/Claude.js +74 -0
- package/build/esm/Hook/Claude.js.map +1 -0
- package/build/esm/Hook/Errors.js +18 -0
- package/build/esm/Hook/Errors.js.map +1 -0
- package/build/esm/Hook/Opencode.js +50 -0
- package/build/esm/Hook/Opencode.js.map +1 -0
- package/build/esm/Hook/SnapshotNote.js +18 -0
- package/build/esm/Hook/SnapshotNote.js.map +1 -0
- package/build/esm/Install/Claude/Hook.js +89 -0
- package/build/esm/Install/Claude/Hook.js.map +1 -0
- package/build/esm/Install/Claude/Mcp.js +71 -0
- package/build/esm/Install/Claude/Mcp.js.map +1 -0
- package/build/esm/Install/Claude/Skill.js +34 -0
- package/build/esm/Install/Claude/Skill.js.map +1 -0
- package/build/esm/Install/Codex/Mcp.js +92 -0
- package/build/esm/Install/Codex/Mcp.js.map +1 -0
- package/build/esm/Install/Codex/Skill.js +30 -0
- package/build/esm/Install/Codex/Skill.js.map +1 -0
- package/build/esm/Install/Cursor/Mcp.js +71 -0
- package/build/esm/Install/Cursor/Mcp.js.map +1 -0
- package/build/esm/Install/Cursor/Skill.js +37 -0
- package/build/esm/Install/Cursor/Skill.js.map +1 -0
- package/build/esm/Install/Domain.js +105 -0
- package/build/esm/Install/Domain.js.map +1 -0
- package/build/esm/Install/Hook.js +38 -0
- package/build/esm/Install/Hook.js.map +1 -0
- package/build/esm/Install/Mcp.js +40 -0
- package/build/esm/Install/Mcp.js.map +1 -0
- package/build/esm/Install/Opencode/Hook.js +44 -0
- package/build/esm/Install/Opencode/Hook.js.map +1 -0
- package/build/esm/Install/Opencode/Mcp.js +73 -0
- package/build/esm/Install/Opencode/Mcp.js.map +1 -0
- package/build/esm/Install/Opencode/Skill.js +34 -0
- package/build/esm/Install/Opencode/Skill.js.map +1 -0
- package/build/esm/Install/Opencode/expandai-webfetch.js +38 -0
- package/build/esm/Install/Skill.js +41 -0
- package/build/esm/Install/Skill.js.map +1 -0
- package/build/esm/Install/Uninstall.js +49 -0
- package/build/esm/Install/Uninstall.js.map +1 -0
- package/build/esm/Mcp/ExpandDocs.js +64 -0
- package/build/esm/Mcp/ExpandDocs.js.map +1 -0
- package/build/esm/Mcp/RequestHeaders.js +13 -0
- package/build/esm/Mcp/RequestHeaders.js.map +1 -0
- package/build/esm/Mcp/Server.js +11 -0
- package/build/esm/Mcp/Server.js.map +1 -0
- package/build/esm/Mcp/Toolkit.js +16 -0
- package/build/esm/Mcp/Toolkit.js.map +1 -0
- package/build/esm/Mcp/tools/Citations.js +92 -0
- package/build/esm/Mcp/tools/Citations.js.map +1 -0
- package/build/esm/Mcp/tools/Fetch.js +141 -0
- package/build/esm/Mcp/tools/Fetch.js.map +1 -0
- package/build/esm/Snippets.js +18 -0
- package/build/esm/Snippets.js.map +1 -0
- package/build/esm/index.js +2 -0
- package/build/esm/index.js.map +1 -0
- package/build/esm/main.js +97 -0
- package/build/esm/main.js.map +1 -0
- package/build/skills/expandai/SKILL.md +72 -0
- package/package.json +72 -10
- package/src/ApiClient.ts +257 -0
- package/src/Authentication.ts +164 -0
- package/src/BrowserOAuth.ts +361 -0
- package/src/Commands/Fetch.ts +157 -0
- package/src/Commands/Hook.ts +48 -0
- package/src/Commands/Mcp.ts +30 -0
- package/src/Commands/McpRun.ts +25 -0
- package/src/Commands/Search.ts +92 -0
- package/src/Commands/Skill.ts +30 -0
- package/src/Commands/Uninstall.ts +38 -0
- package/src/Config.ts +195 -0
- package/src/DeviceAuth.ts +193 -0
- package/src/Hook/Claude.ts +81 -0
- package/src/Hook/Errors.ts +19 -0
- package/src/Hook/Opencode.ts +58 -0
- package/src/Hook/SnapshotNote.ts +18 -0
- package/src/Install/Claude/Hook.ts +133 -0
- package/src/Install/Claude/Mcp.ts +82 -0
- package/src/Install/Claude/Skill.ts +44 -0
- package/src/Install/Codex/Mcp.ts +109 -0
- package/src/Install/Codex/Skill.ts +40 -0
- package/src/Install/Cursor/Mcp.ts +82 -0
- package/src/Install/Cursor/Skill.ts +47 -0
- package/src/Install/Domain.ts +148 -0
- package/src/Install/Hook.ts +50 -0
- package/src/Install/Mcp.ts +71 -0
- package/src/Install/Opencode/Hook.ts +55 -0
- package/src/Install/Opencode/Mcp.ts +84 -0
- package/src/Install/Opencode/Skill.ts +44 -0
- package/src/Install/Opencode/expandai-webfetch.d.ts +23 -0
- package/src/Install/Opencode/expandai-webfetch.js +38 -0
- package/src/Install/Skill.ts +57 -0
- package/src/Install/Uninstall.ts +82 -0
- package/src/Mcp/ExpandDocs.ts +71 -0
- package/src/Mcp/RequestHeaders.ts +16 -0
- package/src/Mcp/Server.ts +14 -0
- package/src/Mcp/Toolkit.ts +21 -0
- package/src/Mcp/tools/Citations.ts +110 -0
- package/src/Mcp/tools/Fetch.ts +177 -0
- package/src/Snippets.ts +32 -0
- package/src/index.ts +1 -0
- package/src/main.ts +146 -0
- package/bun.lockb +0 -0
- package/index.ts +0 -1
- 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
|
+
}) {}
|