1// @effect-diagnostics nodeBuiltinImport:off - cleanup uses Node's retrying rm, which the FileSystem service does not expose.
2import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk";
3import { vi } from "vite-plus/test";
4import * as Deferred from "effect/Deferred";
5import * as Fiber from "effect/Fiber";
6import * as TestClock from "effect/testing/TestClock";
7import {
8 ClaudeSettings,
9 ProviderDriverKind,
10 ProviderInstanceId,
11 type ServerProvider,
12} from "@t3tools/contracts";
13import * as NodeFSP from "node:fs/promises";
14import * as NodeServices from "@effect/platform-node/NodeServices";
15import { assert, it } from "@effect/vitest";
16import * as Effect from "effect/Effect";
17import * as FileSystem from "effect/FileSystem";
18import * as Path from "effect/Path";
19import * as Schema from "effect/Schema";
20
21import {
22 buildClaudeCapabilitiesProbeQueryOptions,
23 CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES,
24 probeClaudeCapabilities,
25 probeClaudeWorkspaceSnapshot,
26} from "./ClaudeProvider.ts";
27import { COMPACT_SLASH_COMMAND } from "@t3tools/provider-core/server/snapshotProbe";
28
29vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true });
30
31const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
32
33it("isolates Claude capability probes without dropping workspace setting sources", () => {
34 const abortController = new AbortController();
35 const options = buildClaudeCapabilitiesProbeQueryOptions({
36 executablePath: "/usr/bin/claude",
37 abortController,
38 environment: {
39 HOME: "/home/user",
40 ENABLE_CLAUDEAI_MCP_SERVERS: "true",
41 FORCE_CODE_TERMINAL: "1",
42 },
43 cwd: "/workspace/project",
44 managedMcpConfig: false,
45 });
46
47 assert.deepEqual(options.mcpServers, {});
48 assert.equal(options.strictMcpConfig, true);
49 assert.equal(options.cwd, "/workspace/project");
50 assert.deepEqual(options.settingSources, [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES]);
51 assert.deepEqual(options.settings, { disableAllHooks: true });
52 assert.deepEqual(options.allowedTools, []);
53 assert.equal(options.persistSession, false);
54 assert.equal(options.pathToClaudeCodeExecutable, "/usr/bin/claude");
55 assert.equal(options.abortController, abortController);
56 assert.equal(options.env?.HOME, "/home/user");
57 assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false");
58 assert.equal(options.env?.FORCE_CODE_TERMINAL, undefined);
59 assert.equal(options.env?.CLAUDE_CODE_AUTO_CONNECT_IDE, "0");
60 assert.equal(options.env?.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL, "1");
61});
62
63it("drops strict MCP mode, and only that, next to an administrator's managed-mcp.json", () => {
64 const options = buildClaudeCapabilitiesProbeQueryOptions({
65 executablePath: "/usr/bin/claude",
66 abortController: new AbortController(),
67 environment: { HOME: "/home/user" },
68 cwd: undefined,
69 managedMcpConfig: true,
70 });
71
72 // Claude exits before initializing when handed --strict-mcp-config here.
73 assert.equal(options.strictMcpConfig, undefined);
74 assert.deepEqual(options.mcpServers, {});
75 assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false");
76});
77
78it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
79 it.effect(
80 "discovers commands and skills separately for each cwd without replacing machine metadata",
81 () =>
82 Effect.gen(function* () {
83 const fs = yield* FileSystem.FileSystem;
84 const path = yield* Path.Path;
85 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-workspaces-" });
86 const configDir = path.join(tempDir, "claude-home");
87 const workspaces = [path.join(tempDir, "one"), path.join(tempDir, "two")];
88 for (const cwd of workspaces) {
89 const skillDir = path.join(cwd, ".claude", "skills", "existing-skill");
90 yield* fs.makeDirectory(skillDir, { recursive: true });
91 yield* fs.writeFileString(
92 path.join(skillDir, "SKILL.md"),
93 "---\nname: existing-skill\ndescription: Existing project skill\n---\nUse this skill.",
94 );
95 }
96 const machineSnapshot = {
97 instanceId: ProviderInstanceId.make("claude"),
98 driver: ProviderDriverKind.make("claudeAgent"),
99 enabled: true,
100 installed: true,
101 status: "ready",
102 auth: { status: "authenticated", email: "machine@example.com" },
103 checkedAt: "2026-03-25T00:00:00.000Z",
104 version: "2.1.288",
105 models: [],
106 slashCommands: [{ name: "server-cwd-only" }],
107 skills: [],
108 } satisfies ServerProvider;
109 let usageCalls = 0;
110 const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => {
111 assert.equal(options?.env?.CLAUDE_CONFIG_DIR, configDir);
112 assert.equal(options?.env?.T3_WORKSPACE_PROBE, "owned-instance");
113 return {
114 initializationResult: async () => ({
115 account: { email: "workspace@example.com" },
116 commands: [
117 {
118 name: options?.cwd === workspaces[0] ? "start-session" : "other-project",
119 description: "Project command",
120 argumentHint: "[topic]",
121 },
122 { name: "nested:review", description: "Review changes", argumentHint: "" },
123 { name: "NESTED:review", description: "", argumentHint: "[path]" },
124 { name: "compact", description: "Provider compact", argumentHint: "" },
125 { name: "user-command", description: "Existing user command", argumentHint: "" },
126 ],
127 }),
128 usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => {
129 usageCalls++;
130 return { rate_limits_available: false, rate_limits: null };
131 },
132 } as ReturnType<typeof ClaudeSdk.query>;
133 });
134 yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore()));
135 for (const [index, cwd] of workspaces.entries()) {
136 const scoped = yield* probeClaudeWorkspaceSnapshot(
137 decodeClaudeSettings({ homePath: configDir }),
138 machineSnapshot,
139 cwd,
140 { ...process.env, T3_WORKSPACE_PROBE: "owned-instance" },
141 );
142 assert.deepEqual(scoped, {
143 ...machineSnapshot,
144 slashCommandsPending: false,
145 slashCommands: [
146 COMPACT_SLASH_COMMAND,
147 {
148 name: index === 0 ? "start-session" : "other-project",
149 description: "Project command",
150 input: { hint: "[topic]" },
151 },
152 { name: "nested:review", description: "Review changes", input: { hint: "[path]" } },
153 { name: "user-command", description: "Existing user command" },
154 ],
155 skills: [
156 {
157 name: "existing-skill",
158 path: path.join(cwd, ".claude", "skills", "existing-skill", "SKILL.md"),
159 enabled: true,
160 scope: "project",
161 description: "Existing project skill",
162 },
163 ],
164 });
165 }
166 assert.deepEqual(
167 query.mock.calls.map(([input]) => input.options?.cwd),
168 workspaces,
169 );
170 assert.equal(usageCalls, 0);
171 }).pipe(Effect.scoped),
172 );
173
174 it.effect("keeps readable skills during failed command discovery and recovers on retry", () =>
175 Effect.gen(function* () {
176 const fs = yield* FileSystem.FileSystem;
177 const path = yield* Path.Path;
178 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-workspace-retry-" });
179 const skillDir = path.join(cwd, ".claude", "skills", "existing-skill");
180 yield* fs.makeDirectory(skillDir, { recursive: true });
181 yield* fs.writeFileString(
182 path.join(skillDir, "SKILL.md"),
183 "---\nname: existing-skill\ndescription: Existing project skill\n---\nUse this skill.",
184 );
185 const machineSnapshot = {
186 instanceId: ProviderInstanceId.make("claude"),
187 driver: ProviderDriverKind.make("claudeAgent"),
188 enabled: true,
189 installed: true,
190 status: "ready",
191 auth: { status: "authenticated" },
192 checkedAt: "2026-03-25T00:00:00.000Z",
193 version: "2.1.288",
194 models: [],
195 slashCommands: [{ name: "server-cwd-only" }],
196 skills: [],
197 } satisfies ServerProvider;
198 const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(
199 () =>
200 ({
201 initializationResult: () =>
202 Promise.reject<ClaudeSdk.SDKControlInitializeResponse>(
203 new Error("Initialization failed"),
204 ),
205 usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({
206 rate_limits_available: false,
207 rate_limits: null,
208 }),
209 }) as ReturnType<typeof ClaudeSdk.query>,
210 );
211 yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore()));
212 const settings = decodeClaudeSettings({ homePath: cwd });
213 const failed = yield* probeClaudeWorkspaceSnapshot(settings, machineSnapshot, cwd);
214 assert.deepEqual(failed, {
215 ...machineSnapshot,
216 slashCommands: [COMPACT_SLASH_COMMAND],
217 slashCommandsPending: true,
218 skills: [
219 {
220 name: "existing-skill",
221 path: path.join(skillDir, "SKILL.md"),
222 enabled: true,
223 scope: "project",
224 description: "Existing project skill",
225 },
226 ],
227 });
228 query.mockImplementation(
229 () =>
230 ({
231 initializationResult: async () => ({
232 commands: [{ name: "recovered", description: "", argumentHint: "" }],
233 }),
234 usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({
235 rate_limits_available: false,
236 rate_limits: null,
237 }),
238 }) as ReturnType<typeof ClaudeSdk.query>,
239 );
240 const recovered = yield* probeClaudeWorkspaceSnapshot(settings, machineSnapshot, cwd);
241 assert.deepEqual(recovered.slashCommands, [COMPACT_SLASH_COMMAND, { name: "recovered" }]);
242 assert.equal(recovered.status, "ready");
243 assert.equal(recovered.slashCommandsPending, false);
244 assert.deepEqual(recovered.skills, failed.skills);
245 const disabled = yield* probeClaudeWorkspaceSnapshot(
246 { ...settings, enabled: false },
247 machineSnapshot,
248 cwd,
249 );
250 assert.equal(disabled, machineSnapshot);
251 assert.equal(query.mock.calls.length, 2);
252 }).pipe(Effect.scoped),
253 );
254
255 it.effect("serializes strict no-MCP options and still resolves account capabilities", () =>
256 Effect.gen(function* () {
257 const fs = yield* FileSystem.FileSystem;
258 const path = yield* Path.Path;
259 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-probe-sdk-" });
260 const executablePath = yield* path.fromFileUrl(
261 new URL("./testing/ClaudeCapabilitiesProbe.fixture.mjs", import.meta.url),
262 );
263 const invocationPath = path.join(tempDir, "invocation.json");
264 // The probe aborts the SDK without awaiting the child's exit, and on
265 // Windows a directory that is still some process's cwd cannot be
266 // removed. Keep the workspace outside the scoped directory and let it
267 // go with a retrying removal once the child has gone.
268 const workspaceCwd = yield* fs.makeTempDirectory({ prefix: "t3-claude-probe-cwd-" });
269 // Node's own retry rather than an Effect schedule: it.effect runs on a
270 // TestClock, so a scheduled retry would wait for time nobody advances.
271 // If the child still holds the directory after that, an empty temp
272 // directory is left behind rather than failing the test for it.
273 yield* Effect.addFinalizer(() =>
274 Effect.promise(() =>
275 NodeFSP.rm(workspaceCwd, {
276 recursive: true,
277 force: true,
278 maxRetries: 20,
279 retryDelay: 250,
280 }).catch(() => undefined),
281 ),
282 );
283
284 const capabilities = yield* probeClaudeCapabilities(
285 decodeClaudeSettings({ binaryPath: executablePath }),
286 {
287 ...process.env,
288 T3_PROBE_INVOCATION_PATH: invocationPath,
289 ENABLE_CLAUDEAI_MCP_SERVERS: "true",
290 },
291 workspaceCwd,
292 );
293
294 assert.deepEqual(capabilities, {
295 email: "dev@example.com",
296 subscriptionType: "pro",
297 tokenSource: "oauth",
298 apiKeySource: undefined,
299 apiProvider: undefined,
300 slashCommands: [
301 {
302 name: "review",
303 description: "Review changes",
304 input: { hint: "[path]" },
305 },
306 ],
307 usage: {
308 rate_limits_available: true,
309 rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } },
310 },
311 });
312
313 const invocation = JSON.parse(yield* fs.readFileString(invocationPath)) as {
314 readonly args: ReadonlyArray<string>;
315 readonly cwd: string;
316 readonly connectorEnv: string;
317 readonly mcpConfig: unknown;
318 };
319 assert.equal(invocation.cwd, yield* fs.realPath(workspaceCwd));
320 assert.equal(invocation.connectorEnv, "false");
321 assert.equal(invocation.args.includes("--strict-mcp-config"), true);
322 assert.equal(invocation.args.includes("--mcp-config"), false);
323 assert.equal(invocation.mcpConfig, undefined);
324
325 assert.equal(invocation.args.includes("--setting-sources=user,project,local"), true);
326
327 const settingsFlagIndex = invocation.args.indexOf("--settings");
328 assert.notEqual(settingsFlagIndex, -1);
329 const flagSettings = JSON.parse(invocation.args[settingsFlagIndex + 1] ?? "{}") as {
330 readonly disableAllHooks?: boolean;
331 };
332 assert.equal(flagSettings.disableAllHooks, true);
333 }).pipe(Effect.scoped),
334 );
335});
336
337it.effect("preserves initialized capabilities when optional usage times out", () =>
338 Effect.gen(function* () {
339 const usageStarted = yield* Deferred.make<void>();
340 let abortSignal: AbortSignal | undefined;
341 const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => {
342 abortSignal = options?.abortController?.signal;
343 return {
344 initializationResult: async () => ({
345 account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },
346 commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }],
347 }),
348 usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: () => {
349 Deferred.doneUnsafe(usageStarted, Effect.void);
350 return new Promise(() => {});
351 },
352 } as ReturnType<typeof ClaudeSdk.query>;
353 });
354 yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore()));
355 const probe = yield* probeClaudeCapabilities(
356 decodeClaudeSettings({ binaryPath: "claude" }),
357 ).pipe(Effect.forkChild);
358 yield* Deferred.await(usageStarted);
359 yield* TestClock.adjust("4 seconds");
360 const capabilities = yield* Fiber.join(probe);
361 assert.equal(capabilities?.email, "dev@example.com");
362 assert.equal(capabilities?.subscriptionType, "pro");
363 assert.equal(capabilities?.tokenSource, "oauth");
364 assert.deepEqual(capabilities?.slashCommands, [
365 { name: "review", description: "Review changes", input: { hint: "[path]" } },
366 ]);
367 assert.equal(capabilities?.usage, undefined);
368 assert.equal(abortSignal?.aborted, true);
369 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
370);
371
372it.effect("asks for usage without the local transcript scan", () =>
373 Effect.gen(function* () {
374 let usageOptions: unknown;
375 const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(
376 () =>
377 ({
378 initializationResult: async () => ({
379 account: { email: "dev@example.com", subscriptionType: "max", tokenSource: "oauth" },
380 commands: [],
381 }),
382 usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async (options?: unknown) => {
383 usageOptions = options;
384 return { rate_limits_available: true, rate_limits: null };
385 },
386 }) as unknown as ReturnType<typeof ClaudeSdk.query>,
387 );
388 yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore()));
389 yield* probeClaudeCapabilities(decodeClaudeSettings({ binaryPath: "claude" }));
390 assert.deepEqual(usageOptions, { skipBehaviors: true });
391 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
392);