@kpritam/grimoire-adapter-spawn-agent 0.1.7
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/dist/.tsbuildinfo +1 -0
- package/dist/errorMap.d.ts +15 -0
- package/dist/errorMap.js +194 -0
- package/dist/eventFormatter.d.ts +64 -0
- package/dist/eventFormatter.js +262 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/layer.d.ts +21 -0
- package/dist/layer.js +131 -0
- package/dist/probe.d.ts +65 -0
- package/dist/probe.js +225 -0
- package/package.json +68 -0
- package/src/errorMap.ts +224 -0
- package/src/eventFormatter.ts +313 -0
- package/src/index.ts +14 -0
- package/src/layer.ts +189 -0
- package/src/probe.ts +272 -0
package/src/probe.ts
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import type { AgentId } from "@kpritam/grimoire-core"
|
|
3
|
+
import { ProviderUnavailable } from "@kpritam/grimoire-core"
|
|
4
|
+
import * as Effect from "effect/Effect"
|
|
5
|
+
import {
|
|
6
|
+
type AgentAdapter,
|
|
7
|
+
AgentNotInstalledError,
|
|
8
|
+
AgentUnauthenticatedError,
|
|
9
|
+
adapters,
|
|
10
|
+
detectAvailableAgents,
|
|
11
|
+
type SupportedAgentId
|
|
12
|
+
} from "spawn-agent"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build the spawn-agent adapter for a given provider id, applying
|
|
16
|
+
* provider-specific workarounds on top of the built-in defaults.
|
|
17
|
+
*
|
|
18
|
+
* Two classes of bug exist in spawn-agent v0.0.1:
|
|
19
|
+
*
|
|
20
|
+
* A) npm-resolution failure (copilot, gemini)
|
|
21
|
+
* The built-in adapter calls `resolvePackageBin("<pkg>")` in both
|
|
22
|
+
* `checkInstalled` and `resolve`. That traverses the local
|
|
23
|
+
* `node_modules` chain, which works for shim packages that are
|
|
24
|
+
* spawn-agent dependencies (`@agentclientprotocol/claude-agent-acp`,
|
|
25
|
+
* `@zed-industries/codex-acp`) but silently breaks for external CLIs
|
|
26
|
+
* that are distributed as npm but installed globally
|
|
27
|
+
* (`@github/copilot`, `@google/gemini-cli`). The fix: replace
|
|
28
|
+
* `checkInstalled` with binary detection and set `binPath` so
|
|
29
|
+
* `resolve()` calls the native binary directly.
|
|
30
|
+
*
|
|
31
|
+
* B) stdout-presence false negative (cursor, opencode)
|
|
32
|
+
* `checkAuthenticated` and `resolve` require both exit-0 AND
|
|
33
|
+
* non-empty stdout from an auth command. When auth is established via
|
|
34
|
+
* an API key env-var the CLI exits 0 but may write only to stderr,
|
|
35
|
+
* leaving stdout empty — causing a false "not authenticated". The
|
|
36
|
+
* fix: check exit code only, which is what the CLIs use to signal
|
|
37
|
+
* success.
|
|
38
|
+
*
|
|
39
|
+
* These are workarounds that belong upstream. Remove them once
|
|
40
|
+
* spawn-agent ships corrected adapters.
|
|
41
|
+
*/
|
|
42
|
+
export const buildAdapter = (id: AgentId): AgentAdapter => {
|
|
43
|
+
// --- class A: npm-resolution ---
|
|
44
|
+
if (id === "copilot") {
|
|
45
|
+
const builtin = adapters.builtInAdapter("copilot" satisfies SupportedAgentId, {
|
|
46
|
+
binPath: "copilot"
|
|
47
|
+
})
|
|
48
|
+
return {
|
|
49
|
+
...builtin,
|
|
50
|
+
checkInstalled: async () =>
|
|
51
|
+
detectAvailableAgents().includes("copilot" satisfies SupportedAgentId)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (id === "gemini") {
|
|
55
|
+
const builtin = adapters.builtInAdapter("gemini" satisfies SupportedAgentId, {
|
|
56
|
+
binPath: "gemini"
|
|
57
|
+
})
|
|
58
|
+
return {
|
|
59
|
+
...builtin,
|
|
60
|
+
checkInstalled: async () =>
|
|
61
|
+
detectAvailableAgents().includes("gemini" satisfies SupportedAgentId)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// --- class B: wrong auth subcommand (cursor) ---
|
|
66
|
+
if (id === "cursor") {
|
|
67
|
+
const builtin = adapters.builtInAdapter("cursor" satisfies SupportedAgentId, {})
|
|
68
|
+
// spawn-agent calls `agent auth whoami` but the cursor CLI has no `auth`
|
|
69
|
+
// subcommand — that string is passed as a prompt to the agent process,
|
|
70
|
+
// which always exits 1. The correct command is `agent whoami`.
|
|
71
|
+
const checkCursorAuth = (): boolean => {
|
|
72
|
+
try {
|
|
73
|
+
const result = spawnSync("agent", ["whoami"], {
|
|
74
|
+
encoding: "utf8",
|
|
75
|
+
env: process.env,
|
|
76
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
77
|
+
})
|
|
78
|
+
return result.status === 0 && result.stdout.trim().length > 0
|
|
79
|
+
} catch {
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
...builtin,
|
|
85
|
+
checkAuthenticated: async () => checkCursorAuth(),
|
|
86
|
+
resolve: async () => {
|
|
87
|
+
const version = spawnSync("agent", ["--version"], {
|
|
88
|
+
encoding: "utf8",
|
|
89
|
+
env: process.env,
|
|
90
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
91
|
+
})
|
|
92
|
+
if (version.status !== 0 || version.error != null) {
|
|
93
|
+
throw new AgentNotInstalledError(
|
|
94
|
+
"cursor",
|
|
95
|
+
"Cursor agent CLI is not installed. Install from https://cursor.com/docs/cli/acp.",
|
|
96
|
+
"https://cursor.com/docs/cli/acp"
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
if (!checkCursorAuth()) {
|
|
100
|
+
throw new AgentUnauthenticatedError(
|
|
101
|
+
"cursor",
|
|
102
|
+
"Cursor agent is not authenticated. Run `agent login` or set CURSOR_API_KEY.",
|
|
103
|
+
"agent login"
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
return { bin: "agent", args: ["acp"], env: {} }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (id === "opencode") {
|
|
111
|
+
const builtin = adapters.builtInAdapter("opencode" satisfies SupportedAgentId, {})
|
|
112
|
+
const checkOpencodeAuth = (): boolean => {
|
|
113
|
+
try {
|
|
114
|
+
const result = spawnSync("opencode", ["auth", "list"], {
|
|
115
|
+
encoding: "utf8",
|
|
116
|
+
env: process.env,
|
|
117
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
118
|
+
})
|
|
119
|
+
return result.status === 0
|
|
120
|
+
} catch {
|
|
121
|
+
return false
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
...builtin,
|
|
126
|
+
checkAuthenticated: async () => checkOpencodeAuth(),
|
|
127
|
+
resolve: async () => {
|
|
128
|
+
const version = spawnSync("opencode", ["--version"], {
|
|
129
|
+
encoding: "utf8",
|
|
130
|
+
env: process.env,
|
|
131
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
132
|
+
})
|
|
133
|
+
if (version.status !== 0 || version.error != null) {
|
|
134
|
+
throw new AgentNotInstalledError(
|
|
135
|
+
"opencode",
|
|
136
|
+
"OpenCode is not installed. Install it with `npm install -g opencode-ai`.",
|
|
137
|
+
"npm install -g opencode-ai"
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
if (!checkOpencodeAuth()) {
|
|
141
|
+
throw new AgentUnauthenticatedError(
|
|
142
|
+
"opencode",
|
|
143
|
+
"OpenCode is not authenticated. Run `opencode auth login` and try again.",
|
|
144
|
+
"opencode auth login"
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
return { bin: "opencode", args: ["acp"], env: {} }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return adapters.builtInAdapter(id satisfies SupportedAgentId, {})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Is this agent's CLI on the host right now? Prefers the adapter's
|
|
157
|
+
* own `checkInstalled()` hook (each built-in adapter knows exactly
|
|
158
|
+
* what to look for — package on disk, binary on PATH, etc.) and falls
|
|
159
|
+
* back to spawn-agent's aggregate `detectAvailableAgents()` when an
|
|
160
|
+
* adapter omits the hook.
|
|
161
|
+
*
|
|
162
|
+
* Never throws — any upstream exception collapses to `false`.
|
|
163
|
+
*/
|
|
164
|
+
const isInstalled = (id: AgentId): Effect.Effect<boolean> =>
|
|
165
|
+
Effect.tryPromise({
|
|
166
|
+
try: async () => {
|
|
167
|
+
const adapter = buildAdapter(id)
|
|
168
|
+
if (adapter.checkInstalled !== undefined) {
|
|
169
|
+
return await adapter.checkInstalled()
|
|
170
|
+
}
|
|
171
|
+
return detectAvailableAgents().includes(id satisfies SupportedAgentId)
|
|
172
|
+
},
|
|
173
|
+
catch: () => new Error("probe failed")
|
|
174
|
+
}).pipe(Effect.orElseSucceed(() => false))
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Is the agent actually authenticated (valid token, logged in, API
|
|
178
|
+
* key present, etc.)? Delegates to the adapter's `checkAuthenticated()`
|
|
179
|
+
* hook — the same logic spawn-agent uses before spawning the CLI —
|
|
180
|
+
* so probe UX matches runtime UX. When the hook is missing (e.g. Pi,
|
|
181
|
+
* Codex), we treat "installed" as "good enough" so we don't falsely
|
|
182
|
+
* mark those providers as unauthenticated.
|
|
183
|
+
*
|
|
184
|
+
* Never throws — any upstream exception collapses to `false`.
|
|
185
|
+
*/
|
|
186
|
+
const isAuthenticated = (id: AgentId): Effect.Effect<boolean> =>
|
|
187
|
+
Effect.tryPromise({
|
|
188
|
+
try: async () => {
|
|
189
|
+
const adapter = buildAdapter(id)
|
|
190
|
+
if (adapter.checkAuthenticated !== undefined) {
|
|
191
|
+
return await adapter.checkAuthenticated()
|
|
192
|
+
}
|
|
193
|
+
return true
|
|
194
|
+
},
|
|
195
|
+
catch: () => new Error("auth probe failed")
|
|
196
|
+
}).pipe(Effect.orElseSucceed(() => false))
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Quick "is the binary installed?" check used by `grimoire doctor`
|
|
200
|
+
* and the `AgentRunner.probe` port. Defers to the adapter hook so
|
|
201
|
+
* binary/package-resolution rules stay in one place upstream.
|
|
202
|
+
*/
|
|
203
|
+
export const probeAgentBinary = (
|
|
204
|
+
id: AgentId
|
|
205
|
+
): Effect.Effect<{ readonly available: boolean; readonly reason?: string }> =>
|
|
206
|
+
Effect.gen(function* () {
|
|
207
|
+
const installed = yield* isInstalled(id)
|
|
208
|
+
if (installed) return { available: true }
|
|
209
|
+
return { available: false, reason: `${id} CLI not found or not resolvable` }
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Best-effort auth gate for `grimoire doctor`. Combines install +
|
|
214
|
+
* auth checks so callers get a single yes/no on "can this agent
|
|
215
|
+
* actually run right now?"
|
|
216
|
+
*
|
|
217
|
+
* Fails with `ProviderUnavailable` so workflows can distinguish an
|
|
218
|
+
* environmental problem (operator action required) from a hard
|
|
219
|
+
* `CliAgentError` from `runner.run`.
|
|
220
|
+
*/
|
|
221
|
+
export const authenticateAgent = (id: AgentId): Effect.Effect<void, ProviderUnavailable> =>
|
|
222
|
+
Effect.gen(function* () {
|
|
223
|
+
const probe = yield* probeAgentBinary(id)
|
|
224
|
+
if (!probe.available) {
|
|
225
|
+
return yield* Effect.fail(
|
|
226
|
+
new ProviderUnavailable({
|
|
227
|
+
provider: id,
|
|
228
|
+
reason: probe.reason ?? `${id} CLI not found or not resolvable`
|
|
229
|
+
})
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
const authed = yield* isAuthenticated(id)
|
|
233
|
+
if (authed) return
|
|
234
|
+
return yield* Effect.fail(
|
|
235
|
+
new ProviderUnavailable({
|
|
236
|
+
provider: id,
|
|
237
|
+
reason: `${id} CLI is installed but not authenticated`
|
|
238
|
+
})
|
|
239
|
+
)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* One-shot probe used by `grimoire agent probe`. Combines install +
|
|
244
|
+
* auth into the row shape the workflow expects. Crucially, this tells
|
|
245
|
+
* the truth on the `authenticated` column — historically the probe
|
|
246
|
+
* reported `authenticated: yes` any time the binary was on PATH, which
|
|
247
|
+
* masked exactly the class of problem operators run the command to
|
|
248
|
+
* find.
|
|
249
|
+
*/
|
|
250
|
+
export const probeAgent = (
|
|
251
|
+
id: AgentId
|
|
252
|
+
): Effect.Effect<{
|
|
253
|
+
readonly available: boolean
|
|
254
|
+
readonly authenticated: boolean
|
|
255
|
+
readonly detail?: string | undefined
|
|
256
|
+
}> =>
|
|
257
|
+
Effect.gen(function* () {
|
|
258
|
+
const probe = yield* probeAgentBinary(id)
|
|
259
|
+
if (!probe.available) {
|
|
260
|
+
return {
|
|
261
|
+
available: false,
|
|
262
|
+
authenticated: false,
|
|
263
|
+
...(probe.reason !== undefined ? { detail: probe.reason } : {})
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const authed = yield* isAuthenticated(id)
|
|
267
|
+
return {
|
|
268
|
+
available: true,
|
|
269
|
+
authenticated: authed,
|
|
270
|
+
...(authed ? {} : { detail: `${id} is installed but not authenticated` })
|
|
271
|
+
}
|
|
272
|
+
})
|