Skip to content

Commit 6a25eb8

Browse files
authored
Merge pull request #22 from hawkff/upstream/issue-11-workspace-tool-context
Fix workspace-aware tool execution for Cursor agents
2 parents 41ad46f + d7b86c8 commit 6a25eb8

File tree

2 files changed

+131
-16
lines changed

2 files changed

+131
-16
lines changed

src/plugin.ts

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { tool } from "@opencode-ai/plugin";
33
import type { Auth } from "@opencode-ai/sdk";
44
import { mkdir } from "fs/promises";
55
import { homedir } from "os";
6-
import { join } from "path";
6+
import { isAbsolute, join, resolve } from "path";
77
import { ToolMapper, type ToolUpdate } from "./acp/tools.js";
88
import { startCursorOAuth } from "./auth";
99
import { LineBuffer } from "./streaming/line-buffer.js";
@@ -1321,13 +1321,71 @@ function jsonSchemaToZod(jsonSchema: any): any {
13211321
return zodShape;
13221322
}
13231323

1324+
function resolveToolContextBaseDir(context: any): string | null {
1325+
const candidates = [context?.worktree, context?.directory];
1326+
for (const candidate of candidates) {
1327+
if (typeof candidate === "string" && candidate.trim().length > 0) {
1328+
return candidate;
1329+
}
1330+
}
1331+
return null;
1332+
}
1333+
1334+
function toAbsoluteWithBase(value: unknown, baseDir: string): unknown {
1335+
if (typeof value !== "string") {
1336+
return value;
1337+
}
1338+
const trimmed = value.trim();
1339+
if (trimmed.length === 0 || isAbsolute(trimmed)) {
1340+
return value;
1341+
}
1342+
return resolve(baseDir, trimmed);
1343+
}
1344+
1345+
function applyToolContextDefaults(
1346+
toolName: string,
1347+
rawArgs: Record<string, unknown>,
1348+
context: any,
1349+
): Record<string, unknown> {
1350+
const baseDir = resolveToolContextBaseDir(context);
1351+
if (!baseDir) {
1352+
return rawArgs;
1353+
}
1354+
1355+
const args: Record<string, unknown> = { ...rawArgs };
1356+
1357+
for (const key of [
1358+
"path",
1359+
"filePath",
1360+
"targetPath",
1361+
"directory",
1362+
"dir",
1363+
"folder",
1364+
"targetDirectory",
1365+
"targetFile",
1366+
"cwd",
1367+
"workdir",
1368+
]) {
1369+
args[key] = toAbsoluteWithBase(args[key], baseDir);
1370+
}
1371+
1372+
if ((toolName === "bash" || toolName === "shell") && args.cwd === undefined && args.workdir === undefined) {
1373+
args.cwd = baseDir;
1374+
}
1375+
1376+
if ((toolName === "grep" || toolName === "glob" || toolName === "ls") && args.path === undefined) {
1377+
args.path = baseDir;
1378+
}
1379+
1380+
return args;
1381+
}
1382+
13241383
/**
13251384
* Build tool hook entries from local registry
13261385
*/
13271386
function buildToolHookEntries(registry: CoreRegistry): Record<string, any> {
13281387
const entries: Record<string, any> = {};
13291388
const tools = registry.list();
1330-
13311389
for (const t of tools) {
13321390
const handler = registry.getHandler(t.name);
13331391
if (!handler) continue;
@@ -1339,7 +1397,8 @@ function buildToolHookEntries(registry: CoreRegistry): Record<string, any> {
13391397
args: zodArgs,
13401398
async execute(args: any, context: any) {
13411399
try {
1342-
return await handler(args);
1400+
const normalizedArgs = applyToolContextDefaults(t.name, args, context);
1401+
return await handler(normalizedArgs);
13431402
} catch (error: any) {
13441403
log.warn("Tool hook execution failed", { tool: t.name, error: String(error?.message || error) });
13451404
throw error;

tests/unit/plugin-tools-hook.test.ts

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,41 @@
11
import { describe, it, expect } from "bun:test";
2+
import { mkdtempSync, readFileSync, realpathSync, rmSync } from "fs";
3+
import { tmpdir } from "os";
4+
import { join } from "path";
25
import { CursorPlugin } from "../../src/plugin";
36
import type { PluginInput } from "@opencode-ai/plugin";
47

8+
function createMockInput(directory: string): PluginInput {
9+
return {
10+
directory,
11+
worktree: directory,
12+
serverUrl: new URL("http://localhost:8080"),
13+
client: {
14+
tool: {
15+
list: async () => [],
16+
},
17+
} as any,
18+
project: {} as any,
19+
$: {} as any,
20+
};
21+
}
22+
23+
function createToolContext(directory: string): any {
24+
return {
25+
sessionID: "test-session",
26+
messageID: "test-message",
27+
agent: "test-agent",
28+
directory,
29+
worktree: directory,
30+
abort: new AbortController().signal,
31+
metadata: () => {},
32+
ask: async () => {},
33+
};
34+
}
35+
536
describe("Plugin tool hook", () => {
637
it("should register default tools via tool hook", async () => {
7-
// Mock PluginInput
8-
const mockInput: PluginInput = {
9-
directory: "/test/dir",
10-
worktree: "/test/dir",
11-
serverUrl: new URL("http://localhost:8080"),
12-
client: {
13-
tool: {
14-
list: async () => [],
15-
},
16-
} as any,
17-
project: {} as any,
18-
$: {} as any,
19-
};
38+
const mockInput = createMockInput("/test/dir");
2039

2140
// Initialize plugin
2241
const hooks = await CursorPlugin(mockInput);
@@ -42,4 +61,41 @@ describe("Plugin tool hook", () => {
4261
expect(bashTool?.args).toBeDefined();
4362
expect(typeof bashTool?.execute).toBe("function");
4463
});
64+
65+
it("resolves relative write paths against context directory", async () => {
66+
const projectDir = mkdtempSync(join(tmpdir(), "plugin-hook-write-"));
67+
try {
68+
const hooks = await CursorPlugin(createMockInput(projectDir));
69+
const out = await hooks.tool?.write?.execute(
70+
{
71+
path: "nested/output.txt",
72+
content: "hello from context",
73+
},
74+
createToolContext(projectDir),
75+
);
76+
77+
const expectedPath = join(projectDir, "nested/output.txt");
78+
expect(readFileSync(expectedPath, "utf-8")).toBe("hello from context");
79+
expect(out).toContain(expectedPath);
80+
} finally {
81+
rmSync(projectDir, { recursive: true, force: true });
82+
}
83+
});
84+
85+
it("defaults bash cwd to context directory", async () => {
86+
const projectDir = mkdtempSync(join(tmpdir(), "plugin-hook-bash-"));
87+
try {
88+
const hooks = await CursorPlugin(createMockInput(projectDir));
89+
const out = await hooks.tool?.bash?.execute(
90+
{
91+
command: "pwd",
92+
},
93+
createToolContext(projectDir),
94+
);
95+
96+
expect(realpathSync((out || "").trim())).toBe(realpathSync(projectDir));
97+
} finally {
98+
rmSync(projectDir, { recursive: true, force: true });
99+
}
100+
});
45101
});

0 commit comments

Comments
 (0)