@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/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
+ })