From ef39f36112d0c91197750d3e2f1dd98c9e694b59 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:10:46 +0530 Subject: [PATCH 1/6] feat(config): replace tool modes with claude and codex --- src/config.test.ts | 14 +++----------- src/config.ts | 15 ++------------- src/server.test.ts | 17 ++++++++--------- src/user-config.ts | 3 +++ 4 files changed, 16 insertions(+), 33 deletions(-) diff --git a/src/config.test.ts b/src/config.test.ts index 7b3eeeb6..e7478ca9 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -15,12 +15,7 @@ assert.equal(loadConfig(baseEnv).widgets, "full"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "changes" }).widgets, "changes"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "full" }).widgets, "full"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "off" }).widgets, "off"); -assert.equal(loadConfig(baseEnv).toolMode, "minimal"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "minimal" }).toolMode, "minimal"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "full" }).toolMode, "full"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex" }).toolMode, "codex"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "0" }).toolMode, "full"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "1" }).toolMode, "minimal"); +assert.equal(loadConfig(baseEnv).toolMode, "codex"); assert.equal(loadConfig(baseEnv).skillsEnabled, true); assert.equal(loadConfig(baseEnv).devspaceSkillsDir, join(emptyConfigDir, "skills")); assert.equal(loadConfig(baseEnv).devspaceAgentsDir, join(emptyConfigDir, "agents")); @@ -50,11 +45,6 @@ assert.throws( () => loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "write-only" }), /Invalid DEVSPACE_WIDGETS: write-only/, ); -assert.throws( - () => loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "invalid" }), - /Invalid DEVSPACE_TOOL_MODE: invalid/, -); - assert.deepEqual(loadConfig(baseEnv).logging, { level: "info", format: "json", @@ -163,6 +153,7 @@ writeFileSync( subagents: true, artifactsEnabled: true, artifactMaxFileBytes: 321, + tools: { mode: "claude" }, }), ); writeFileSync( @@ -180,6 +171,7 @@ assert.equal(fileConfig.subagents.enabled, true); assert.equal(fileConfig.subagents.providers.length, 7); assert.equal(fileConfig.artifactsEnabled, true); assert.equal(fileConfig.artifactMaxFileBytes, 321); +assert.equal(fileConfig.toolMode, "claude"); assert.deepEqual(fileConfig.allowedHosts, [ "localhost", "127.0.0.1", diff --git a/src/config.ts b/src/config.ts index 54a131c9..bd8f47a8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,7 +6,7 @@ import type { OAuthConfig } from "./oauth-provider.js"; import { devspaceAgentsDir, devspaceSkillsDir, loadDevspaceFiles } from "./user-config.js"; import { resolveSubagentsConfig, type SubagentsConfig } from "./local-agent-config.js"; -export type ToolMode = "minimal" | "full" | "codex"; +export type ToolMode = "claude" | "codex"; export type WidgetMode = "off" | "changes" | "full"; const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60; const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; @@ -85,17 +85,6 @@ function parseBoolean(value: string | undefined): boolean { return ["1", "true", "yes", "on"].includes(value?.toLowerCase() ?? ""); } -function parseToolMode(env: NodeJS.ProcessEnv): ToolMode { - const mode = env.DEVSPACE_TOOL_MODE; - if (mode === "minimal" || mode === "full" || mode === "codex") return mode; - if (mode) throw new Error(`Invalid DEVSPACE_TOOL_MODE: ${mode}`); - - if (env.DEVSPACE_MINIMAL_TOOLS !== undefined) { - return parseBoolean(env.DEVSPACE_MINIMAL_TOOLS) ? "minimal" : "full"; - } - return "minimal"; -} - function parseLogLevel(value: string | undefined): LogLevel { if (!value || value === "info") return "info"; if (["silent", "error", "warn", "debug"].includes(value)) return value as LogLevel; @@ -231,7 +220,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { allowedRoots: parseAllowedRoots(env.DEVSPACE_ALLOWED_ROOTS ?? files.config.allowedRoots), allowedHosts: parseAllowedHosts(env.DEVSPACE_ALLOWED_HOSTS, derivedAllowedHosts), publicBaseUrl, - toolMode: parseToolMode(env), + toolMode: files.config.tools?.mode ?? "codex", widgets: parseWidgetMode(env.DEVSPACE_WIDGETS), stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())), worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())), diff --git a/src/server.test.ts b/src/server.test.ts index ab7010ca..592cbda2 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -25,13 +25,9 @@ test("tool modes expose the expected host-facing tool surface", async (t) => { expected: string[]; }> = [ { - mode: "minimal", + mode: "claude", expected: ["open_workspace", "read", "write", "edit", "bash"], }, - { - mode: "full", - expected: ["open_workspace", "read", "write", "edit", "bash", "grep", "glob", "ls"], - }, { mode: "codex", expected: ["open_workspace", "read", "apply_patch", "exec_command", "write_stdin"], @@ -64,7 +60,7 @@ test("widget modes compose independently from tool modes", async (t) => { for (const { widgets, showChanges, workspaceCard } of cases) { await t.test(widgets, async (nested) => { - const context = await fixture(nested, { toolMode: "full", widgets }); + const context = await fixture(nested, { toolMode: "claude", widgets }); const tools = await context.client.listTools(); const workspace = tools.tools.find((tool) => tool.name === "open_workspace"); const changes = tools.tools.find((tool) => tool.name === "show_changes"); @@ -344,14 +340,17 @@ async function fixture( DEVSPACE_WORKTREE_ROOT: join(root, ".worktrees"), DEVSPACE_AGENT_DIR: agentDir, DEVSPACE_WIDGETS: options.widgets ?? "full", - DEVSPACE_TOOL_MODE: options.toolMode ?? "full", DEVSPACE_SUBAGENTS: options.localAgentProviders ? "1" : "0", DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", PORT: "1", }); + const modeConfig: ServerConfig = { + ...loadedConfig, + toolMode: options.toolMode ?? loadedConfig.toolMode, + }; const config: ServerConfig = options.localAgentProviders ? { - ...loadedConfig, + ...modeConfig, subagents: options.subagents ?? { enabled: true, providers: initialProviderAvailability.map((provider) => ({ @@ -360,7 +359,7 @@ async function fixture( })), }, } - : loadedConfig; + : modeConfig; const resolveProviderAvailability: () => LocalAgentProviderAvailability[] = typeof options.localAgentProviders === "function" ? options.localAgentProviders diff --git a/src/user-config.ts b/src/user-config.ts index 506b468c..203bc7c3 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -23,6 +23,9 @@ const devspaceUserConfigSchema = z.object({ artifactMaxFileBytes: z.number().optional(), agentDir: z.string().optional(), subagents: storedSubagentsConfigSchema.optional(), + tools: z.object({ + mode: z.enum(["claude", "codex"]).optional(), + }).strict().optional(), }).passthrough(); const devspaceAuthConfigSchema = z.object({ From 954b44b56927356e424290526a74867af43fd5c5 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:10:46 +0530 Subject: [PATCH 2/6] refactor(tools): remove dedicated search tools --- src/pi-tools.ts | 27 -- src/tool-surfaces/claude.ts | 317 +++++++++++++++++++ src/tool-surfaces/index.ts | 12 +- src/tool-surfaces/standard.ts | 573 ---------------------------------- src/tool-surfaces/types.ts | 5 - 5 files changed, 321 insertions(+), 613 deletions(-) create mode 100644 src/tool-surfaces/claude.ts delete mode 100644 src/tool-surfaces/standard.ts diff --git a/src/pi-tools.ts b/src/pi-tools.ts index 238b9c54..06f82197 100644 --- a/src/pi-tools.ts +++ b/src/pi-tools.ts @@ -1,17 +1,11 @@ import { createBashTool, createEditTool, - createFindTool, - createGrepTool, - createLsTool, createReadTool, createWriteTool, type BashToolInput, type EditToolInput, type EditToolDetails, - type FindToolInput, - type GrepToolInput, - type LsToolInput, type ReadToolInput, type WriteToolInput, type AgentToolResult, @@ -97,27 +91,6 @@ export async function editFileTool(input: EditToolInput, context: ToolContext): }, context); } -export async function grepFilesTool(input: GrepToolInput, context: ToolContext): Promise { - if (input.path) resolveAllowedPath(input.path, context.cwd, [context.root]); - const tool = createGrepTool(context.cwd); - - return runTool((params) => tool.execute("grep_files", params), input, context); -} - -export async function findFilesTool(input: FindToolInput, context: ToolContext): Promise { - if (input.path) resolveAllowedPath(input.path, context.cwd, [context.root]); - const tool = createFindTool(context.cwd); - - return runTool((params) => tool.execute("find_files", params), input, context); -} - -export async function listDirectoryTool(input: LsToolInput, context: ToolContext): Promise { - if (input.path) resolveAllowedPath(input.path, context.cwd, [context.root]); - const tool = createLsTool(context.cwd); - - return runTool((params) => tool.execute("list_directory", params), input, context); -} - export async function runShellTool(input: BashToolInput, context: ToolContext): Promise { const tool = createBashTool(context.cwd); const timeout = input.timeout === undefined ? 30 : Math.min(input.timeout, 300); diff --git a/src/tool-surfaces/claude.ts b/src/tool-surfaces/claude.ts new file mode 100644 index 00000000..eadd566a --- /dev/null +++ b/src/tool-surfaces/claude.ts @@ -0,0 +1,317 @@ +import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; +import { existsSync } from "node:fs"; +import * as z from "zod/v4"; +import { + editFileTool, + runShellTool, + writeFileTool, +} from "../pi-tools.js"; +import { + EDIT_TOOL_ANNOTATIONS, + SHELL_TOOL_ANNOTATIONS, + WRITE_TOOL_ANNOTATIONS, + toolNames, + workspaceIdDescription, + type ToolInstructionContext, + type ToolRegistrationContext, +} from "./types.js"; +import { + contentLineCount, + contentText, + countDiffStats, + logFailedToolResponse, + logToolCall, + newFilePatch, + resultOutputSchema, + textBlock, + textSummary, + toolWidgetDescriptorMeta, +} from "./shared.js"; + +const CLAUDE_INSTRUCTIONS = `Use ${toolNames.read} for direct file reads, ${toolNames.shell} with command-line tools such as rg, find, ls, and tree for search and directory inspection, ${toolNames.edit} for targeted modifications, and ${toolNames.write} only for new files or complete rewrites. Use ${toolNames.shell} for tests, builds, git inspection, package scripts, and other commands, but do not create or modify files through shell commands. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.`; + +export function claudeInstructions({ + agents, + skills, +}: ToolInstructionContext): string { + return `${agents}${skills}${CLAUDE_INSTRUCTIONS}`; +} + +export function registerClaudeTools(context: ToolRegistrationContext): void { + registerClaudeMutationTools(context); + registerShellTool(context); +} + +const CLAUDE_SHELL_DESCRIPTION = `Run a shell command in a workspace. Use it for tests, builds, git inspection, package scripts, search, file discovery, and directory inspection. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read} for direct file reads. This is powerful execution and should only be exposed behind strong authentication.`; + +function registerClaudeMutationTools(context: ToolRegistrationContext): void { + const { server, config, workspaces } = context; + + registerAppTool( + server, + toolNames.write, + { + title: "Write file", + description: `Create or completely overwrite a file in a workspace. Prefer ${toolNames.edit} for targeted changes to existing files.`, + inputSchema: { + workspaceId: z.string().describe(workspaceIdDescription), + path: z + .string() + .describe("File path to write, relative to the workspace root."), + content: z.string().describe("Complete new file content."), + }, + outputSchema: resultOutputSchema(), + ...toolWidgetDescriptorMeta(config, "write"), + annotations: WRITE_TOOL_ANNOTATIONS, + }, + async ({ workspaceId, ...input }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); + const absolutePath = workspaces.resolvePath(workspace, input.path); + const overwritesExistingFile = existsSync(absolutePath); + const response = await writeFileTool(input, { + cwd: workspace.root, + root: workspace.root, + }); + + if (response.isError) { + logFailedToolResponse( + config, + { + tool: toolNames.write, + workspaceId, + path: input.path, + }, + response.content, + startedAt, + ); + return response; + } + + // An aggregate review can show the real replacement diff. A new-file + // patch would misrepresent an overwrite as additions with no removals. + const patch = overwritesExistingFile + ? undefined + : newFilePatch(input.path, input.content); + const stats = countDiffStats(patch); + const summary = { + ...stats, + lines: contentLineCount(input.content), + characters: input.content.length, + }; + logToolCall(config, { + tool: toolNames.write, + workspaceId, + path: input.path, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + ...response, + _meta: { + tool: toolNames.write, + card: { + workspaceId, + path: input.path, + summary, + payload: { + content: response.content, + patch, + }, + }, + }, + structuredContent: { + result: contentText(response.content), + }, + }; + }, + ); + + registerAppTool( + server, + toolNames.edit, + { + title: "Edit file", + description: `Edit one file in a workspace by replacing exact text blocks. Prefer this over ${toolNames.write} for targeted changes. Each oldText must match a unique, non-overlapping region of the original file; merge nearby changes into one edit and keep oldText as small as possible while still unique.`, + inputSchema: { + workspaceId: z.string().describe(workspaceIdDescription), + path: z + .string() + .describe("File path to edit, relative to the workspace root."), + edits: z + .array( + z.object({ + oldText: z + .string() + .describe( + "Exact text to replace. Must match uniquely in the original file.", + ), + newText: z.string().describe("Replacement text."), + }), + ) + .min(1), + }, + outputSchema: resultOutputSchema({ + status: z.literal("applied"), + }), + ...toolWidgetDescriptorMeta(config, "edit"), + annotations: EDIT_TOOL_ANNOTATIONS, + }, + async ({ workspaceId, ...input }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); + workspaces.resolvePath(workspace, input.path); + const response = await editFileTool(input, { + cwd: workspace.root, + root: workspace.root, + }); + + if (response.isError) { + logFailedToolResponse( + config, + { + tool: toolNames.edit, + workspaceId, + path: input.path, + }, + response.content, + startedAt, + ); + return response; + } + + const stats = countDiffStats( + response.details?.patch ?? response.details?.diff, + ); + const summary = { + ...stats, + editCount: input.edits.length, + }; + const editResultText = `Edited ${input.path} (+${stats.additions} -${stats.removals}).`; + const editContent = [textBlock(editResultText)]; + logToolCall(config, { + tool: toolNames.edit, + workspaceId, + path: input.path, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content: editContent, + _meta: { + tool: toolNames.edit, + card: { + workspaceId, + path: input.path, + summary, + payload: { + diff: response.details?.diff, + patch: response.details?.patch, + }, + }, + }, + structuredContent: { + status: "applied", + result: contentText(editContent), + }, + }; + }, + ); +} + +function registerShellTool(context: ToolRegistrationContext): void { + const { server, config, workspaces } = context; + + registerAppTool( + server, + toolNames.shell, + { + title: "Bash", + description: CLAUDE_SHELL_DESCRIPTION, + inputSchema: { + workspaceId: z.string().describe(workspaceIdDescription), + command: z + .string() + .describe( + `Shell command to run. Must not create or modify project files; use ${toolNames.edit} or ${toolNames.write} for file changes.`, + ), + workingDirectory: z + .string() + .optional() + .describe( + "Optional working directory relative to the workspace root. Defaults to the workspace root.", + ), + timeout: z + .number() + .positive() + .max(300) + .optional() + .describe("Timeout in seconds. Defaults to 30, max 300."), + }, + outputSchema: resultOutputSchema(), + ...toolWidgetDescriptorMeta(config, "shell"), + annotations: SHELL_TOOL_ANNOTATIONS, + }, + async ({ workspaceId, workingDirectory, ...input }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); + const cwd = workspaces.resolveWorkingDirectory( + workspace, + workingDirectory, + ); + const response = await runShellTool(input, { + cwd, + root: workspace.root, + }); + + if (response.isError) { + logFailedToolResponse( + config, + { + tool: toolNames.shell, + workspaceId, + workingDirectory: workingDirectory ?? ".", + command: input.command, + commandLength: input.command.length, + }, + response.content, + startedAt, + ); + return response; + } + + const summary = { + command: input.command, + workingDirectory: workingDirectory ?? ".", + ...textSummary(response.content), + }; + logToolCall(config, { + tool: toolNames.shell, + workspaceId, + workingDirectory: workingDirectory ?? ".", + command: input.command, + commandLength: input.command.length, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + ...response, + _meta: { + tool: toolNames.shell, + card: { + workspaceId, + path: workingDirectory, + summary, + payload: { content: response.content }, + }, + }, + structuredContent: { + result: contentText(response.content), + }, + }; + }, + ); +} diff --git a/src/tool-surfaces/index.ts b/src/tool-surfaces/index.ts index b2114097..f86a6e11 100644 --- a/src/tool-surfaces/index.ts +++ b/src/tool-surfaces/index.ts @@ -1,16 +1,12 @@ import type { ToolMode } from "../config.js"; import { codexInstructions, registerCodexTools } from "./codex.js"; -import { registerStandardTools, standardInstructions } from "./standard.js"; +import { claudeInstructions, registerClaudeTools } from "./claude.js"; import { type ToolSurface } from "./types.js"; const TOOL_SURFACES: Record = { - minimal: { - register: (context) => registerStandardTools(context, "minimal"), - instructions: standardInstructions("minimal"), - }, - full: { - register: (context) => registerStandardTools(context, "full"), - instructions: standardInstructions("full"), + claude: { + register: registerClaudeTools, + instructions: claudeInstructions, }, codex: { register: registerCodexTools, diff --git a/src/tool-surfaces/standard.ts b/src/tool-surfaces/standard.ts deleted file mode 100644 index f18f29ea..00000000 --- a/src/tool-surfaces/standard.ts +++ /dev/null @@ -1,573 +0,0 @@ -import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; -import { existsSync } from "node:fs"; -import * as z from "zod/v4"; -import { - editFileTool, - findFilesTool, - grepFilesTool, - listDirectoryTool, - runShellTool, - writeFileTool, -} from "../pi-tools.js"; -import { - EDIT_TOOL_ANNOTATIONS, - SHELL_TOOL_ANNOTATIONS, - WRITE_TOOL_ANNOTATIONS, - toolNames, - workspaceIdDescription, - type ToolInstructionContext, - type ToolRegistrationContext, -} from "./types.js"; -import { - contentLineCount, - contentText, - countDiffStats, - logFailedToolResponse, - logToolCall, - newFilePatch, - resultOutputSchema, - textBlock, - textSummary, - toolWidgetDescriptorMeta, -} from "./shared.js"; - -type StandardRegistration = (context: ToolRegistrationContext) => void; - -const MINIMAL_INSPECTION = `In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled; use ${toolNames.shell} with command-line tools such as grep, rg, find, ls, and tree for search and directory inspection. `; - -const FULL_INSPECTION = `Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection. `; - -const STANDARD_EDITING = `Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.`; - -export function standardInstructions(mode: "minimal" | "full") { - const inspection = mode === "minimal" ? MINIMAL_INSPECTION : FULL_INSPECTION; - return ({ agents, skills }: ToolInstructionContext): string => - `${agents}${skills}${inspection}${STANDARD_EDITING}`; -} - -export function registerStandardTools( - context: ToolRegistrationContext, - mode: "minimal" | "full", -): void { - for (const register of STANDARD_REGISTRATIONS[mode]) { - register(context); - } -} - -const STANDARD_REGISTRATIONS: Record< - "minimal" | "full", - readonly StandardRegistration[] -> = { - minimal: [registerStandardMutationTools, registerMinimalShellTool], - full: [ - registerStandardMutationTools, - registerSearchTools, - registerFullShellTool, - ], -}; - -const MINIMAL_SHELL_DESCRIPTION = `Run a shell command in a workspace. Use only for tests, builds, git inspection, package scripts, search, file discovery, and directory inspection. In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled; use command-line tools such as grep, rg, find, ls, and tree for those read-only inspection actions. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read} for direct file reads. This is powerful execution and should only be exposed behind strong authentication.`; -const FULL_SHELL_DESCRIPTION = `Run a shell command in a workspace. Use only for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection. This is powerful execution and should only be exposed behind strong authentication.`; - -function registerMinimalShellTool(context: ToolRegistrationContext): void { - registerShellTool(context, MINIMAL_SHELL_DESCRIPTION); -} - -function registerFullShellTool(context: ToolRegistrationContext): void { - registerShellTool(context, FULL_SHELL_DESCRIPTION); -} - -function registerStandardMutationTools(context: ToolRegistrationContext): void { - const { server, config, workspaces } = context; - - registerAppTool( - server, - toolNames.write, - { - title: "Write file", - description: `Create or completely overwrite a file in a workspace. Prefer ${toolNames.edit} for targeted changes to existing files.`, - inputSchema: { - workspaceId: z.string().describe(workspaceIdDescription), - path: z - .string() - .describe("File path to write, relative to the workspace root."), - content: z.string().describe("Complete new file content."), - }, - outputSchema: resultOutputSchema(), - ...toolWidgetDescriptorMeta(config, "write"), - annotations: WRITE_TOOL_ANNOTATIONS, - }, - async ({ workspaceId, ...input }) => { - const startedAt = performance.now(); - const workspace = workspaces.getWorkspace(workspaceId); - const absolutePath = workspaces.resolvePath(workspace, input.path); - const overwritesExistingFile = existsSync(absolutePath); - const response = await writeFileTool(input, { - cwd: workspace.root, - root: workspace.root, - }); - - if (response.isError) { - logFailedToolResponse( - config, - { - tool: toolNames.write, - workspaceId, - path: input.path, - }, - response.content, - startedAt, - ); - return response; - } - - // An aggregate review can show the real replacement diff. A new-file - // patch would misrepresent an overwrite as additions with no removals. - const patch = overwritesExistingFile - ? undefined - : newFilePatch(input.path, input.content); - const stats = countDiffStats(patch); - const summary = { - ...stats, - lines: contentLineCount(input.content), - characters: input.content.length, - }; - logToolCall(config, { - tool: toolNames.write, - workspaceId, - path: input.path, - success: true, - durationMs: Math.round(performance.now() - startedAt), - }); - - return { - ...response, - _meta: { - tool: toolNames.write, - card: { - workspaceId, - path: input.path, - summary, - payload: { - content: response.content, - patch, - }, - }, - }, - structuredContent: { - result: contentText(response.content), - }, - }; - }, - ); - - registerAppTool( - server, - toolNames.edit, - { - title: "Edit file", - description: `Edit one file in a workspace by replacing exact text blocks. Prefer this over ${toolNames.write} for targeted changes. Each oldText must match a unique, non-overlapping region of the original file; merge nearby changes into one edit and keep oldText as small as possible while still unique.`, - inputSchema: { - workspaceId: z.string().describe(workspaceIdDescription), - path: z - .string() - .describe("File path to edit, relative to the workspace root."), - edits: z - .array( - z.object({ - oldText: z - .string() - .describe( - "Exact text to replace. Must match uniquely in the original file.", - ), - newText: z.string().describe("Replacement text."), - }), - ) - .min(1), - }, - outputSchema: resultOutputSchema({ - status: z.literal("applied"), - }), - ...toolWidgetDescriptorMeta(config, "edit"), - annotations: EDIT_TOOL_ANNOTATIONS, - }, - async ({ workspaceId, ...input }) => { - const startedAt = performance.now(); - const workspace = workspaces.getWorkspace(workspaceId); - workspaces.resolvePath(workspace, input.path); - const response = await editFileTool(input, { - cwd: workspace.root, - root: workspace.root, - }); - - if (response.isError) { - logFailedToolResponse( - config, - { - tool: toolNames.edit, - workspaceId, - path: input.path, - }, - response.content, - startedAt, - ); - return response; - } - - const stats = countDiffStats( - response.details?.patch ?? response.details?.diff, - ); - const summary = { - ...stats, - editCount: input.edits.length, - }; - const editResultText = `Edited ${input.path} (+${stats.additions} -${stats.removals}).`; - const editContent = [textBlock(editResultText)]; - logToolCall(config, { - tool: toolNames.edit, - workspaceId, - path: input.path, - success: true, - durationMs: Math.round(performance.now() - startedAt), - }); - - return { - content: editContent, - _meta: { - tool: toolNames.edit, - card: { - workspaceId, - path: input.path, - summary, - payload: { - diff: response.details?.diff, - patch: response.details?.patch, - }, - }, - }, - structuredContent: { - status: "applied", - result: contentText(editContent), - }, - }; - }, - ); -} - -function registerSearchTools(context: ToolRegistrationContext): void { - const { server, config, workspaces } = context; - - registerAppTool( - server, - toolNames.grep, - { - title: "Grep", - description: - "Search file contents in a workspace. Use this before broad reads when looking for symbols, text, or usage sites. Respects project ignore rules.", - inputSchema: { - workspaceId: z.string().describe(workspaceIdDescription), - pattern: z.string().describe("Search pattern."), - path: z - .string() - .optional() - .describe( - "Optional path or glob scope relative to the workspace root.", - ), - include: z.string().optional().describe("Optional include glob."), - }, - outputSchema: resultOutputSchema(), - ...toolWidgetDescriptorMeta(config, "search"), - annotations: { readOnlyHint: true }, - }, - async ({ workspaceId, ...input }) => { - const startedAt = performance.now(); - const workspace = workspaces.getWorkspace(workspaceId); - if (input.path) workspaces.resolvePath(workspace, input.path); - const response = await grepFilesTool(input, { - cwd: workspace.root, - root: workspace.root, - }); - - if (response.isError) { - logFailedToolResponse( - config, - { - tool: toolNames.grep, - workspaceId, - path: input.path, - }, - response.content, - startedAt, - ); - return response; - } - - const summary = { - pattern: input.pattern, - scope: input.path ?? ".", - ...textSummary(response.content), - }; - logToolCall(config, { - tool: toolNames.grep, - workspaceId, - path: input.path, - success: true, - durationMs: Math.round(performance.now() - startedAt), - }); - - return { - ...response, - _meta: { - tool: toolNames.grep, - card: { - workspaceId, - path: input.path, - summary, - payload: { content: response.content }, - }, - }, - structuredContent: { - result: contentText(response.content), - }, - }; - }, - ); - - registerAppTool( - server, - toolNames.glob, - { - title: "Glob", - description: - "Find files by glob pattern in a workspace. Use this to discover filenames or narrow file sets before reading. Respects project ignore rules.", - inputSchema: { - workspaceId: z.string().describe(workspaceIdDescription), - pattern: z.string().describe("File glob pattern."), - path: z - .string() - .optional() - .describe("Optional path scope relative to the workspace root."), - }, - outputSchema: resultOutputSchema(), - ...toolWidgetDescriptorMeta(config, "search"), - annotations: { readOnlyHint: true }, - }, - async ({ workspaceId, ...input }) => { - const startedAt = performance.now(); - const workspace = workspaces.getWorkspace(workspaceId); - if (input.path) workspaces.resolvePath(workspace, input.path); - const response = await findFilesTool(input, { - cwd: workspace.root, - root: workspace.root, - }); - - if (response.isError) { - logFailedToolResponse( - config, - { - tool: toolNames.glob, - workspaceId, - path: input.path, - }, - response.content, - startedAt, - ); - return response; - } - - const summary = { - pattern: input.pattern, - scope: input.path ?? ".", - ...textSummary(response.content), - }; - logToolCall(config, { - tool: toolNames.glob, - workspaceId, - path: input.path, - success: true, - durationMs: Math.round(performance.now() - startedAt), - }); - - return { - ...response, - _meta: { - tool: toolNames.glob, - card: { - workspaceId, - path: input.path, - summary, - payload: { content: response.content }, - }, - }, - structuredContent: { - result: contentText(response.content), - }, - }; - }, - ); - - registerAppTool( - server, - toolNames.ls, - { - title: "Ls", - description: - "List a directory in a workspace. Use this for directory inspection before reading files.", - inputSchema: { - workspaceId: z.string().describe(workspaceIdDescription), - path: z - .string() - .describe("Directory path to list, relative to the workspace root."), - }, - outputSchema: resultOutputSchema(), - ...toolWidgetDescriptorMeta(config, "directory"), - annotations: { readOnlyHint: true }, - }, - async ({ workspaceId, ...input }) => { - const startedAt = performance.now(); - const workspace = workspaces.getWorkspace(workspaceId); - workspaces.resolvePath(workspace, input.path); - const response = await listDirectoryTool(input, { - cwd: workspace.root, - root: workspace.root, - }); - - if (response.isError) { - logFailedToolResponse( - config, - { - tool: toolNames.ls, - workspaceId, - path: input.path, - }, - response.content, - startedAt, - ); - return response; - } - - const summary = textSummary(response.content); - logToolCall(config, { - tool: toolNames.ls, - workspaceId, - path: input.path, - success: true, - durationMs: Math.round(performance.now() - startedAt), - }); - - return { - ...response, - _meta: { - tool: toolNames.ls, - card: { - workspaceId, - path: input.path, - summary, - payload: { content: response.content }, - }, - }, - structuredContent: { - result: contentText(response.content), - }, - }; - }, - ); -} - -function registerShellTool( - context: ToolRegistrationContext, - shellDescription: string, -): void { - const { server, config, workspaces } = context; - - registerAppTool( - server, - toolNames.shell, - { - title: "Bash", - description: shellDescription, - inputSchema: { - workspaceId: z.string().describe(workspaceIdDescription), - command: z - .string() - .describe( - `Shell command to run. Must not create or modify project files; use ${toolNames.edit} or ${toolNames.write} for file changes.`, - ), - workingDirectory: z - .string() - .optional() - .describe( - "Optional working directory relative to the workspace root. Defaults to the workspace root.", - ), - timeout: z - .number() - .positive() - .max(300) - .optional() - .describe("Timeout in seconds. Defaults to 30, max 300."), - }, - outputSchema: resultOutputSchema(), - ...toolWidgetDescriptorMeta(config, "shell"), - annotations: SHELL_TOOL_ANNOTATIONS, - }, - async ({ workspaceId, workingDirectory, ...input }) => { - const startedAt = performance.now(); - const workspace = workspaces.getWorkspace(workspaceId); - const cwd = workspaces.resolveWorkingDirectory( - workspace, - workingDirectory, - ); - const response = await runShellTool(input, { - cwd, - root: workspace.root, - }); - - if (response.isError) { - logFailedToolResponse( - config, - { - tool: toolNames.shell, - workspaceId, - workingDirectory: workingDirectory ?? ".", - command: input.command, - commandLength: input.command.length, - }, - response.content, - startedAt, - ); - return response; - } - - const summary = { - command: input.command, - workingDirectory: workingDirectory ?? ".", - ...textSummary(response.content), - }; - logToolCall(config, { - tool: toolNames.shell, - workspaceId, - workingDirectory: workingDirectory ?? ".", - command: input.command, - commandLength: input.command.length, - success: true, - durationMs: Math.round(performance.now() - startedAt), - }); - - return { - ...response, - _meta: { - tool: toolNames.shell, - card: { - workspaceId, - path: workingDirectory, - summary, - payload: { content: response.content }, - }, - }, - structuredContent: { - result: contentText(response.content), - }, - }; - }, - ); -} diff --git a/src/tool-surfaces/types.ts b/src/tool-surfaces/types.ts index c1fc13fd..f10fbd9b 100644 --- a/src/tool-surfaces/types.ts +++ b/src/tool-surfaces/types.ts @@ -10,9 +10,6 @@ export const toolNames = { read: "read", write: "write", edit: "edit", - grep: "grep", - glob: "glob", - ls: "ls", shell: "bash", } as const; @@ -66,8 +63,6 @@ export type ToolWidgetKind = | "read" | "write" | "edit" - | "search" - | "directory" | "shell" | "show_changes"; From 18e32faf26be2f4b0e781a1967134a0700e99f87 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:10:46 +0530 Subject: [PATCH 3/6] docs(tools): document the converged surfaces --- docs/chatgpt-coding-workflow.md | 19 +++++++++---------- docs/configuration.md | 22 +++++++++++++--------- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index d7a5d13c..46beb5c5 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -158,14 +158,7 @@ DevSpace exposes these tool names: - `edit` - `bash` -By default, DevSpace also runs in `DEVSPACE_TOOL_MODE=minimal`, so dedicated -`grep`, `glob`, and `ls` tools are hidden. Use `bash` with command-line tools -such as `rg`, `find`, and `ls` for search and directory inspection. - -Use `DEVSPACE_TOOL_MODE=full` to restore dedicated search and directory tools. - -The experimental Codex-style surface is enabled with -`DEVSPACE_TOOL_MODE=codex`. It exposes: +DevSpace uses the Codex-style surface by default. It exposes: - `open_workspace` - `read` @@ -173,11 +166,17 @@ The experimental Codex-style surface is enabled with - `exec_command` - `write_stdin` -In this mode, `write`, `edit`, `bash`, `grep`, `glob`, and `ls` are not -registered. `exec_command` returns a process session ID when a command is still +In this mode, `write`, `edit`, and `bash` are not registered. `exec_command` +returns a process session ID when a command is still running after its yield window. Use `write_stdin` to poll it, send input, resize a PTY, or send Ctrl-C. Set `tty: true` only for commands that need a terminal. +Set `tools.mode` to `claude` in `~/.devspace/config.json` to expose `write`, +`edit`, and `bash` instead of the Codex mutation and command tools. Dedicated +MCP tools for `grep`, `glob`, and `ls` are not registered in either mode; use +the configured shell tool with command-line tools such as `rg`, `find`, and +`ls`. + ## Show Changes By default, `DEVSPACE_WIDGETS=full`. diff --git a/docs/configuration.md b/docs/configuration.md index 93a3d4fa..f38fbfc2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -91,18 +91,23 @@ MCP clients discover metadata from: ## Tool Modes -`DEVSPACE_TOOL_MODE` controls the tool surface. +`tools.mode` in `~/.devspace/config.json` controls the tool surface: + +```json +{ + "tools": { + "mode": "codex" + } +} +``` | Value | Behavior | | --- | --- | -| `minimal` | Default. Exposes `open_workspace`, `read`, `write`, `edit`, and `bash`. Clients use `bash` with tools such as `rg`, `find`, and `ls` for inspection. | -| `full` | Exposes the minimal tools plus dedicated `grep`, `glob`, and `ls` tools. | -| `codex` | Experimental. Exposes `open_workspace`, `read`, `apply_patch`, `exec_command`, and `write_stdin`. Existing mutation and shell tools are hidden. | +| `codex` | Default. Exposes `open_workspace`, `read`, `apply_patch`, `exec_command`, and `write_stdin`. | +| `claude` | Exposes `open_workspace`, `read`, `write`, `edit`, and `bash`. Clients use `bash` with tools such as `rg`, `find`, and `ls` for inspection. | -`DEVSPACE_MINIMAL_TOOLS` remains a backward-compatible alias when -`DEVSPACE_TOOL_MODE` is unset: `1` selects `minimal` and `0` selects `full`. -The `codex` mode must be selected through `DEVSPACE_TOOL_MODE` and always uses -its fixed short tool names regardless of `DEVSPACE_TOOL_NAMING`. +The dedicated MCP tools `grep`, `glob`, and `ls` are no longer exposed. Both +modes use their shell tool for search, file discovery, and directory inspection. Codex-mode commands run without a PTY by default. Set `tty: true` on `exec_command` for interactive terminal programs. PTY support uses the optional @@ -250,7 +255,6 @@ DEVSPACE_ALLOWED_ROOTS="$HOME/personal,$HOME/work" \ DEVSPACE_PUBLIC_BASE_URL="https://devspace.example.com" \ DEVSPACE_WORKTREE_ROOT="$HOME/.devspace/worktrees" \ DEVSPACE_ARTIFACTS="1" \ -DEVSPACE_TOOL_MODE="minimal" \ DEVSPACE_WIDGETS="full" \ npx @waishnav/devspace serve ``` From 915eff75d3095b2deaad0f2d7a2ac806e14fb8bd Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:05:41 +0530 Subject: [PATCH 4/6] docs(tools): state local shell authority --- src/tool-surfaces/claude.ts | 4 ++-- src/tool-surfaces/codex.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tool-surfaces/claude.ts b/src/tool-surfaces/claude.ts index eadd566a..8c590e9c 100644 --- a/src/tool-surfaces/claude.ts +++ b/src/tool-surfaces/claude.ts @@ -28,7 +28,7 @@ import { toolWidgetDescriptorMeta, } from "./shared.js"; -const CLAUDE_INSTRUCTIONS = `Use ${toolNames.read} for direct file reads, ${toolNames.shell} with command-line tools such as rg, find, ls, and tree for search and directory inspection, ${toolNames.edit} for targeted modifications, and ${toolNames.write} only for new files or complete rewrites. Use ${toolNames.shell} for tests, builds, git inspection, package scripts, and other commands, but do not create or modify files through shell commands. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.`; +const CLAUDE_INSTRUCTIONS = `Use ${toolNames.read} for direct file reads, ${toolNames.shell} with command-line tools such as rg, find, ls, and tree for search and directory inspection, ${toolNames.edit} for targeted modifications, and ${toolNames.write} only for new files or complete rewrites. Use ${toolNames.shell} for tests, builds, git inspection, package scripts, and other commands, but do not create or modify files through shell commands. Shell commands run with the local user's authority and are not sandboxed; workspace validation only selects their initial working directory. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.`; export function claudeInstructions({ agents, @@ -42,7 +42,7 @@ export function registerClaudeTools(context: ToolRegistrationContext): void { registerShellTool(context); } -const CLAUDE_SHELL_DESCRIPTION = `Run a shell command in a workspace. Use it for tests, builds, git inspection, package scripts, search, file discovery, and directory inspection. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read} for direct file reads. This is powerful execution and should only be exposed behind strong authentication.`; +const CLAUDE_SHELL_DESCRIPTION = `Run a shell command in a workspace with the local user's authority. Commands are not sandboxed; workspace validation only selects the initial working directory. Use it for tests, builds, git inspection, package scripts, search, file discovery, and directory inspection. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read} for direct file reads. This is powerful execution and should only be exposed behind strong authentication.`; function registerClaudeMutationTools(context: ToolRegistrationContext): void { const { server, config, workspaces } = context; diff --git a/src/tool-surfaces/codex.ts b/src/tool-surfaces/codex.ts index 42d2ae12..3007656e 100644 --- a/src/tool-surfaces/codex.ts +++ b/src/tool-surfaces/codex.ts @@ -20,7 +20,7 @@ import { type CodexRegistration = (context: ToolRegistrationContext) => void; -const CODEX_INSTRUCTIONS = `Use ${toolNames.read} for direct file reads, apply_patch for all file modifications, exec_command for inspection, tests, builds, and other commands, and write_stdin to poll or interact with running processes. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.`; +const CODEX_INSTRUCTIONS = `Use ${toolNames.read} for direct file reads, apply_patch for all file modifications, exec_command for inspection, tests, builds, and other commands, and write_stdin to poll or interact with running processes. Commands run with the local user's authority and are not sandboxed; workspace validation only selects their initial working directory. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.`; export function codexInstructions(): string { return CODEX_INSTRUCTIONS; @@ -179,7 +179,7 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { { title: "Execute command", description: - "Run a command in a workspace. Returns its result when it exits during the yield window, otherwise returns a sessionId for write_stdin. Use this for file inspection, tests, builds, package scripts, and long-running processes.", + "Run a command with the local user's authority. Commands are not sandboxed; workspace validation only selects the initial working directory. Returns the result when it exits during the yield window, otherwise returns a sessionId for write_stdin. Use this for file inspection, tests, builds, package scripts, and long-running processes.", inputSchema: { workspaceId: z.string().describe(workspaceIdDescription), cmd: z.string().min(1).describe("Shell command to execute."), From b4f631ff951874507e385a6ca61c28a601f97420 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:05:52 +0530 Subject: [PATCH 5/6] docs(tools): qualify the Claude inventory --- docs/chatgpt-coding-workflow.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 46beb5c5..ed1fd9fa 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -150,7 +150,7 @@ sessions for that workspace. ## Tool Names -DevSpace exposes these tool names: +The Claude surface exposes these tool names: - `open_workspace` - `read` From 9236e28e0ad25abd52612e762929c6c3b689c17c Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:06:00 +0530 Subject: [PATCH 6/6] docs(config): record tool mode env removal --- docs/configuration.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index f38fbfc2..4ce9c95a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -101,6 +101,10 @@ MCP clients discover metadata from: } ``` +`DEVSPACE_TOOL_MODE` and `DEVSPACE_MINIMAL_TOOLS` are no longer read. Set +`tools.mode` in the configuration file when selecting the Claude surface; +omitting it selects Codex. + | Value | Behavior | | --- | --- | | `codex` | Default. Exposes `open_workspace`, `read`, `apply_patch`, `exec_command`, and `write_stdin`. |