@ahpd/agent-claude 0.1.0
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/LICENSE +21 -0
- package/README.md +56 -0
- package/dist/catalog.d.ts +19 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +28 -0
- package/dist/catalog.js.map +1 -0
- package/dist/claude.d.ts +28 -0
- package/dist/claude.d.ts.map +1 -0
- package/dist/claude.js +311 -0
- package/dist/claude.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp.d.ts +31 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +78 -0
- package/dist/mcp.js.map +1 -0
- package/dist/probe.d.ts +3 -0
- package/dist/probe.d.ts.map +1 -0
- package/dist/probe.js +102 -0
- package/dist/probe.js.map +1 -0
- package/dist/session.d.ts +70 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +2582 -0
- package/dist/session.js.map +1 -0
- package/dist/transcript.d.ts +11 -0
- package/dist/transcript.d.ts.map +1 -0
- package/dist/transcript.js +214 -0
- package/dist/transcript.js.map +1 -0
- package/package.json +64 -0
- package/src/catalog.ts +29 -0
- package/src/claude.ts +353 -0
- package/src/index.ts +21 -0
- package/src/mcp.ts +75 -0
- package/src/probe.ts +104 -0
- package/src/session.ts +2635 -0
- package/src/transcript.ts +217 -0
package/src/claude.ts
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { probe } from './probe.js';
|
|
2
|
+
import { serversFor } from './mcp.js';
|
|
3
|
+
import { createSession, EFFORT_LABELS, EFFORTS } from './session.js';
|
|
4
|
+
import { turnsOf } from './transcript.js';
|
|
5
|
+
import { catalogue } from './catalog.js';
|
|
6
|
+
import { within } from '@ahpd/server';
|
|
7
|
+
import type { Agent, Bag, Start } from '@ahpd/server';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The resource a token for this backend is for.
|
|
11
|
+
*
|
|
12
|
+
* Named once, because it is the identifier a client must send back verbatim:
|
|
13
|
+
* the protocol says `authenticate`'s `resource` MUST match one the server
|
|
14
|
+
* advertised, so this string appearing twice with a typo between them is a
|
|
15
|
+
* token nothing will accept.
|
|
16
|
+
*/
|
|
17
|
+
const ANTHROPIC = 'https://api.anthropic.com';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Claude Code, as an agent backend.
|
|
21
|
+
*
|
|
22
|
+
* Everything the host would otherwise have to know about one particular
|
|
23
|
+
* harness: which settings it takes, where its past sessions are kept, and how
|
|
24
|
+
* to start one. The host asks through `Agent` and imports none of this.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** How to build the Claude backend. */
|
|
28
|
+
export interface ClaudeOptions {
|
|
29
|
+
/**
|
|
30
|
+
* The directories it will work in, and the ones it lists.
|
|
31
|
+
*
|
|
32
|
+
* The first is where a session goes when the client names none. A client
|
|
33
|
+
* may name any of the others and nothing else: a host that ran the agent
|
|
34
|
+
* wherever it was told is one anybody who can reach the port can point at
|
|
35
|
+
* any directory on the machine.
|
|
36
|
+
*
|
|
37
|
+
* This is also the catalogue's scope, so a directory left out is one whose
|
|
38
|
+
* sessions are neither listed nor openable.
|
|
39
|
+
*/
|
|
40
|
+
paths: string[];
|
|
41
|
+
/** The id clients name. `claude` unless something else already is. */
|
|
42
|
+
provider?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Claude Code on one or more directories, ready to be handed to `createHost`. */
|
|
46
|
+
export function claude(options: ClaudeOptions): Agent {
|
|
47
|
+
const dirs = options.paths;
|
|
48
|
+
const dir = dirs[0];
|
|
49
|
+
if (dir === undefined)
|
|
50
|
+
throw new Error('claude() needs at least one directory to work in.');
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Which directory a session goes in.
|
|
54
|
+
*
|
|
55
|
+
* Named, or the first. Anything else is refused rather than quietly
|
|
56
|
+
* replaced - a directory accepted and then ignored is a session running
|
|
57
|
+
* somewhere nobody asked for, with nothing on screen to say so.
|
|
58
|
+
*/
|
|
59
|
+
const workingDirectory = (asked?: string): string => {
|
|
60
|
+
if (asked === undefined)
|
|
61
|
+
return dir;
|
|
62
|
+
/*
|
|
63
|
+
* Under a served directory, not equal to one.
|
|
64
|
+
*
|
|
65
|
+
* This compared for equality, so a host told to serve `/home/you` served
|
|
66
|
+
* that one directory and refused every project inside it - which is the
|
|
67
|
+
* only kind of directory anybody opens. An editor asks for its workspace
|
|
68
|
+
* folder, and that is never the path somebody passed to `--path`.
|
|
69
|
+
*/
|
|
70
|
+
if (!dirs.some((served) => within(served, asked))) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`This host does not serve ${asked}. It serves ${dirs.join(', ')}.`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
// What was asked for, not the root it sits under: the session runs where
|
|
76
|
+
// the client said.
|
|
77
|
+
return asked;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/*
|
|
81
|
+
* What the probe learned about output styles.
|
|
82
|
+
*
|
|
83
|
+
* The schema is otherwise fixed, but this one property's choices belong to
|
|
84
|
+
* the harness rather than to the protocol - a person's own styles live in
|
|
85
|
+
* their settings - so it is learned once at startup, the way models are,
|
|
86
|
+
* and the control is simply absent until it is known.
|
|
87
|
+
*/
|
|
88
|
+
let styles: string[] = [];
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Whether the models this harness offers carry effort controls of their own.
|
|
92
|
+
*
|
|
93
|
+
* A model's `configSchema` and the session-wide `effortLevel` reach the same
|
|
94
|
+
* setting, and a client draws both - so a person is shown two effort
|
|
95
|
+
* controls, on two different values, for one thing. The model's is the
|
|
96
|
+
* truthful one: it lists what that model actually supports, where the
|
|
97
|
+
* session-wide key lists all five whatever is chosen. So this backend offers
|
|
98
|
+
* the session key only while there is nothing better.
|
|
99
|
+
*/
|
|
100
|
+
let perModelEffort = false;
|
|
101
|
+
let style: string | undefined;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* What a session can be told to do differently.
|
|
105
|
+
*
|
|
106
|
+
* One schema, used by `resolveSessionConfig` (before a session exists) and
|
|
107
|
+
* by every session's own state (after one does). Two copies would drift, and
|
|
108
|
+
* the composer would offer one set of controls on the new-session screen and
|
|
109
|
+
* a different set the moment a session opened.
|
|
110
|
+
*
|
|
111
|
+
* `sessionMutable` is what each row turns on: the permission mode, the model
|
|
112
|
+
* and the effort level are things the CLI takes on a *running* session.
|
|
113
|
+
* `thinking` is fixed when the query is built, so offering it live would be
|
|
114
|
+
* a switch that flips back.
|
|
115
|
+
*/
|
|
116
|
+
const schema = (): Bag => ({
|
|
117
|
+
// A JSON Schema object, and it has to say so: `type` is required, and a
|
|
118
|
+
// schema without it matches nothing a client validates.
|
|
119
|
+
type: 'object',
|
|
120
|
+
properties: {
|
|
121
|
+
/*
|
|
122
|
+
* One axis, and the CLI's own five values.
|
|
123
|
+
*
|
|
124
|
+
* The protocol's config schema is generic and a backend advertises what
|
|
125
|
+
* it has - VS Code's own hosts advertise different properties for
|
|
126
|
+
* Copilot and for Claude, and a client draws whatever it is given. Its
|
|
127
|
+
* Claude host says why in as many words: it collapses the platform's
|
|
128
|
+
* `autoApprove` x `mode` two-axis surface onto one `permissionMode`
|
|
129
|
+
* matching the SDK's enum, and *omits* `autoApprove`, `mode`,
|
|
130
|
+
* `isolation` and `branch` deliberately, because the pickers key off
|
|
131
|
+
* property names and omitting them suppresses a mode and branch UI that
|
|
132
|
+
* would not mean anything here.
|
|
133
|
+
*
|
|
134
|
+
* The wording is that host's too, so one session reads the same however
|
|
135
|
+
* it is opened. `auto` was missing here and is a real mode the CLI
|
|
136
|
+
* takes: the agent deciding, per call, whether it needs to ask.
|
|
137
|
+
*/
|
|
138
|
+
permissionMode: {
|
|
139
|
+
scope: 'session',
|
|
140
|
+
type: 'string',
|
|
141
|
+
title: 'Approvals',
|
|
142
|
+
description: 'How the agent handles tool approvals.',
|
|
143
|
+
enum: ['default', 'acceptEdits', 'plan', 'auto', 'bypassPermissions'],
|
|
144
|
+
enumLabels: [
|
|
145
|
+
'Ask Before Edits',
|
|
146
|
+
'Edit Automatically',
|
|
147
|
+
'Plan Mode',
|
|
148
|
+
'Auto Mode',
|
|
149
|
+
'Bypass Permissions',
|
|
150
|
+
],
|
|
151
|
+
enumDescriptions: [
|
|
152
|
+
'Asks before editing files.',
|
|
153
|
+
'Edits files without asking, and asks before using other tools.',
|
|
154
|
+
'Creates a plan before making changes.',
|
|
155
|
+
'Decides whether to ask for each tool operation.',
|
|
156
|
+
'Runs all tools without asking.',
|
|
157
|
+
],
|
|
158
|
+
default: 'default',
|
|
159
|
+
sessionMutable: true,
|
|
160
|
+
},
|
|
161
|
+
/*
|
|
162
|
+
* The model is not a config property.
|
|
163
|
+
*
|
|
164
|
+
* A session has no model; each message has one. The choices are carried
|
|
165
|
+
* on the agent (`RootState.agents[].models`) and the choice on the turn.
|
|
166
|
+
* `session/configChanged` with a `model` key is still honoured, but the
|
|
167
|
+
* model is not advertised here as a control of its own.
|
|
168
|
+
*/
|
|
169
|
+
...(perModelEffort ? {} : {
|
|
170
|
+
effortLevel: {
|
|
171
|
+
scope: 'chat',
|
|
172
|
+
type: 'string',
|
|
173
|
+
title: 'Effort',
|
|
174
|
+
description: 'How hard it thinks before answering.',
|
|
175
|
+
enum: [...EFFORTS],
|
|
176
|
+
enumLabels: EFFORTS.map((one) => EFFORT_LABELS[one]),
|
|
177
|
+
default: 'high',
|
|
178
|
+
sessionMutable: true,
|
|
179
|
+
},
|
|
180
|
+
}),
|
|
181
|
+
// Learned, so absent until the probe has answered and absent for good
|
|
182
|
+
// on a harness that has no styles.
|
|
183
|
+
...(styles.length > 0
|
|
184
|
+
? {
|
|
185
|
+
outputStyle: {
|
|
186
|
+
scope: 'session',
|
|
187
|
+
type: 'string',
|
|
188
|
+
title: 'Output style',
|
|
189
|
+
description: 'The voice it answers in.',
|
|
190
|
+
enum: styles,
|
|
191
|
+
enumLabels: styles.map((name) => name.charAt(0).toUpperCase() + name.slice(1)),
|
|
192
|
+
...(style !== undefined ? { default: style } : {}),
|
|
193
|
+
sessionMutable: true,
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
: {}),
|
|
197
|
+
thinking: {
|
|
198
|
+
type: 'string',
|
|
199
|
+
title: 'Thinking',
|
|
200
|
+
description: 'Fixed when the session is created.',
|
|
201
|
+
enum: ['adaptive', 'disabled'],
|
|
202
|
+
enumLabels: ['Adaptive', 'Off'],
|
|
203
|
+
enumDescriptions: ['The agent decides when to think', 'No extended thinking'],
|
|
204
|
+
default: 'adaptive',
|
|
205
|
+
sessionMutable: false,
|
|
206
|
+
},
|
|
207
|
+
/*
|
|
208
|
+
* Per-tool allow and deny, which is the slope the mode above is a cliff.
|
|
209
|
+
*
|
|
210
|
+
* A platform key rather than one of this backend's invention: it is what
|
|
211
|
+
* the reference client's permission picker writes when somebody approves
|
|
212
|
+
* a tool "in this session", and its own Claude host advertises it
|
|
213
|
+
* unchanged because the SDK takes `allowedTools` / `disallowedTools`
|
|
214
|
+
* natively. Without it the only way to stop being asked about the one
|
|
215
|
+
* command you trust is `bypassPermissions`, which stops asking about
|
|
216
|
+
* everything.
|
|
217
|
+
*
|
|
218
|
+
* An object, and the first config value here that is not a string. The
|
|
219
|
+
* protocol declares the bag `Record<string, unknown>`; this host used to
|
|
220
|
+
* declare it `Record<string, string>`, which is why nothing of this
|
|
221
|
+
* shape could be carried at all.
|
|
222
|
+
*/
|
|
223
|
+
permissions: {
|
|
224
|
+
scope: 'session',
|
|
225
|
+
type: 'object',
|
|
226
|
+
title: 'Permissions',
|
|
227
|
+
description: 'Per-tool session permissions. Updated when a tool is approved for this session.',
|
|
228
|
+
properties: {
|
|
229
|
+
allow: { type: 'array', title: 'Allowed tools', items: { type: 'string', title: 'Tool name' } },
|
|
230
|
+
deny: { type: 'array', title: 'Denied tools', items: { type: 'string', title: 'Tool name' } },
|
|
231
|
+
},
|
|
232
|
+
default: { allow: [], deny: [] },
|
|
233
|
+
// Unlike `thinking`, this one really can move on a running session:
|
|
234
|
+
// the SDK takes the lists when the query is built, and `canUseTool`
|
|
235
|
+
// is where this host already sits between the agent and the person.
|
|
236
|
+
sessionMutable: true,
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const defaults = (): Record<string, unknown> => ({
|
|
242
|
+
permissionMode: 'default',
|
|
243
|
+
// Beside its schema or not at all: a value with no property to draw it is
|
|
244
|
+
// a control a client cannot show and cannot change.
|
|
245
|
+
...(perModelEffort ? {} : { effortLevel: 'high' }),
|
|
246
|
+
thinking: 'adaptive',
|
|
247
|
+
permissions: { allow: [], deny: [] },
|
|
248
|
+
...(style !== undefined ? { outputStyle: style } : {}),
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
provider: options.provider ?? 'claude',
|
|
253
|
+
displayName: 'Claude Code',
|
|
254
|
+
// Both, because the SDK resumes at a named prompt: `resumeSessionAt` with
|
|
255
|
+
// `forkSession` continues from a turn under a new id, and a side chat is
|
|
256
|
+
// an unresumed session handed what that turn said.
|
|
257
|
+
chats: { fork: true, sideChat: true },
|
|
258
|
+
// The SDK takes `additionalDirectories` at startup, so a session works in
|
|
259
|
+
// as many as it was given; the first is the process root and is fixed.
|
|
260
|
+
multipleDirectories: true,
|
|
261
|
+
description: `The Claude Agent SDK, on ${dirs.join(', ')}`,
|
|
262
|
+
schema,
|
|
263
|
+
defaults,
|
|
264
|
+
|
|
265
|
+
directories: () => [...dirs],
|
|
266
|
+
|
|
267
|
+
// The styles are kept as well as handed on: `schema()` is asked before any
|
|
268
|
+
// session exists, and it can only offer what has already been learned.
|
|
269
|
+
probe: async () => {
|
|
270
|
+
const offered = await probe(dir);
|
|
271
|
+
styles = offered.outputStyles ?? [];
|
|
272
|
+
style = offered.outputStyle;
|
|
273
|
+
perModelEffort = offered.models.some((model) => model.configSchema !== undefined);
|
|
274
|
+
return offered;
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
// Every directory it serves, as one list. A session is listed by the
|
|
278
|
+
// catalogue of the directory it ran in, and a host serving several has
|
|
279
|
+
// one catalogue.
|
|
280
|
+
list: async () => (await Promise.all(dirs.map((served) => catalogue(served)))).flat(),
|
|
281
|
+
|
|
282
|
+
// Whichever directory holds it. The transcript reader wants the one the
|
|
283
|
+
// session ran in, and only its own catalogue knows which that was.
|
|
284
|
+
transcript: async (id) => {
|
|
285
|
+
for (const served of dirs) {
|
|
286
|
+
const rows = await catalogue(served).catch(() => []);
|
|
287
|
+
if (rows.some((row) => row.id === id))
|
|
288
|
+
return turnsOf(id, served);
|
|
289
|
+
}
|
|
290
|
+
return undefined;
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
/*
|
|
294
|
+
* The one resource this backend can be given a token for.
|
|
295
|
+
*
|
|
296
|
+
* `required: false`, and that is the honest declaration rather than the
|
|
297
|
+
* lenient one: this daemon runs as whoever started it and inherits their
|
|
298
|
+
* `claude login` or `ANTHROPIC_API_KEY`, so it works with nothing pushed
|
|
299
|
+
* at all. Saying `required: true` would refuse clients that would
|
|
300
|
+
* otherwise be perfectly able to open a session.
|
|
301
|
+
*/
|
|
302
|
+
protectedResources: [{
|
|
303
|
+
resource: ANTHROPIC,
|
|
304
|
+
resource_name: 'Anthropic API',
|
|
305
|
+
authorization_servers: ['https://console.anthropic.com'],
|
|
306
|
+
required: false,
|
|
307
|
+
}],
|
|
308
|
+
|
|
309
|
+
create: (start: Start) => createSession({
|
|
310
|
+
uri: start.uri,
|
|
311
|
+
chatUri: start.chatUri,
|
|
312
|
+
cwd: workingDirectory(start.workingDirectory),
|
|
313
|
+
/*
|
|
314
|
+
* The MCP servers, declared by this host rather than found by the CLI.
|
|
315
|
+
*
|
|
316
|
+
* The same files the CLI reads - `.mcp.json` beside the project and
|
|
317
|
+
* `mcpServers` in `~/.claude.json` - handed to the SDK so they are
|
|
318
|
+
* *its* servers. That is what makes a token a client signed in with
|
|
319
|
+
* applicable: `setMcpServers` re-declares only what the SDK was given.
|
|
320
|
+
*/
|
|
321
|
+
mcpServers: serversFor([workingDirectory(start.workingDirectory), ...(start.additional ?? [])]),
|
|
322
|
+
// Each one checked the way the first is: a directory this host does not
|
|
323
|
+
// serve is not one an agent may be pointed at, however it arrived.
|
|
324
|
+
...(start.additional && start.additional.length > 0
|
|
325
|
+
? { additional: start.additional.map((one) => workingDirectory(one)) }
|
|
326
|
+
: {}),
|
|
327
|
+
// The host's own tools, offered to the model beside this backend's.
|
|
328
|
+
...(start.tools && start.tools.length > 0 ? { tools: start.tools } : {}),
|
|
329
|
+
settings: start.settings,
|
|
330
|
+
schema: start.schema,
|
|
331
|
+
emit: start.emit,
|
|
332
|
+
...(start.seedCustomizations ? { seedCustomizations: start.seedCustomizations } : {}),
|
|
333
|
+
...(start.resume !== undefined ? { resume: start.resume } : {}),
|
|
334
|
+
...(start.forkAt !== undefined ? { forkAt: start.forkAt } : {}),
|
|
335
|
+
...(start.rewindAt !== undefined ? { rewindAt: start.rewindAt } : {}),
|
|
336
|
+
...(start.context !== undefined ? { context: start.context } : {}),
|
|
337
|
+
...(start.seed ? { seed: start.seed } : {}),
|
|
338
|
+
...(start.onFileEdit ? { onFileEdit: start.onFileEdit } : {}),
|
|
339
|
+
...(start.onHandshake ? { onHandshake: start.onHandshake } : {}),
|
|
340
|
+
/*
|
|
341
|
+
* A pushed token, as the variable the CLI reads.
|
|
342
|
+
*
|
|
343
|
+
* Which variable that is, is this file's business and not the host's:
|
|
344
|
+
* the host knows a token belongs to `https://api.anthropic.com` and
|
|
345
|
+
* stops there, which is what keeps `createHost` the protocol and
|
|
346
|
+
* nothing else.
|
|
347
|
+
*/
|
|
348
|
+
...(start.credentials?.[ANTHROPIC]
|
|
349
|
+
? { env: { ANTHROPIC_API_KEY: start.credentials[ANTHROPIC] } }
|
|
350
|
+
: {}),
|
|
351
|
+
}),
|
|
352
|
+
};
|
|
353
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Claude backend, as an `Agent` this host can be handed.
|
|
3
|
+
*
|
|
4
|
+
* One implementation of the contract `@ahpd/server` declares, and the only
|
|
5
|
+
* thing in either package that knows what the Claude harness is: it starts the
|
|
6
|
+
* agent SDK, translates its message stream into the state actions the protocol
|
|
7
|
+
* describes, and reads a transcript somebody else's session left on disk.
|
|
8
|
+
*
|
|
9
|
+
* A host that wants a different harness registers a different `Agent` and
|
|
10
|
+
* never loads this. A host that wants both registers both - `createHost` takes
|
|
11
|
+
* a list, and cannot tell one from another.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export { catalogue } from './catalog.js';
|
|
15
|
+
export { claude } from './claude.js';
|
|
16
|
+
export type { ClaudeOptions } from './claude.js';
|
|
17
|
+
export { createSession, EFFORTS, EFFORT_LABELS } from './session.js';
|
|
18
|
+
export type { Published } from './session.js';
|
|
19
|
+
export { probe } from './probe.js';
|
|
20
|
+
export { turnsOf } from './transcript.js';
|
|
21
|
+
export { protectedResource, urlOf } from './mcp.js';
|
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** The MCP servers a session runs with, read from the files the CLI reads. */
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import type { Bag } from '@ahpd/server';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Every MCP server configured for a directory, by name.
|
|
10
|
+
*
|
|
11
|
+
* The CLI finds these itself and this host normally leaves it to. It reads
|
|
12
|
+
* them here for one reason: a server the *SDK* was given is one
|
|
13
|
+
* `setMcpServers` can re-declare, and a server that came from a settings file
|
|
14
|
+
* is not - so a token a client signs in with has nowhere to go unless this
|
|
15
|
+
* host owns the declaration. The files are the CLI's own: `.mcp.json` beside
|
|
16
|
+
* the project, and `mcpServers` in `~/.claude.json`.
|
|
17
|
+
*
|
|
18
|
+
* A file that is missing, unreadable or not JSON contributes nothing. This is
|
|
19
|
+
* a best effort by design: the CLI still reads the same files, and a server
|
|
20
|
+
* this misses is a server that works exactly as it did before.
|
|
21
|
+
*/
|
|
22
|
+
export function serversFor(directories: string[]): Record<string, Bag> {
|
|
23
|
+
const found: Record<string, Bag> = {};
|
|
24
|
+
const files = [
|
|
25
|
+
join(homedir(), '.claude.json'),
|
|
26
|
+
...directories.map((dir) => join(dir, '.mcp.json')),
|
|
27
|
+
];
|
|
28
|
+
for (const file of files) {
|
|
29
|
+
let held: unknown;
|
|
30
|
+
// Synchronous on purpose: these are two small files read once when a
|
|
31
|
+
// session is created, and a backend's `create` answers with a session
|
|
32
|
+
// rather than a promise of one.
|
|
33
|
+
try { held = JSON.parse(readFileSync(file, 'utf8')) as unknown; }
|
|
34
|
+
catch { continue; }
|
|
35
|
+
const bag = typeof held === 'object' && held !== null ? held as Bag : {};
|
|
36
|
+
const servers = typeof bag.mcpServers === 'object' && bag.mcpServers !== null ? bag.mcpServers as Bag : {};
|
|
37
|
+
for (const [name, config] of Object.entries(servers)) {
|
|
38
|
+
// Later wins: a project's own `.mcp.json` is nearer than the home file,
|
|
39
|
+
// which is the order the CLI resolves them in.
|
|
40
|
+
if (typeof config === 'object' && config !== null) found[name] = config as Bag;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return found;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The URL an http or sse server lives at, or nothing for a stdio one. */
|
|
47
|
+
export const urlOf = (config: unknown): string | undefined => {
|
|
48
|
+
const bag = typeof config === 'object' && config !== null ? config as Bag : {};
|
|
49
|
+
const url = typeof bag.url === 'string' ? bag.url : undefined;
|
|
50
|
+
if (url === undefined) return undefined;
|
|
51
|
+
const kind = typeof bag.type === 'string' ? bag.type : 'http';
|
|
52
|
+
return kind === 'http' || kind === 'sse' ? url : undefined;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What an MCP server says about signing into it, per RFC 9728.
|
|
57
|
+
*
|
|
58
|
+
* `<url>/.well-known/oauth-protected-resource`, which is where an OAuth
|
|
59
|
+
* protected resource publishes its metadata and where a client looks to find
|
|
60
|
+
* the authorization server. Answers the discovered document, or a bare one
|
|
61
|
+
* naming the server itself: the URL *is* the canonical resource identifier,
|
|
62
|
+
* so an incomplete answer is still a true one - what a client loses is the
|
|
63
|
+
* one-click sign-in, not the knowledge that it needs to sign in.
|
|
64
|
+
*/
|
|
65
|
+
export async function protectedResource(url: string, name: string, ms = 15_000): Promise<Bag> {
|
|
66
|
+
const bare: Bag = { resource: url, resource_name: name };
|
|
67
|
+
const found = new URL(url);
|
|
68
|
+
const at = `${found.origin}/.well-known/oauth-protected-resource${found.pathname.replace(/\/+$/, '')}`;
|
|
69
|
+
const said = await fetch(at, { signal: AbortSignal.timeout(ms) })
|
|
70
|
+
.then((answer) => (answer.ok ? answer.json() as Promise<unknown> : undefined))
|
|
71
|
+
.catch(() => undefined);
|
|
72
|
+
if (typeof said !== 'object' || said === null) return bare;
|
|
73
|
+
const metadata = said as Bag;
|
|
74
|
+
return typeof metadata.resource === 'string' ? metadata : bare;
|
|
75
|
+
}
|
package/src/probe.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
2
|
+
import { customizationsOf, EFFORT_LABELS, EFFORTS } from './session.js';
|
|
3
|
+
import type { Bag, Offered } from '@ahpd/server';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reads what the agent backend offers, once, without creating a session.
|
|
7
|
+
*
|
|
8
|
+
* Clients ask `resolveSessionConfig` before creating anything, so the models
|
|
9
|
+
* and commands have to be known before any session exists. This starts one
|
|
10
|
+
* short-lived agent process at startup, asks it over the control protocol,
|
|
11
|
+
* and closes it. No prompt is sent and no transcript is written.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const bag = (value: unknown): Bag => (typeof value === 'object' && value !== null ? value as Bag : {});
|
|
15
|
+
const list = (value: unknown): unknown[] => (Array.isArray(value) ? value : []);
|
|
16
|
+
const str = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* How hard *this* model can be told to think, as its own config schema.
|
|
20
|
+
*
|
|
21
|
+
* Per model and not per session, which is the whole point: the CLI reports a
|
|
22
|
+
* different `supportedEffortLevels` for each - some take all five, some take
|
|
23
|
+
* one, and some take none - so a single session-wide effort control offers
|
|
24
|
+
* levels the chosen model may not have, and accepts one it will then ignore.
|
|
25
|
+
* A model that supports none gets no schema and a client draws no control,
|
|
26
|
+
* which is the honest form of "this one does not think harder on request".
|
|
27
|
+
*
|
|
28
|
+
* `thinkingLevel` is the key, because that is the one the reference client's
|
|
29
|
+
* picker writes into `ModelSelection.config` for both of its providers.
|
|
30
|
+
*/
|
|
31
|
+
const thinkingFor = (efforts: string[]): { configSchema?: Record<string, unknown> } => {
|
|
32
|
+
if (efforts.length === 0) return {};
|
|
33
|
+
return {
|
|
34
|
+
configSchema: {
|
|
35
|
+
type: 'object',
|
|
36
|
+
properties: {
|
|
37
|
+
thinkingLevel: {
|
|
38
|
+
type: 'string',
|
|
39
|
+
title: 'Thinking Level',
|
|
40
|
+
description: 'Controls how much reasoning effort Claude uses.',
|
|
41
|
+
enum: [...efforts],
|
|
42
|
+
enumLabels: efforts.map((one) => EFFORT_LABELS[one as typeof EFFORTS[number]] ?? one),
|
|
43
|
+
...(efforts.includes('high') ? { default: 'high' } : {}),
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export async function probe(cwd: string): Promise<Offered> {
|
|
51
|
+
// A prompt that never yields. The query needs one to exist; it does not need
|
|
52
|
+
// one to answer what it can do.
|
|
53
|
+
async function* silence(): AsyncGenerator<never> {
|
|
54
|
+
await new Promise<void>(() => {});
|
|
55
|
+
// eslint-disable-next-line no-unreachable
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const handle = query({ prompt: silence(), options: { cwd } } as Parameters<typeof query>[0]);
|
|
60
|
+
try {
|
|
61
|
+
const [init, mcp, skills] = await Promise.all([
|
|
62
|
+
handle.initializationResult().then((answer) => bag(answer as unknown)),
|
|
63
|
+
// Best effort beside the one that matters: a harness with no MCP servers
|
|
64
|
+
// and one that will not say are the same empty list here, and neither is
|
|
65
|
+
// worth failing the probe over.
|
|
66
|
+
handle.mcpServerStatus().then((answer) => (Array.isArray(answer) ? answer : [])).catch(() => [] as unknown[]),
|
|
67
|
+
handle.reloadSkills().then((answer) => list(bag(answer as unknown).skills)).catch(() => [] as unknown[]),
|
|
68
|
+
]);
|
|
69
|
+
const styles = list(init.available_output_styles).filter((s): s is string => typeof s === 'string');
|
|
70
|
+
return {
|
|
71
|
+
customizations: customizationsOf(init, mcp, skills),
|
|
72
|
+
// Only when the harness has them. An empty list would draw a picker
|
|
73
|
+
// with nothing in it, which is worse than no control.
|
|
74
|
+
...(styles.length > 0 ? { outputStyles: styles } : {}),
|
|
75
|
+
...(str(init.output_style) ? { outputStyle: str(init.output_style) as string } : {}),
|
|
76
|
+
models: list(init.models)
|
|
77
|
+
// `value`, not `id`.
|
|
78
|
+
.map((raw) => {
|
|
79
|
+
const model = bag(raw);
|
|
80
|
+
return {
|
|
81
|
+
id: str(model.value) ?? '',
|
|
82
|
+
name: str(model.displayName) ?? str(model.value) ?? '',
|
|
83
|
+
...thinkingFor(list(model.supportedEffortLevels).filter((one): one is string => typeof one === 'string')),
|
|
84
|
+
};
|
|
85
|
+
})
|
|
86
|
+
.filter((model) => model.id !== ''),
|
|
87
|
+
commands: list(init.commands)
|
|
88
|
+
.map((raw) => {
|
|
89
|
+
const command = bag(raw);
|
|
90
|
+
return {
|
|
91
|
+
name: str(command.name) ?? '',
|
|
92
|
+
...(str(command.description) ? { description: str(command.description) as string } : {}),
|
|
93
|
+
...(str(command.argumentHint) ? { argumentHint: str(command.argumentHint) as string } : {}),
|
|
94
|
+
};
|
|
95
|
+
})
|
|
96
|
+
.filter((command) => command.name !== ''),
|
|
97
|
+
};
|
|
98
|
+
} catch {
|
|
99
|
+
// A harness that will not answer offers nothing, which is a real answer.
|
|
100
|
+
return { models: [], commands: [], customizations: [] };
|
|
101
|
+
} finally {
|
|
102
|
+
handle.close();
|
|
103
|
+
}
|
|
104
|
+
}
|