diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 4e001e46..59803fe9 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -171,15 +171,20 @@ DevSpace uses the Codex-style surface by default. It exposes: - `open_workspace` - `read` - `apply_patch` -- `exec_command` +- `exec_cmd` - `write_stdin` - `show_changes` -In this mode, `write`, `edit`, and `bash` are not registered. `exec_command` +In this mode, `write`, `edit`, and `bash` are not registered. `exec_cmd` 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.fileRead` to `shell` to omit the dedicated `read` tool. File, +instruction, and skill inspection then goes through the configured shell tool: +`exec_cmd` in Codex mode or `bash` in Claude mode. The default is `tool`, which +keeps `read` exposed. + Set `tools.mode` to `claude` in `~/.devspace/config.jsonc` 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 diff --git a/docs/configuration.md b/docs/configuration.md index 98f0d124..8aaae313 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -39,6 +39,7 @@ Run `devspace init` to create both files. `devspace config set publicBaseUrl }, "tools": { "mode": "codex", + "fileRead": "tool", }, "ui": { "enabled": true, @@ -95,9 +96,15 @@ After restarting, refresh tokens for removed aliases can no longer mint tokens. | Value | Tool surface | | --- | --- | -| `codex` | Default. `open_workspace`, `read`, `apply_patch`, `exec_command`, `write_stdin`, and `show_changes`. | +| `codex` | Default. `open_workspace`, `read`, `apply_patch`, `exec_cmd`, `write_stdin`, and `show_changes`. | | `claude` | `open_workspace`, `read`, `write`, `edit`, `bash`, and `show_changes`. | +`tools.fileRead` controls how file contents are inspected. The default `tool` +value exposes the dedicated `read` tool. Set it to `shell` to omit `read` and +use the active shell tool instead: `exec_cmd` in Codex mode or `bash` in Claude +mode. This also directs workspace instruction and skill reads through that +shell tool. + The dedicated MCP tools `grep`, `glob`, and `ls` are not exposed. Each mode uses its shell tool with programs such as `rg`, `find`, and `ls` when it needs those operations. diff --git a/schema/v1/devspace.schema.json b/schema/v1/devspace.schema.json index cf9ce8f8..b478f4ee 100644 --- a/schema/v1/devspace.schema.json +++ b/schema/v1/devspace.schema.json @@ -99,6 +99,14 @@ "claude", "codex" ] + }, + "fileRead": { + "default": "tool", + "type": "string", + "enum": [ + "tool", + "shell" + ] } }, "additionalProperties": false diff --git a/src/config-schema.ts b/src/config-schema.ts index 95141fbd..16cb74f1 100644 --- a/src/config-schema.ts +++ b/src/config-schema.ts @@ -24,6 +24,7 @@ const storageConfigSchema = z.object({ const toolsConfigSchema = z.object({ mode: z.enum(["claude", "codex"]).default("codex"), + fileRead: z.enum(["tool", "shell"]).default("tool"), }).strict().prefault({}); const uiConfigSchema = z.object({ @@ -90,6 +91,7 @@ export const devspaceConfigSchema = z.object({ export type DevspaceConfig = z.output; export type DevspaceConfigInput = z.input; export type ToolMode = DevspaceConfig["tools"]["mode"]; +export type FileReadMode = DevspaceConfig["tools"]["fileRead"]; export function defaultDevspaceConfig(): DevspaceConfig { return devspaceConfigSchema.parse({ configVersion: DEVSPACE_CONFIG_VERSION }); diff --git a/src/config.test.ts b/src/config.test.ts index ddb2fc1c..4972913b 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -19,6 +19,7 @@ try { assert.deepEqual(defaults.allowedRoots, [process.cwd()]); assert.deepEqual(defaults.allowedHosts, ["localhost", "127.0.0.1", "::1"]); assert.equal(defaults.toolMode, "codex"); + assert.equal(defaults.fileReadMode, "tool"); assert.equal(defaults.uiEnabled, true); assert.equal(defaults.skillsEnabled, true); assert.equal(defaults.artifactsEnabled, false); @@ -52,7 +53,7 @@ try { worktreeRoot: "~/trees", }, storage: { stateDir: "~/state" }, - tools: { mode: "claude" }, + tools: { mode: "claude", fileRead: "shell" }, ui: { enabled: false }, artifacts: { enabled: true, maxFileBytes: 321 }, skills: { enabled: false, paths: ["~/skills"], agentDir: "~/agent" }, @@ -94,6 +95,7 @@ try { "example.internal", ]); assert.equal(configured.toolMode, "claude"); + assert.equal(configured.fileReadMode, "shell"); assert.equal(configured.uiEnabled, false); assert.equal(configured.stateDir, resolve(homedir(), "state")); assert.equal(configured.worktreeRoot, resolve(homedir(), "trees")); diff --git a/src/config.ts b/src/config.ts index 34fcdfc2..ae26f19a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,12 +1,12 @@ import { resolve } from "node:path"; -import type { ToolMode } from "./config-schema.js"; +import type { FileReadMode, ToolMode } from "./config-schema.js"; import { expandHomePath } from "./roots.js"; import type { LoggingConfig } from "./logger.js"; import type { OAuthConfig } from "./oauth-provider.js"; import { devspaceAgentsDir, devspaceSkillsDir, loadDevspaceFiles } from "./user-config.js"; import type { SubagentsConfig } from "./local-agent-config.js"; -export type { ToolMode } from "./config-schema.js"; +export type { FileReadMode, ToolMode } from "./config-schema.js"; export interface ServerConfig { configDir: string; @@ -17,6 +17,7 @@ export interface ServerConfig { allowedHosts: string[]; publicBaseUrl: string; toolMode: ToolMode; + fileReadMode: FileReadMode; uiEnabled: boolean; stateDir: string; worktreeRoot: string; @@ -66,6 +67,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { allowedHosts: normalizeAllowedHosts(derivedAllowedHosts), publicBaseUrl, toolMode: stored.tools.mode, + fileReadMode: stored.tools.fileRead, uiEnabled: stored.ui.enabled, stateDir: normalizePath(stored.storage.stateDir), worktreeRoot: normalizePath(stored.workspaces.worktreeRoot), diff --git a/src/server.test.ts b/src/server.test.ts index 7f9fe15c..69b44fff 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -24,6 +24,7 @@ const execFileAsync = promisify(execFile); test("tool modes expose the expected host-facing tool surface", async (t) => { const cases: Array<{ mode: ToolMode; + fileReadMode?: ServerConfig["fileReadMode"]; expected: string[]; }> = [ { @@ -32,13 +33,27 @@ test("tool modes expose the expected host-facing tool surface", async (t) => { }, { mode: "codex", - expected: ["open_workspace", "read", "apply_patch", "exec_command", "write_stdin", "show_changes"], + expected: ["open_workspace", "read", "apply_patch", "exec_cmd", "write_stdin", "show_changes"], + }, + { + mode: "claude", + fileReadMode: "shell", + expected: ["open_workspace", "write", "edit", "bash", "show_changes"], + }, + { + mode: "codex", + fileReadMode: "shell", + expected: ["open_workspace", "apply_patch", "exec_cmd", "write_stdin", "show_changes"], }, ]; - for (const { mode, expected } of cases) { - await t.test(mode, async (nested) => { - const context = await fixture(nested, { toolMode: mode, uiEnabled: false }); + for (const { mode, fileReadMode, expected } of cases) { + await t.test(`${mode}/${fileReadMode ?? "tool"}`, async (nested) => { + const context = await fixture(nested, { + toolMode: mode, + fileReadMode, + uiEnabled: false, + }); const tools = await context.client.listTools(); assert.deepEqual( @@ -72,7 +87,7 @@ test("Codex process tools bound model-facing yield windows to 12 seconds", async const context = await fixture(t, { toolMode: "codex", uiEnabled: false }); const tools = await context.client.listTools(); - for (const toolName of ["exec_command", "write_stdin"] as const) { + for (const toolName of ["exec_cmd", "write_stdin"] as const) { const tool = tools.tools.find(({ name }) => name === toolName); const yieldSchema = tool?.inputSchema?.properties?.yield_time_ms as { maximum?: number; @@ -537,7 +552,7 @@ test("server shutdown waits for an active MCP tool call", async (t) => { accessToken, "tools/call", { - name: "exec_command", + name: "exec_cmd", arguments: { workspace_id: workspaceId, cmd: `node -e \"${command}\"`, @@ -644,6 +659,7 @@ async function fixture( localAgentProviders?: LocalAgentProviderAvailability[] | (() => LocalAgentProviderAvailability[]); subagents?: SubagentsConfig; toolMode?: ToolMode; + fileReadMode?: ServerConfig["fileReadMode"]; uiEnabled?: boolean; } = {}, ): Promise { @@ -690,6 +706,7 @@ async function fixture( const modeConfig: ServerConfig = { ...loadedConfig, toolMode: options.toolMode ?? loadedConfig.toolMode, + fileReadMode: options.fileReadMode ?? loadedConfig.fileReadMode, uiEnabled: options.uiEnabled ?? loadedConfig.uiEnabled, }; const config: ServerConfig = options.localAgentProviders diff --git a/src/server.ts b/src/server.ts index 608498a4..abae97be 100644 --- a/src/server.ts +++ b/src/server.ts @@ -124,6 +124,12 @@ function serverInstructions( config: ServerConfig, toolSurface: ToolSurface, ): string { + const fileReadToolName = config.fileReadMode === "tool" + ? toolNames.read + : toolSurface.shellToolName; + const skillReadInstruction = config.fileReadMode === "tool" + ? `use ${toolNames.read} with the returned skill path` + : `use ${toolSurface.shellToolName} to read the returned skill path`; const artifactInstruction = config.artifactsEnabled && isArtifactDownloadSupportedPlatform() ? " When the user provides an attached or generated file that needs to be added to the workspace, pass the provided file directly to download_artifact with the existing workspace_id and a suitable relative destination path. Do not reconstruct attached files manually." @@ -131,9 +137,9 @@ function serverInstructions( const showChangesInstruction = " If files are modified, call show_changes once after the final related change and before the final response."; const skills = config.skillsEnabled - ? `When ${toolNames.openWorkspace} returns available skills and a task matches one, use ${toolNames.read} with the returned skill path before proceeding. ` + ? `When ${toolNames.openWorkspace} returns available skills and a task matches one, ${skillReadInstruction} before proceeding. ` : ""; - const agents = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in available_agents_files, use ${toolNames.read} to inspect that instruction file and follow it. `; + const agents = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in available_agents_files, use ${fileReadToolName} to inspect that instruction file and follow it. `; const common = `Call ${toolNames.openWorkspace} when starting work in a project folder or isolated worktree without a usable workspace_id, then reuse the returned workspace_id for subsequent operations in that workspace.`; return `${common} ${toolSurface.instructions({ agents, skills })}${artifactInstruction}${showChangesInstruction}`; @@ -613,7 +619,7 @@ function registerMcpSurface( }, ); - registrationTarget.registerTool( + if (config.fileReadMode === "tool") registrationTarget.registerTool( toolNames.read, { title: "Read file", diff --git a/src/tool-surfaces/claude.ts b/src/tool-surfaces/claude.ts index d7d17f65..a712ffa6 100644 --- a/src/tool-surfaces/claude.ts +++ b/src/tool-surfaces/claude.ts @@ -22,13 +22,11 @@ import { textBlock, } from "./shared.js"; -const CLAUDE_INSTRUCTIONS = `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}`; + return `${agents}${skills}`; } export function registerClaudeTools(context: ToolRegistrationContext): void { @@ -36,8 +34,6 @@ export function registerClaudeTools(context: ToolRegistrationContext): void { registerShellTool(context); } -const CLAUDE_SHELL_DESCRIPTION = "Run a shell command in a workspace with the user's local permissions."; - function registerClaudeMutationTools(context: ToolRegistrationContext): void { const { server, config, workspaces } = context; @@ -187,7 +183,10 @@ function registerShellTool(context: ToolRegistrationContext): void { toolNames.shell, { title: "Bash", - description: CLAUDE_SHELL_DESCRIPTION, + description: + config.fileReadMode === "shell" + ? "Run a shell command in a workspace with the user's local permissions, including commands that inspect or read files." + : "Run a shell command in a workspace with the user's local permissions.", inputSchema: { workspace_id: z.string().describe(workspaceIdDescription), command: z diff --git a/src/tool-surfaces/codex.ts b/src/tool-surfaces/codex.ts index 23b3e853..6b03a3b2 100644 --- a/src/tool-surfaces/codex.ts +++ b/src/tool-surfaces/codex.ts @@ -9,6 +9,7 @@ import { SHELL_TOOL_ANNOTATIONS, toolNames, workspaceIdDescription, + type ToolInstructionContext, type ToolRegistrationContext, } from "./types.js"; import { @@ -20,10 +21,11 @@ import { type CodexRegistration = (context: ToolRegistrationContext) => void; -const CODEX_INSTRUCTIONS = `Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.`; - -export function codexInstructions(): string { - return CODEX_INSTRUCTIONS; +export function codexInstructions({ + agents, + skills, +}: ToolInstructionContext): string { + return `${agents}${skills}`; } export function registerCodexTools(context: ToolRegistrationContext): void { @@ -142,11 +144,13 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { const { server, config, workspaces, processSessions } = context; server.registerTool( - "exec_command", + toolNames.exec, { title: "Execute command", description: - "Run a shell command in a workspace with the user's local permissions. Returns the result when it exits during the yield window, otherwise returns a session_id for write_stdin.", + config.fileReadMode === "shell" + ? "Run a shell command in a workspace with the user's local permissions, including commands that inspect or read files. Returns the result when it exits during the yield window, otherwise returns a session_id for write_stdin." + : "Run a shell command in a workspace with the user's local permissions. Returns the result when it exits during the yield window, otherwise returns a session_id for write_stdin.", inputSchema: { workspace_id: z.string().describe(workspaceIdDescription), cmd: z.string().min(1).describe("Shell command to execute."), @@ -214,7 +218,7 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { const snapshot = await runLoggedToolOperation( config, { - tool: "exec_command", + tool: toolNames.exec, workspaceId, workingDirectory: workingDirectory ?? ".", command: cmd, @@ -250,14 +254,14 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { { title: "Write to process", description: - "Poll or write characters to a process returned by exec_command. Omit chars or pass an empty string to poll. Pass \\u0003 to send Ctrl-C.", + `Poll or write characters to a process returned by ${toolNames.exec}. Omit chars or pass an empty string to poll. Pass \\u0003 to send Ctrl-C.`, inputSchema: { workspace_id: z .string() .describe("Workspace identifier used to start the process."), session_id: z .number() - .describe("Process session identifier returned by exec_command."), + .describe(`Process session identifier returned by ${toolNames.exec}.`), chars: z .string() .optional() diff --git a/src/tool-surfaces/index.ts b/src/tool-surfaces/index.ts index f86a6e11..5f68a4c6 100644 --- a/src/tool-surfaces/index.ts +++ b/src/tool-surfaces/index.ts @@ -1,14 +1,16 @@ import type { ToolMode } from "../config.js"; import { codexInstructions, registerCodexTools } from "./codex.js"; import { claudeInstructions, registerClaudeTools } from "./claude.js"; -import { type ToolSurface } from "./types.js"; +import { toolNames, type ToolSurface } from "./types.js"; const TOOL_SURFACES: Record = { claude: { + shellToolName: toolNames.shell, register: registerClaudeTools, instructions: claudeInstructions, }, codex: { + shellToolName: toolNames.exec, register: registerCodexTools, instructions: codexInstructions, }, diff --git a/src/tool-surfaces/types.ts b/src/tool-surfaces/types.ts index 62f1add6..77dca295 100644 --- a/src/tool-surfaces/types.ts +++ b/src/tool-surfaces/types.ts @@ -11,6 +11,7 @@ export const toolNames = { write: "write", edit: "edit", shell: "bash", + exec: "exec_cmd", } as const; export const workspaceIdDescription = @@ -86,6 +87,7 @@ export interface ToolInstructionContext { } export interface ToolSurface { + shellToolName: string; register(context: ToolRegistrationContext): void; instructions(context: ToolInstructionContext): string; }