expandai 0.0.3 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/dts/Commands/Hook.d.ts +1 -1
- package/build/dts/Commands/Uninstall.d.ts +1 -1
- package/build/dts/Init/ClaudeProfile.d.ts +1 -2
- package/build/dts/Init/ClaudeProfile.d.ts.map +1 -1
- package/build/dts/Install/Claude/Hook.d.ts +14 -46
- package/build/dts/Install/Claude/Hook.d.ts.map +1 -1
- package/build/dts/Install/Hook.d.ts +2 -2
- package/build/dts/Install/Uninstall.d.ts +1 -1
- package/build/dts/Mcp/Server.d.ts +1 -1
- package/build/dts/Version.d.ts +1 -1
- package/build/esm/Init/ClaudeProfile.js +19 -55
- package/build/esm/Init/ClaudeProfile.js.map +1 -1
- package/build/esm/Init/Service.js +1 -1
- package/build/esm/Init/Service.js.map +1 -1
- package/build/esm/Init/Status.js +1 -1
- package/build/esm/Init/Status.js.map +1 -1
- package/build/esm/Install/Claude/Hook.js +19 -163
- package/build/esm/Install/Claude/Hook.js.map +1 -1
- package/build/esm/Version.js +1 -1
- package/build/skills/expandai/SKILL.md +12 -6
- package/package.json +1 -1
- package/src/Init/ClaudeProfile.ts +19 -62
- package/src/Init/Service.ts +1 -1
- package/src/Init/Status.ts +1 -1
- package/src/Install/Claude/Hook.ts +21 -184
- package/src/Version.ts +1 -1
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { homedir } from 'node:os'
|
|
2
|
-
import {
|
|
3
|
-
import { CLI_VERSION } from '../../Version.js'
|
|
2
|
+
import { Config, Console, Effect, Match, Schema } from 'effect'
|
|
4
3
|
import { type CliCommand, ConfigIo, cliShellCommand, DIRECT_CLI } from '../Domain.js'
|
|
5
4
|
import type { InstallScope } from '../Mcp.js'
|
|
6
5
|
|
|
@@ -78,142 +77,20 @@ export function isExpandaiHookCommand(command: string): boolean {
|
|
|
78
77
|
}
|
|
79
78
|
|
|
80
79
|
/**
|
|
81
|
-
* `
|
|
82
|
-
* `permissions.deny`, so uninstall can tell our entry from an identical one the
|
|
83
|
-
* user set first. A deny entry is a bare string with nowhere to carry that, and
|
|
84
|
-
* "our hook is present" is not the same claim: installing over a settings file
|
|
85
|
-
* that already denied `WebFetch` leaves the hook without us having written the
|
|
86
|
-
* rule, and removing it then revokes a control its owner chose.
|
|
87
|
-
*/
|
|
88
|
-
export function makeExpandaiMatcher(
|
|
89
|
-
profileVersion: string,
|
|
90
|
-
cli: CliCommand = DIRECT_CLI,
|
|
91
|
-
managedDeny = true,
|
|
92
|
-
): typeof HookMatcher.Type {
|
|
93
|
-
return {
|
|
94
|
-
matcher: WEBFETCH_MATCHER,
|
|
95
|
-
hooks: [
|
|
96
|
-
{
|
|
97
|
-
type: 'command',
|
|
98
|
-
command: hookCommandFor(cli),
|
|
99
|
-
expandaiProfileVersion: profileVersion,
|
|
100
|
-
expandaiManagedDeny: managedDeny,
|
|
101
|
-
},
|
|
102
|
-
],
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** True only when our own hook entry says we wrote the deny rule. */
|
|
107
|
-
export function expandaiOwnsWebFetchDeny(settings: typeof SettingsSchema.Type): boolean {
|
|
108
|
-
return (settings.hooks.PreToolUse ?? []).some((entry) =>
|
|
109
|
-
entry.hooks.some((hook) => isExpandaiHookCommand(hook.command) && hook.expandaiManagedDeny === true),
|
|
110
|
-
)
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function webFetchAlreadyDenied(settings: typeof SettingsSchema.Type): boolean {
|
|
114
|
-
return (settings.permissions?.deny ?? []).includes(WEBFETCH_MATCHER)
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export const expandaiMatcher = makeExpandaiMatcher(CLI_VERSION)
|
|
118
|
-
|
|
119
|
-
function stripExpandaiHooks(entries: ReadonlyArray<typeof HookMatcher.Type>): Array<typeof HookMatcher.Type> {
|
|
120
|
-
return Array.flatMap(entries, (entry) => {
|
|
121
|
-
const hooks = Array.filter(entry.hooks, (hook) => !isExpandaiHookCommand(hook.command))
|
|
122
|
-
return hooks.length === 0 && hooks.length !== entry.hooks.length ? [] : [{ ...entry, hooks }]
|
|
123
|
-
})
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Denying `WebFetch` removes it from the toolset rather than blocking it per call,
|
|
128
|
-
* so the agent reaches for Expand's tool instead of being refused. That difference
|
|
129
|
-
* is the whole point: a hook can only answer a WebFetch call with `deny`, which
|
|
130
|
-
* Claude Code renders to the user as `Error:` on every single fetch.
|
|
131
|
-
*
|
|
132
|
-
* The hook stays installed as the fallback. With the deny rule in force WebFetch is
|
|
133
|
-
* never called, so the hook never fires; if the rule is ever absent or overridden,
|
|
134
|
-
* the hook still intercepts — with the red line, but correctly.
|
|
135
|
-
*/
|
|
136
|
-
export function planClaudeWebFetchDeny(settings: typeof SettingsSchema.Type): ClaudeHookPlan {
|
|
137
|
-
const deny = settings.permissions?.deny ?? []
|
|
138
|
-
if (deny.includes(WEBFETCH_MATCHER)) return { changed: false, settings }
|
|
139
|
-
return {
|
|
140
|
-
changed: true,
|
|
141
|
-
settings: {
|
|
142
|
-
...settings,
|
|
143
|
-
permissions: { ...(settings.permissions ?? {}), deny: [...deny, WEBFETCH_MATCHER] },
|
|
144
|
-
},
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Drops the `WebFetch` entry; anything else the user denied is left alone.
|
|
80
|
+
* Nothing here writes to `settings.json`, and nothing here removes from it.
|
|
150
81
|
*
|
|
151
|
-
* A deny entry is a bare string with nowhere to record who wrote it,
|
|
152
|
-
*
|
|
153
|
-
*
|
|
82
|
+
* A `WebFetch` deny entry is a bare string with nowhere to record who wrote it,
|
|
83
|
+
* and our hook is not proof either: `hook install` used to write the hook on its
|
|
84
|
+
* own, so a settings file carrying our hook and a `WebFetch` deny may well hold a
|
|
85
|
+
* rule the user added afterwards. Removing it would revoke a restriction its
|
|
86
|
+
* owner chose. Since Claude Code no longer gets a hook or a deny rule from us,
|
|
87
|
+
* there is nothing of ours left to clean up, and guessing is the only thing a
|
|
88
|
+
* cleanup pass could do.
|
|
154
89
|
*/
|
|
155
|
-
export function planClaudeWebFetchDenyRemoval(settings: typeof SettingsSchema.Type): ClaudeHookPlan {
|
|
156
|
-
const deny = settings.permissions?.deny
|
|
157
|
-
if (deny === undefined || !deny.includes(WEBFETCH_MATCHER)) return { changed: false, settings }
|
|
158
|
-
const remaining = deny.filter((entry) => entry !== WEBFETCH_MATCHER)
|
|
159
|
-
const permissions = { ...settings.permissions, deny: remaining }
|
|
160
|
-
return { changed: true, settings: { ...settings, permissions } }
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
export function planClaudeHook(
|
|
164
|
-
settings: typeof SettingsSchema.Type,
|
|
165
|
-
profileVersion: string = CLI_VERSION,
|
|
166
|
-
cli: CliCommand = DIRECT_CLI,
|
|
167
|
-
): ClaudeHookPlan {
|
|
168
|
-
const preToolUse = settings.hooks.PreToolUse ?? []
|
|
169
|
-
const owned = preToolUse.flatMap((entry) =>
|
|
170
|
-
entry.hooks.filter((hook) => isExpandaiHookCommand(hook.command)).map((hook) => ({ entry, hook })),
|
|
171
|
-
)
|
|
172
|
-
// The marker records history — did *this* install add the rule — so once it is
|
|
173
|
-
// written it must be preserved, not recomputed. Recomputing it from present
|
|
174
|
-
// state makes the plan disagree with what it just wrote: after we add the deny
|
|
175
|
-
// rule, `webFetchAlreadyDenied` is true, so a second plan would decide the rule
|
|
176
|
-
// was never ours and rewrite the hook forever.
|
|
177
|
-
const existingMarker = owned[0]?.hook.expandaiManagedDeny
|
|
178
|
-
const managedDeny = existingMarker ?? !webFetchAlreadyDenied(settings)
|
|
179
|
-
const current =
|
|
180
|
-
owned.length === 1 &&
|
|
181
|
-
owned[0]?.entry.matcher === WEBFETCH_MATCHER &&
|
|
182
|
-
owned[0]?.hook.command === hookCommandFor(cli) &&
|
|
183
|
-
owned[0]?.hook.expandaiProfileVersion === profileVersion &&
|
|
184
|
-
owned[0]?.hook.expandaiManagedDeny === managedDeny
|
|
185
|
-
if (current) return { changed: false, settings }
|
|
186
|
-
|
|
187
|
-
return {
|
|
188
|
-
changed: true,
|
|
189
|
-
settings: {
|
|
190
|
-
...settings,
|
|
191
|
-
hooks: {
|
|
192
|
-
...settings.hooks,
|
|
193
|
-
PreToolUse: [...stripExpandaiHooks(preToolUse), makeExpandaiMatcher(profileVersion, cli, managedDeny)],
|
|
194
|
-
},
|
|
195
|
-
},
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
export function planClaudeHookRemoval(settings: typeof SettingsSchema.Type): ClaudeHookPlan {
|
|
200
|
-
const preToolUse = settings.hooks.PreToolUse ?? []
|
|
201
|
-
const changed = preToolUse.some((entry) => entry.hooks.some((hook) => isExpandaiHookCommand(hook.command)))
|
|
202
|
-
return changed
|
|
203
|
-
? {
|
|
204
|
-
changed,
|
|
205
|
-
settings: {
|
|
206
|
-
...settings,
|
|
207
|
-
hooks: { ...settings.hooks, PreToolUse: stripExpandaiHooks(preToolUse) },
|
|
208
|
-
},
|
|
209
|
-
}
|
|
210
|
-
: { changed, settings }
|
|
211
|
-
}
|
|
212
90
|
|
|
213
91
|
export class ClaudeHook extends Effect.Service<ClaudeHook>()('@expandai/cli/Install/Claude/Hook', {
|
|
214
92
|
dependencies: [ConfigIo.Default],
|
|
215
93
|
effect: Effect.gen(function* () {
|
|
216
|
-
const io = yield* ConfigIo
|
|
217
94
|
const home = yield* Config.string('HOME').pipe(Config.withDefault(homedir()))
|
|
218
95
|
const cwd = yield* Config.string('PWD').pipe(Config.withDefault(process.cwd()))
|
|
219
96
|
|
|
@@ -225,60 +102,20 @@ export class ClaudeHook extends Effect.Service<ClaudeHook>()('@expandai/cli/Inst
|
|
|
225
102
|
)
|
|
226
103
|
})
|
|
227
104
|
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (!hookPlan.changed && !plan.changed) {
|
|
239
|
-
yield* Console.log(`expandai WebFetch hook already installed at ${target}`)
|
|
240
|
-
return
|
|
241
|
-
}
|
|
242
|
-
yield* io.write(target, plan.settings, SettingsSchema)
|
|
243
|
-
yield* Console.log(`Installed expandai WebFetch hook and deny rule at ${target}`)
|
|
105
|
+
const NOTICE =
|
|
106
|
+
'expandai does not install a WebFetch hook for Claude Code — the MCP server and runtime skill replace it, and neither can disable a built-in tool.'
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Kept as a no-op rather than deleted so `hook install|uninstall claude-code`
|
|
110
|
+
* still resolves. Both say what the profile is instead of touching
|
|
111
|
+
* `settings.json`, which expandai no longer writes for Claude Code.
|
|
112
|
+
*/
|
|
113
|
+
const install = Effect.fn('ClaudeHook.install')(function* (_scope: InstallScope) {
|
|
114
|
+
yield* Console.log(NOTICE)
|
|
244
115
|
})
|
|
245
116
|
|
|
246
|
-
const uninstall = Effect.fn('ClaudeHook.uninstall')(function* (
|
|
247
|
-
|
|
248
|
-
if (!(yield* io.exists(target))) return
|
|
249
|
-
const settings = yield* io.read(target, SettingsSchema)
|
|
250
|
-
// Uninstall has to undo both halves, or `WebFetch` stays denied with nothing
|
|
251
|
-
// left to serve the fetch — worse than never having installed us.
|
|
252
|
-
//
|
|
253
|
-
// But only the halves we wrote. `expandaiManagedDeny` on our hook records
|
|
254
|
-
// whether this install added the deny rule; without it, the rule is either
|
|
255
|
-
// the user's own or from an install predating the marker, and those are
|
|
256
|
-
// indistinguishable. Guessing "ours" revokes a control its owner chose;
|
|
257
|
-
// guessing "theirs" leaves `WebFetch` denied with nothing serving fetches.
|
|
258
|
-
// So leave it and say so — `uninstall` also fans out over both scopes, and
|
|
259
|
-
// would otherwise reach into a global settings file a project-scoped
|
|
260
|
-
// install never touched.
|
|
261
|
-
const hookPlan = planClaudeHookRemoval(settings)
|
|
262
|
-
if (!hookPlan.changed) return
|
|
263
|
-
// Read the marker off the hook before it is stripped.
|
|
264
|
-
const owned = expandaiOwnsWebFetchDeny(settings)
|
|
265
|
-
const denyPlan = owned ? planClaudeWebFetchDenyRemoval(hookPlan.settings) : hookPlan
|
|
266
|
-
const strandedDeny = !owned && (settings.permissions?.deny ?? []).includes(WEBFETCH_MATCHER)
|
|
267
|
-
yield* io.write(target, denyPlan.settings, SettingsSchema)
|
|
268
|
-
// `denyPlan` is the hook plan itself when we do not own the rule, so its
|
|
269
|
-
// `changed` flag reports the hook removal — not a deny removal that never
|
|
270
|
-
// happened.
|
|
271
|
-
const removedDeny = owned && denyPlan.changed
|
|
272
|
-
yield* Console.log(
|
|
273
|
-
removedDeny
|
|
274
|
-
? `Removed expandai WebFetch hook and deny rule from ${target}`
|
|
275
|
-
: `Removed expandai WebFetch hook from ${target}`,
|
|
276
|
-
)
|
|
277
|
-
if (strandedDeny) {
|
|
278
|
-
yield* Console.log(
|
|
279
|
-
`Left "WebFetch" in permissions.deny at ${target} — expandai could not confirm it added that rule. Remove it by hand if you want Claude Code's built-in WebFetch back.`,
|
|
280
|
-
)
|
|
281
|
-
}
|
|
117
|
+
const uninstall = Effect.fn('ClaudeHook.uninstall')(function* (_scope: InstallScope) {
|
|
118
|
+
yield* Console.log(NOTICE)
|
|
282
119
|
})
|
|
283
120
|
|
|
284
121
|
return { install, uninstall, pathFor } as const
|
package/src/Version.ts
CHANGED