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 { ConfigIo, cliShellCommand, DIRECT_CLI } from '../Domain.js';
|
|
5
4
|
/** Claude Code WebFetch hook invocation persisted in harness settings. */
|
|
6
5
|
export const HOOK_INVOCATION = 'expandai hook claude-code';
|
|
@@ -41,178 +40,35 @@ export function isExpandaiHookCommand(command) {
|
|
|
41
40
|
return direct.test(value) || absolute.test(value) || shellFallback.test(value);
|
|
42
41
|
}
|
|
43
42
|
/**
|
|
44
|
-
* `
|
|
45
|
-
* `permissions.deny`, so uninstall can tell our entry from an identical one the
|
|
46
|
-
* user set first. A deny entry is a bare string with nowhere to carry that, and
|
|
47
|
-
* "our hook is present" is not the same claim: installing over a settings file
|
|
48
|
-
* that already denied `WebFetch` leaves the hook without us having written the
|
|
49
|
-
* rule, and removing it then revokes a control its owner chose.
|
|
50
|
-
*/
|
|
51
|
-
export function makeExpandaiMatcher(profileVersion, cli = DIRECT_CLI, managedDeny = true) {
|
|
52
|
-
return {
|
|
53
|
-
matcher: WEBFETCH_MATCHER,
|
|
54
|
-
hooks: [
|
|
55
|
-
{
|
|
56
|
-
type: 'command',
|
|
57
|
-
command: hookCommandFor(cli),
|
|
58
|
-
expandaiProfileVersion: profileVersion,
|
|
59
|
-
expandaiManagedDeny: managedDeny,
|
|
60
|
-
},
|
|
61
|
-
],
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
/** True only when our own hook entry says we wrote the deny rule. */
|
|
65
|
-
export function expandaiOwnsWebFetchDeny(settings) {
|
|
66
|
-
return (settings.hooks.PreToolUse ?? []).some((entry) => entry.hooks.some((hook) => isExpandaiHookCommand(hook.command) && hook.expandaiManagedDeny === true));
|
|
67
|
-
}
|
|
68
|
-
function webFetchAlreadyDenied(settings) {
|
|
69
|
-
return (settings.permissions?.deny ?? []).includes(WEBFETCH_MATCHER);
|
|
70
|
-
}
|
|
71
|
-
export const expandaiMatcher = makeExpandaiMatcher(CLI_VERSION);
|
|
72
|
-
function stripExpandaiHooks(entries) {
|
|
73
|
-
return Array.flatMap(entries, (entry) => {
|
|
74
|
-
const hooks = Array.filter(entry.hooks, (hook) => !isExpandaiHookCommand(hook.command));
|
|
75
|
-
return hooks.length === 0 && hooks.length !== entry.hooks.length ? [] : [{ ...entry, hooks }];
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
/**
|
|
79
|
-
* Denying `WebFetch` removes it from the toolset rather than blocking it per call,
|
|
80
|
-
* so the agent reaches for Expand's tool instead of being refused. That difference
|
|
81
|
-
* is the whole point: a hook can only answer a WebFetch call with `deny`, which
|
|
82
|
-
* Claude Code renders to the user as `Error:` on every single fetch.
|
|
43
|
+
* Nothing here writes to `settings.json`, and nothing here removes from it.
|
|
83
44
|
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
45
|
+
* A `WebFetch` deny entry is a bare string with nowhere to record who wrote it,
|
|
46
|
+
* and our hook is not proof either: `hook install` used to write the hook on its
|
|
47
|
+
* own, so a settings file carrying our hook and a `WebFetch` deny may well hold a
|
|
48
|
+
* rule the user added afterwards. Removing it would revoke a restriction its
|
|
49
|
+
* owner chose. Since Claude Code no longer gets a hook or a deny rule from us,
|
|
50
|
+
* there is nothing of ours left to clean up, and guessing is the only thing a
|
|
51
|
+
* cleanup pass could do.
|
|
87
52
|
*/
|
|
88
|
-
export function planClaudeWebFetchDeny(settings) {
|
|
89
|
-
const deny = settings.permissions?.deny ?? [];
|
|
90
|
-
if (deny.includes(WEBFETCH_MATCHER))
|
|
91
|
-
return { changed: false, settings };
|
|
92
|
-
return {
|
|
93
|
-
changed: true,
|
|
94
|
-
settings: {
|
|
95
|
-
...settings,
|
|
96
|
-
permissions: { ...(settings.permissions ?? {}), deny: [...deny, WEBFETCH_MATCHER] },
|
|
97
|
-
},
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
/**
|
|
101
|
-
* Drops the `WebFetch` entry; anything else the user denied is left alone.
|
|
102
|
-
*
|
|
103
|
-
* A deny entry is a bare string with nowhere to record who wrote it, so this
|
|
104
|
-
* cannot tell our rule from an identical one the user added themselves. The
|
|
105
|
-
* caller supplies that evidence — see `uninstall`.
|
|
106
|
-
*/
|
|
107
|
-
export function planClaudeWebFetchDenyRemoval(settings) {
|
|
108
|
-
const deny = settings.permissions?.deny;
|
|
109
|
-
if (deny === undefined || !deny.includes(WEBFETCH_MATCHER))
|
|
110
|
-
return { changed: false, settings };
|
|
111
|
-
const remaining = deny.filter((entry) => entry !== WEBFETCH_MATCHER);
|
|
112
|
-
const permissions = { ...settings.permissions, deny: remaining };
|
|
113
|
-
return { changed: true, settings: { ...settings, permissions } };
|
|
114
|
-
}
|
|
115
|
-
export function planClaudeHook(settings, profileVersion = CLI_VERSION, cli = DIRECT_CLI) {
|
|
116
|
-
const preToolUse = settings.hooks.PreToolUse ?? [];
|
|
117
|
-
const owned = preToolUse.flatMap((entry) => entry.hooks.filter((hook) => isExpandaiHookCommand(hook.command)).map((hook) => ({ entry, hook })));
|
|
118
|
-
// The marker records history — did *this* install add the rule — so once it is
|
|
119
|
-
// written it must be preserved, not recomputed. Recomputing it from present
|
|
120
|
-
// state makes the plan disagree with what it just wrote: after we add the deny
|
|
121
|
-
// rule, `webFetchAlreadyDenied` is true, so a second plan would decide the rule
|
|
122
|
-
// was never ours and rewrite the hook forever.
|
|
123
|
-
const existingMarker = owned[0]?.hook.expandaiManagedDeny;
|
|
124
|
-
const managedDeny = existingMarker ?? !webFetchAlreadyDenied(settings);
|
|
125
|
-
const current = owned.length === 1 &&
|
|
126
|
-
owned[0]?.entry.matcher === WEBFETCH_MATCHER &&
|
|
127
|
-
owned[0]?.hook.command === hookCommandFor(cli) &&
|
|
128
|
-
owned[0]?.hook.expandaiProfileVersion === profileVersion &&
|
|
129
|
-
owned[0]?.hook.expandaiManagedDeny === managedDeny;
|
|
130
|
-
if (current)
|
|
131
|
-
return { changed: false, settings };
|
|
132
|
-
return {
|
|
133
|
-
changed: true,
|
|
134
|
-
settings: {
|
|
135
|
-
...settings,
|
|
136
|
-
hooks: {
|
|
137
|
-
...settings.hooks,
|
|
138
|
-
PreToolUse: [...stripExpandaiHooks(preToolUse), makeExpandaiMatcher(profileVersion, cli, managedDeny)],
|
|
139
|
-
},
|
|
140
|
-
},
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
export function planClaudeHookRemoval(settings) {
|
|
144
|
-
const preToolUse = settings.hooks.PreToolUse ?? [];
|
|
145
|
-
const changed = preToolUse.some((entry) => entry.hooks.some((hook) => isExpandaiHookCommand(hook.command)));
|
|
146
|
-
return changed
|
|
147
|
-
? {
|
|
148
|
-
changed,
|
|
149
|
-
settings: {
|
|
150
|
-
...settings,
|
|
151
|
-
hooks: { ...settings.hooks, PreToolUse: stripExpandaiHooks(preToolUse) },
|
|
152
|
-
},
|
|
153
|
-
}
|
|
154
|
-
: { changed, settings };
|
|
155
|
-
}
|
|
156
53
|
export class ClaudeHook extends Effect.Service()('@expandai/cli/Install/Claude/Hook', {
|
|
157
54
|
dependencies: [ConfigIo.Default],
|
|
158
55
|
effect: Effect.gen(function* () {
|
|
159
|
-
const io = yield* ConfigIo;
|
|
160
56
|
const home = yield* Config.string('HOME').pipe(Config.withDefault(homedir()));
|
|
161
57
|
const cwd = yield* Config.string('PWD').pipe(Config.withDefault(process.cwd()));
|
|
162
58
|
const pathFor = Effect.fn(function* (scope) {
|
|
163
59
|
return yield* Match.value(scope).pipe(Match.when('global', () => Effect.succeed(`${home}/.claude/settings.json`)), Match.when('project', () => Effect.succeed(`${cwd}/.claude/settings.json`)), Match.exhaustive);
|
|
164
60
|
});
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const hookPlan = planClaudeHook(settings);
|
|
174
|
-
const plan = planClaudeWebFetchDeny(hookPlan.settings);
|
|
175
|
-
if (!hookPlan.changed && !plan.changed) {
|
|
176
|
-
yield* Console.log(`expandai WebFetch hook already installed at ${target}`);
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
yield* io.write(target, plan.settings, SettingsSchema);
|
|
180
|
-
yield* Console.log(`Installed expandai WebFetch hook and deny rule at ${target}`);
|
|
61
|
+
const NOTICE = '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.';
|
|
62
|
+
/**
|
|
63
|
+
* Kept as a no-op rather than deleted so `hook install|uninstall claude-code`
|
|
64
|
+
* still resolves. Both say what the profile is instead of touching
|
|
65
|
+
* `settings.json`, which expandai no longer writes for Claude Code.
|
|
66
|
+
*/
|
|
67
|
+
const install = Effect.fn('ClaudeHook.install')(function* (_scope) {
|
|
68
|
+
yield* Console.log(NOTICE);
|
|
181
69
|
});
|
|
182
|
-
const uninstall = Effect.fn('ClaudeHook.uninstall')(function* (
|
|
183
|
-
|
|
184
|
-
if (!(yield* io.exists(target)))
|
|
185
|
-
return;
|
|
186
|
-
const settings = yield* io.read(target, SettingsSchema);
|
|
187
|
-
// Uninstall has to undo both halves, or `WebFetch` stays denied with nothing
|
|
188
|
-
// left to serve the fetch — worse than never having installed us.
|
|
189
|
-
//
|
|
190
|
-
// But only the halves we wrote. `expandaiManagedDeny` on our hook records
|
|
191
|
-
// whether this install added the deny rule; without it, the rule is either
|
|
192
|
-
// the user's own or from an install predating the marker, and those are
|
|
193
|
-
// indistinguishable. Guessing "ours" revokes a control its owner chose;
|
|
194
|
-
// guessing "theirs" leaves `WebFetch` denied with nothing serving fetches.
|
|
195
|
-
// So leave it and say so — `uninstall` also fans out over both scopes, and
|
|
196
|
-
// would otherwise reach into a global settings file a project-scoped
|
|
197
|
-
// install never touched.
|
|
198
|
-
const hookPlan = planClaudeHookRemoval(settings);
|
|
199
|
-
if (!hookPlan.changed)
|
|
200
|
-
return;
|
|
201
|
-
// Read the marker off the hook before it is stripped.
|
|
202
|
-
const owned = expandaiOwnsWebFetchDeny(settings);
|
|
203
|
-
const denyPlan = owned ? planClaudeWebFetchDenyRemoval(hookPlan.settings) : hookPlan;
|
|
204
|
-
const strandedDeny = !owned && (settings.permissions?.deny ?? []).includes(WEBFETCH_MATCHER);
|
|
205
|
-
yield* io.write(target, denyPlan.settings, SettingsSchema);
|
|
206
|
-
// `denyPlan` is the hook plan itself when we do not own the rule, so its
|
|
207
|
-
// `changed` flag reports the hook removal — not a deny removal that never
|
|
208
|
-
// happened.
|
|
209
|
-
const removedDeny = owned && denyPlan.changed;
|
|
210
|
-
yield* Console.log(removedDeny
|
|
211
|
-
? `Removed expandai WebFetch hook and deny rule from ${target}`
|
|
212
|
-
: `Removed expandai WebFetch hook from ${target}`);
|
|
213
|
-
if (strandedDeny) {
|
|
214
|
-
yield* Console.log(`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.`);
|
|
215
|
-
}
|
|
70
|
+
const uninstall = Effect.fn('ClaudeHook.uninstall')(function* (_scope) {
|
|
71
|
+
yield* Console.log(NOTICE);
|
|
216
72
|
});
|
|
217
73
|
return { install, uninstall, pathFor };
|
|
218
74
|
}),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Hook.js","sourceRoot":"","sources":["../../../../src/Install/Claude/Hook.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAA;AACjC,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"Hook.js","sourceRoot":"","sources":["../../../../src/Install/Claude/Hook.ts"],"names":[],"mappings":"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"}
|
package/build/esm/Version.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: expandai
|
|
3
|
-
description:
|
|
3
|
+
description: Use when any public http(s) URL needs to be read — documentation, articles, GitHub pages, blog posts, release notes, or a link the user pasted — and before calling WebFetch or curl on it. WebFetch does not execute JavaScript, so also use this whenever a fetch came back empty, truncated, 403, paywalled, or asking you to enable JavaScript. Quoting a captured page requires the citation protocol below. Not for localhost, private or internal hosts, or JSON API endpoints.
|
|
4
4
|
metadata:
|
|
5
5
|
expandai-profile-version: 0.0.2
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# expandai
|
|
9
9
|
|
|
10
|
+
**Before answering from a page you fetched, read the Citations section below.**
|
|
11
|
+
|
|
10
12
|
Fetch any URL as clean markdown.
|
|
11
13
|
|
|
12
14
|
```bash
|
|
@@ -15,7 +17,12 @@ expandai fetch <url>
|
|
|
15
17
|
|
|
16
18
|
## When to use
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
Reach for `expandai fetch` before `curl` or `WebFetch`. It renders the live page in a browser —
|
|
21
|
+
JavaScript, SPAs, bot protection — and returns markdown rather than raw HTML. `WebFetch` has no
|
|
22
|
+
JavaScript engine, so on a client-rendered page it returns the empty shell and nothing else.
|
|
23
|
+
|
|
24
|
+
Skip it for localhost, private or internal hosts, and JSON API endpoints; `curl` is the right
|
|
25
|
+
tool there.
|
|
19
26
|
|
|
20
27
|
## Usage
|
|
21
28
|
|
|
@@ -33,9 +40,8 @@ expandai fetch https://example.com --format json # object-mode { meta, markdown
|
|
|
33
40
|
|
|
34
41
|
## Search a page you already fetched
|
|
35
42
|
|
|
36
|
-
Every fetch produces a snapshot handle (`meta.snapshotId`).
|
|
37
|
-
|
|
38
|
-
extract specific passages by **searching the snapshot by handle** — don't fetch the URL again:
|
|
43
|
+
Every fetch produces a snapshot handle (`meta.snapshotId`). Once a page is captured, extract
|
|
44
|
+
specific passages by **searching the snapshot by handle** — don't fetch the URL again:
|
|
39
45
|
|
|
40
46
|
```bash
|
|
41
47
|
expandai search <snapshotId> "your query" # snippets from the stored snapshot
|
|
@@ -52,7 +58,7 @@ prefer `expandai search` (or `fetch_search`), which reuses the existing capture.
|
|
|
52
58
|
## Citations
|
|
53
59
|
|
|
54
60
|
Cite every claim you take from a fetched page. This is not optional, and it applies however
|
|
55
|
-
the page arrived — `expandai fetch`, `expandai search`, the MCP tools
|
|
61
|
+
the page arrived — `expandai fetch`, `expandai search`, or the MCP tools.
|
|
56
62
|
|
|
57
63
|
Captured markdown stamps an evidence id next to the text it belongs to, written as `{1199}`.
|
|
58
64
|
For each sentence you use, take the id nearest that text and end your answer with:
|
package/package.json
CHANGED
|
@@ -2,14 +2,7 @@ import { homedir } from 'node:os'
|
|
|
2
2
|
import { FileSystem, Path } from '@effect/platform'
|
|
3
3
|
import { NodeFileSystem, NodePath } from '@effect/platform-node'
|
|
4
4
|
import { Array, Config, Effect, Either, Layer, Option, Schema } from 'effect'
|
|
5
|
-
import {
|
|
6
|
-
hookCommandFor,
|
|
7
|
-
isExpandaiHookCommand,
|
|
8
|
-
planClaudeHook,
|
|
9
|
-
planClaudeWebFetchDeny,
|
|
10
|
-
SettingsSchema,
|
|
11
|
-
WEBFETCH_MATCHER,
|
|
12
|
-
} from '../Install/Claude/Hook.js'
|
|
5
|
+
import { SettingsSchema } from '../Install/Claude/Hook.js'
|
|
13
6
|
import { planClaudeMcp } from '../Install/Claude/Mcp.js'
|
|
14
7
|
import { planClaudeSkill, SKILL_FILE_NAME, SKILL_NAME } from '../Install/Claude/Skill.js'
|
|
15
8
|
import { type CliCommand, CliInvocation, McpServersConfigSchema, SERVER_KEY } from '../Install/Domain.js'
|
|
@@ -19,7 +12,6 @@ import type { MutationExpectedState, MutationOperation } from './MutationTransac
|
|
|
19
12
|
|
|
20
13
|
export const CLAUDE_PROFILE_VERSION = CLI_VERSION
|
|
21
14
|
|
|
22
|
-
export const CLAUDE_POLICY_MANAGED_HOOKS = 'managed_hooks_only'
|
|
23
15
|
export const CLAUDE_POLICY_MANAGED_MCP = 'managed_mcp_only'
|
|
24
16
|
export const CLAUDE_POLICY_MCP_DENIED = 'managed_mcp_denied'
|
|
25
17
|
export const CLAUDE_POLICY_MCP_NOT_ALLOWED = 'managed_mcp_not_allowed'
|
|
@@ -145,8 +137,10 @@ function managedPolicyBlocks(settings: ReadonlyArray<Record<string, unknown>>):
|
|
|
145
137
|
const managedMcpOnly = settings.some((value) => value.allowManagedMcpServersOnly === true)
|
|
146
138
|
const allowedMcpConfigured = settings.some((value) => Array.isArray(value.allowedMcpServers))
|
|
147
139
|
const expandaiAllowed = settings.some((value) => listContainsExpandai(value.allowedMcpServers))
|
|
140
|
+
// `allowManagedHooksOnly` is deliberately not consulted. It restricts which hooks
|
|
141
|
+
// may be installed, and this profile installs none — blocking on it would refuse
|
|
142
|
+
// a skill-plus-MCP setup the policy has no objection to.
|
|
148
143
|
for (const value of settings) {
|
|
149
|
-
if (value.allowManagedHooksOnly === true) blocks.add(CLAUDE_POLICY_MANAGED_HOOKS)
|
|
150
144
|
if (listContainsExpandai(value.deniedMcpServers)) blocks.add(CLAUDE_POLICY_MCP_DENIED)
|
|
151
145
|
}
|
|
152
146
|
if (managedMcpOnly && !expandaiAllowed) blocks.add(CLAUDE_POLICY_MANAGED_MCP)
|
|
@@ -170,30 +164,7 @@ function mcpLegacyVariant(value: unknown): string[] {
|
|
|
170
164
|
return ['owned-entry']
|
|
171
165
|
}
|
|
172
166
|
|
|
173
|
-
function hookLegacyVariant(command: string): string {
|
|
174
|
-
if (/^npx\s+-y\s+/u.test(command.trim())) return 'npx-y'
|
|
175
|
-
if (/\shook\s+claude\s*$/u.test(command)) return 'old-command-spelling'
|
|
176
|
-
if (/^(?:"|'|\/|[A-Za-z]:\\)/u.test(command.trim())) return 'absolute-path'
|
|
177
|
-
if (/^if\s+command\s+-v\s+expandai/u.test(command.trim())) return 'npx-shell-fallback'
|
|
178
|
-
return 'unversioned'
|
|
179
|
-
}
|
|
180
|
-
|
|
181
167
|
function makeInspection(scope: InitScope, loaded: LoadedClaudeProfile, cli: CliCommand): ClaudeProfileInspection {
|
|
182
|
-
const hookPlan = planClaudeHook(loaded.settings, CLAUDE_PROFILE_VERSION, cli)
|
|
183
|
-
const denyPlan = planClaudeWebFetchDeny(hookPlan.settings)
|
|
184
|
-
const hookEntries = (loaded.settings.hooks.PreToolUse ?? []).flatMap((entry) =>
|
|
185
|
-
entry.hooks.filter((hook) => isExpandaiHookCommand(hook.command)).map((hook) => ({ entry, hook })),
|
|
186
|
-
)
|
|
187
|
-
const hookVersion = hookEntries.length === 1 ? hookEntries[0]?.hook.expandaiProfileVersion : undefined
|
|
188
|
-
const hookCurrent = !hookPlan.changed && !denyPlan.changed
|
|
189
|
-
const hookLegacy = hookCurrent
|
|
190
|
-
? []
|
|
191
|
-
: hookEntries.map(({ entry, hook }) =>
|
|
192
|
-
entry.matcher === WEBFETCH_MATCHER && hook.command === hookCommandFor(cli)
|
|
193
|
-
? 'unversioned'
|
|
194
|
-
: hookLegacyVariant(hook.command),
|
|
195
|
-
)
|
|
196
|
-
|
|
197
168
|
const plannedSkill = planClaudeSkill(loaded.skillText, loaded.bundledSkillText, CLAUDE_PROFILE_VERSION)
|
|
198
169
|
const currentSkillVersion = skillVersion(loaded.skillText)
|
|
199
170
|
const skillCurrent = loaded.skillText !== undefined && !plannedSkill.changed
|
|
@@ -201,7 +172,6 @@ function makeInspection(scope: InitScope, loaded: LoadedClaudeProfile, cli: CliC
|
|
|
201
172
|
const mcpValue = loaded.mcp.mcpServers[SERVER_KEY]
|
|
202
173
|
const mcpCurrent = !planClaudeMcp(loaded.mcp, cli).changed
|
|
203
174
|
const components: InitComponent[] = []
|
|
204
|
-
if (hookCurrent) components.push('webfetch-hook')
|
|
205
175
|
if (skillCurrent) components.push('runtime-skill')
|
|
206
176
|
if (mcpCurrent) components.push('mcp')
|
|
207
177
|
|
|
@@ -211,12 +181,9 @@ function makeInspection(scope: InitScope, loaded: LoadedClaudeProfile, cli: CliC
|
|
|
211
181
|
return {
|
|
212
182
|
scope,
|
|
213
183
|
targets: loaded.targets,
|
|
214
|
-
hook
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
legacyVariants: hookLegacy,
|
|
218
|
-
...(hookVersion === undefined ? {} : { version: hookVersion }),
|
|
219
|
-
},
|
|
184
|
+
// Claude Code has no hook component. Reported as absent-and-current so a
|
|
185
|
+
// caller that still reads this field cannot conclude something is missing.
|
|
186
|
+
hook: { current: true, installed: false, legacyVariants: [] },
|
|
220
187
|
skill: {
|
|
221
188
|
current: skillCurrent,
|
|
222
189
|
installed: loaded.skillText !== undefined,
|
|
@@ -229,16 +196,15 @@ function makeInspection(scope: InitScope, loaded: LoadedClaudeProfile, cli: CliC
|
|
|
229
196
|
legacyVariants: mcpCurrent ? [] : mcpLegacyVariant(mcpValue),
|
|
230
197
|
},
|
|
231
198
|
components,
|
|
232
|
-
configured: components.length ===
|
|
199
|
+
configured: components.length === 2,
|
|
233
200
|
profileVersion: CLAUDE_PROFILE_VERSION,
|
|
234
201
|
componentVersions: {
|
|
235
|
-
...(hookVersion === undefined ? {} : { hook: hookVersion }),
|
|
236
202
|
...(currentSkillVersion === undefined ? {} : { skill: currentSkillVersion }),
|
|
237
203
|
...(mcpCurrent ? { mcp: CLAUDE_PROFILE_VERSION } : {}),
|
|
238
204
|
},
|
|
239
205
|
policyBlocks: [...new Set(policyBlocks)].sort(),
|
|
240
206
|
remotePolicyMayApply: true,
|
|
241
|
-
reloadMayBeRequired: !
|
|
207
|
+
reloadMayBeRequired: !mcpCurrent,
|
|
242
208
|
consentMayBeRequired: scope === 'project',
|
|
243
209
|
}
|
|
244
210
|
}
|
|
@@ -383,7 +349,10 @@ const makeClaudeProfile = Effect.gen(function* () {
|
|
|
383
349
|
),
|
|
384
350
|
),
|
|
385
351
|
)
|
|
386
|
-
|
|
352
|
+
// Only the files this profile writes. `settings.json` is left alone, so its
|
|
353
|
+
// being read-only says nothing about whether setup can succeed — checking it
|
|
354
|
+
// would refuse an install that never touches it.
|
|
355
|
+
const readOnlyTargets = yield* Effect.filter([targets.skillFile, targets.mcpFile], (target) =>
|
|
387
356
|
writableAncestor(target).pipe(Effect.map((writable) => !writable)),
|
|
388
357
|
)
|
|
389
358
|
|
|
@@ -409,33 +378,23 @@ const makeClaudeProfile = Effect.gen(function* () {
|
|
|
409
378
|
|
|
410
379
|
const plan = Effect.fn('ClaudeProfile.plan')(function* (scope: InitScope) {
|
|
411
380
|
const loaded = yield* load(scope)
|
|
412
|
-
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
|
|
416
|
-
const settingsChanged = hookPlan.changed || denyPlan.changed
|
|
381
|
+
// The Claude Code profile is the MCP server plus the runtime skill, and nothing
|
|
382
|
+
// else. `settings.json` is not written at all: no `permissions` entry, no
|
|
383
|
+
// PreToolUse hook. Both of those can disable a built-in tool, and a install
|
|
384
|
+
// that can disable a built-in tool can also fail leaving it disabled.
|
|
417
385
|
const skillPlan = planClaudeSkill(loaded.skillText, loaded.bundledSkillText, CLAUDE_PROFILE_VERSION)
|
|
418
386
|
const mcpPlan = planClaudeMcp(loaded.mcp, cli)
|
|
419
|
-
const settingsText = `${yield* Schema.encode(SettingsSchema)(denyPlan.settings)}\n`
|
|
420
387
|
const mcpText = `${yield* Schema.encode(McpServersConfigSchema)(mcpPlan.config)}\n`
|
|
421
388
|
const settingsDirectory = path.dirname(loaded.targets.settingsFile)
|
|
422
389
|
const skillsDirectory = path.dirname(loaded.targets.skillDirectory)
|
|
423
390
|
const operations: MutationOperation[] = []
|
|
424
|
-
if (
|
|
391
|
+
if (skillPlan.changed) {
|
|
425
392
|
operations.push({
|
|
426
393
|
type: 'mkdir',
|
|
427
394
|
path: settingsDirectory,
|
|
428
395
|
expected: yield* expectedDirectory(settingsDirectory),
|
|
429
396
|
})
|
|
430
397
|
}
|
|
431
|
-
if (settingsChanged) {
|
|
432
|
-
operations.push({
|
|
433
|
-
type: 'write',
|
|
434
|
-
path: loaded.targets.settingsFile,
|
|
435
|
-
bytes: textEncoder.encode(settingsText),
|
|
436
|
-
expected: loaded.settingsExpected,
|
|
437
|
-
})
|
|
438
|
-
}
|
|
439
398
|
if (skillPlan.changed) {
|
|
440
399
|
operations.push({
|
|
441
400
|
type: 'mkdir',
|
|
@@ -471,8 +430,6 @@ const makeClaudeProfile = Effect.gen(function* () {
|
|
|
471
430
|
scope,
|
|
472
431
|
{
|
|
473
432
|
...inspectedLoaded,
|
|
474
|
-
settings: denyPlan.settings,
|
|
475
|
-
settingsText,
|
|
476
433
|
skillText: skillPlan.text,
|
|
477
434
|
mcp: mcpPlan.config,
|
|
478
435
|
mcpText,
|
|
@@ -480,7 +437,7 @@ const makeClaudeProfile = Effect.gen(function* () {
|
|
|
480
437
|
cli,
|
|
481
438
|
)
|
|
482
439
|
return {
|
|
483
|
-
changed:
|
|
440
|
+
changed: skillPlan.changed || mcpPlan.changed,
|
|
484
441
|
inspection,
|
|
485
442
|
resultInspection,
|
|
486
443
|
operations,
|
package/src/Init/Service.ts
CHANGED
|
@@ -200,7 +200,7 @@ function isCompleteHarnessInspection(
|
|
|
200
200
|
if (inspection.integrationVersion === undefined || inspection.integrationVersion.trim().length === 0) return false
|
|
201
201
|
if (inspection.nextAction.trim().length === 0) return false
|
|
202
202
|
const components = new Set(inspection.components)
|
|
203
|
-
const expectsHook = harness === '
|
|
203
|
+
const expectsHook = harness === 'opencode'
|
|
204
204
|
return (
|
|
205
205
|
components.size === inspection.components.length &&
|
|
206
206
|
components.has('runtime-skill') &&
|
package/src/Init/Status.ts
CHANGED
|
@@ -152,7 +152,7 @@ function validateInspection(
|
|
|
152
152
|
// Only the harnesses with a web-fetch hook install three components; cursor
|
|
153
153
|
// and codex are complete with two. Demanding three of them reported a
|
|
154
154
|
// correctly configured machine as an invalid result.
|
|
155
|
-
const expectsHook = harness === '
|
|
155
|
+
const expectsHook = harness === 'opencode'
|
|
156
156
|
const allComponents =
|
|
157
157
|
componentSet.has('runtime-skill') &&
|
|
158
158
|
componentSet.has('mcp') &&
|