From 68fb44f5b0cfb167f16285e331fc030223e10ac9 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 22 Oct 2026 13:40:04 -0700 Subject: [PATCH 1/3] fix: stage outside patches under the gitignored .patchloom directory An outside patch was copied to .patchloom-/ at the workspace root. .gitignore only ignores .patchloom/, so the copy showed up in source control during preview and stayed if the editor quit first. npm run check: 505 tests, 92.97% lines. Signed-off-by: Sebastien Tardif --- README.md | 2 +- src/commands/quickActions.ts | 7 ++++++- test/unit/quickActions.test.ts | 1 + 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f4eb088..47bbf76 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ Click it to run **Show Status**. That command writes the full report to the Outp Content fields open a scratch file so newlines stay newlines. Pattern and anchor fields start with the current selection when it is a single line. Preview copies the file under the workspace and runs the CLI with the workspace as its working directory, so `.patchloom.toml` format commands and EditorConfig match Apply. The preview copy is removed afterward. The diff uses a `patchloom-preview` document, which is not an untitled buffer and is not saved. A preview that finds no pattern says so. A command that exits cleanly and changes nothing says the command made no edit. Search still writes its text to the Output channel, and then offers the hits in a list that opens the file at the matching line. Files without a match open at line 1. -Workspace Quick Actions and Batch Apply pass `--contain` so CLI paths stay inside the workspace root (CLI 0.10+). Containment is relative to the effective working directory (the workspace folder). Patch apply and patch merge ask for a file or the clipboard, stage that text inside the workspace, show a dry run, and write only after a second confirmation. A dry run that would change files exits 2 with `ok: true` and `applied: false`. That result still reaches the write confirmation. A bad patch or a stale hunk stops before that confirmation. A three-way merge that conflicts, when conflict markers were not allowed, also stops. Nothing is written. Run it again and choose Allow conflict markers to write them. An empty clipboard is an error and does not write. Cancel leaves the files unchanged. A path inside that patch that leaves the workspace is rejected. +Workspace Quick Actions and Batch Apply pass `--contain` so CLI paths stay inside the workspace root (CLI 0.10+). Containment is relative to the effective working directory (the workspace folder). Patch apply and patch merge ask for a file or the clipboard, copy that text under `.patchloom/` (already gitignored), show a dry run, and write only after a second confirmation. A dry run that would change files exits 2 with `ok: true` and `applied: false`. That result still reaches the write confirmation. A bad patch or a stale hunk stops before that confirmation. A three-way merge that conflicts, when conflict markers were not allowed, also stops. Nothing is written. Run it again and choose Allow conflict markers to write them. An empty clipboard is an error and does not write. Cancel leaves the files unchanged. A path inside that patch that leaves the workspace is rejected. ### Batch operations diff --git a/src/commands/quickActions.ts b/src/commands/quickActions.ts index 2c767eb..6de5114 100644 --- a/src/commands/quickActions.ts +++ b/src/commands/quickActions.ts @@ -2457,7 +2457,12 @@ export async function stageExternalPatchInWorkspace( return { patchPath, cleanup: async () => {} }; } - const tempDir = await fs.mkdtemp(path.join(workspaceRoot, ".patchloom-")); + // `.gitignore` ignores `.patchloom/` only. A sibling `.patchloom-*` directory + // shows up in source control for the whole preview, and stays if VS Code quits + // before cleanup. + const scratch = path.join(workspaceRoot, ".patchloom"); + await fs.mkdir(scratch, { recursive: true }); + const tempDir = await fs.mkdtemp(path.join(scratch, "qa-patch-")); const stagedPath = path.join(tempDir, path.basename(patchPath)); try { await fs.copyFile(patchPath, stagedPath); diff --git a/test/unit/quickActions.test.ts b/test/unit/quickActions.test.ts index a7ad45e..4870039 100644 --- a/test/unit/quickActions.test.ts +++ b/test/unit/quickActions.test.ts @@ -1210,6 +1210,7 @@ test("stageExternalPatchInWorkspace copies an outside patch into the workspace", try { assert.notEqual(staged.patchPath, outsidePatch); assert.equal(isPathInsideWorkspace(workspaceRoot, staged.patchPath), true); + assert.ok(staged.patchPath.startsWith(path.join(workspaceRoot, ".patchloom") + path.sep)); assert.equal(await fs.readFile(staged.patchPath, "utf8"), "diff --git a/x b/x\n"); } finally { await staged.cleanup(); From 1611cc8cbc3ed9020998696cfdba00566a14337e Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 22 Oct 2026 13:47:47 -0700 Subject: [PATCH 2/3] fix: quote Windows shim arguments that end in a backslash cmd.exe treats a trailing backslash as an escape. Through patchloom.cmd that joined the path to the next argument, or ate the closing quote. Those arguments are quoted and the trailing slashes are doubled. npm run check: 505 tests, 92.96% lines. Signed-off-by: Sebastien Tardif --- README.md | 2 +- src/binary/launch.ts | 7 +++++-- test/unit/launch.test.ts | 2 ++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 47bbf76..09f90af 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Or search for **Patchloom** in the Extensions view (`Ctrl+Shift+X` / `Cmd+Shift+ scoop bucket add patchloom https://github.com/patchloom/scoop-bucket scoop install patchloom # Windows (Scoop; preferred PATH channel) ``` - On Windows, prefer the extension managed installer or Scoop. `npm install -g patchloom` is supported: it puts `patchloom.cmd` on `PATH`, and the extension launches that shim through `cmd.exe`. If Show Status says `spawn EINVAL`, the extension build is one that does not launch `.cmd` shims. Avoid winget and Chocolatey for install or upgrade: both lag GitHub Releases and often leave you on an old CLI. The extension activates when the window finishes starting, including in a folder that has no `AGENTS.md` yet. Hiding the status bar does not hide the command palette entries. + On Windows, prefer the extension managed installer or Scoop. `npm install -g patchloom` is supported: it puts `patchloom.cmd` on `PATH`, and the extension launches that shim through `cmd.exe`. A path that ends in a backslash is quoted so `cmd.exe` does not swallow the next argument. If Show Status says `spawn EINVAL`, the extension build is one that does not launch `.cmd` shims. Avoid winget and Chocolatey for install or upgrade: both lag GitHub Releases and often leave you on an old CLI. The extension activates when the window finishes starting, including in a folder that has no `AGENTS.md` yet. Hiding the status bar does not hide the command palette entries. 2. Open a project and run **Patchloom: Setup Workspace**

diff --git a/src/binary/launch.ts b/src/binary/launch.ts index 67ee238..378c05b 100644 --- a/src/binary/launch.ts +++ b/src/binary/launch.ts @@ -98,8 +98,11 @@ function quoteCmdArgument(value: string): string { ); } const escaped = value.replaceAll("%", "%%"); - if (escaped.length === 0 || /[\s"&|<>^()]/.test(escaped)) { - return `"${escaped.replaceAll("\"", "\"\"")}"`; + // A trailing \ escapes the next character. Unquoted, that is the space + // before the next argument. Quoted, it escapes the closing quote. + if (escaped.length === 0 || /[\s"&|<>^()]/.test(escaped) || escaped.endsWith("\\")) { + const quoted = escaped.replaceAll("\"", "\"\"").replace(/\\+$/, (run) => run + run); + return `"${quoted}"`; } return escaped; } diff --git a/test/unit/launch.test.ts b/test/unit/launch.test.ts index c4fccd3..3ee3c01 100644 --- a/test/unit/launch.test.ts +++ b/test/unit/launch.test.ts @@ -42,6 +42,8 @@ test("patchloomLaunch runs cmd and bat shims through ComSpec without shell", () const inner = spaced.args[3]?.slice(1, -1) ?? ""; assert.match(inner, /^"C:\\Users\\Ada Lovelace\\npm\\patchloom.cmd"/); + const trailingSlash = patchloomLaunch("C:\\npm\\patchloom.cmd", ["C:\\work\\"], "win32", "cmd.exe"); + assert.match(trailingSlash.args[3] ?? "", /"C:\\work\\\\"/); const percent = patchloomLaunch("C:\\npm\\patchloom.cmd", ["--new=%PATH%"], "win32", "cmd.exe"); assert.match(percent.args[3] ?? "", /%%PATH%%/); assert.throws( From ed4df9360980f2585180c88204b74346a55b545b Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 22 Oct 2026 13:51:06 -0700 Subject: [PATCH 3/3] fix: cancel MCP setup when the second-server warning is dismissed Escape on that question took the same path as Skip that file, so the other selected configs were still written. Dismiss now cancels the command. Skip still drops only the VS Code workspace file. npm run check: 506 tests, 92.97% lines. Signed-off-by: Sebastien Tardif --- README.md | 2 +- src/commands/configureMcp.ts | 14 +++++++++----- src/mcp/config.ts | 19 +++++++++++++++++++ test/unit/mcpConfig.test.ts | 9 +++++++++ 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 09f90af..ba7386b 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Run `Patchloom: Setup Workspace` to walk through everything your project needs: - **Cursor** (`.cursor/mcp.json`) - **Windsurf** (`~/.codeium/windsurf/mcp_config.json`) -When configuring, pick **Full tool inventory** (default) or **Core pack**. Core sets `PATCHLOOM_MCP_SURFACE=core` on the server entry. Workspace files (VS Code, portable, and Cursor) store `command` as `patchloom`, not a machine path. A stale absolute path is rewritten. The Windsurf user file may still store the resolved binary path. VS Code also starts a native Patchloom server. Writing `.vscode/mcp.json` adds a second server next to that provider, and the command asks before it writes that file. The native server follows the `patchloom.mcpSurface` setting (`full` or `core`). Existing servers in JSON or JSONC (`//` comments, trailing commas) stay in the file. A config that is not an object is left unchanged and the command reports an error. If an existing file cannot be read, the command reports that error and does not replace it. A missing file is still created. +When configuring, pick **Full tool inventory** (default) or **Core pack**. Core sets `PATCHLOOM_MCP_SURFACE=core` on the server entry. Workspace files (VS Code, portable, and Cursor) store `command` as `patchloom`, not a machine path. A stale absolute path is rewritten. The Windsurf user file may still store the resolved binary path. VS Code also starts a native Patchloom server. Writing `.vscode/mcp.json` adds a second server next to that provider, and the command asks before it writes that file. Skip that file continues with the other selected configs. Dismissing the question cancels the command and writes nothing. The native server follows the `patchloom.mcpSurface` setting (`full` or `core`). Existing servers in JSON or JSONC (`//` comments, trailing commas) stay in the file. A config that is not an object is left unchanged and the command reports an error. If an existing file cannot be read, the command reports that error and does not replace it. A missing file is still created. CLI **0.37.5** exposes **66** MCP tools by default (including `explain_plan`, `tidy_check`, `list_files`, `apply_fragment`, `notebook_edit`, and `operation_schema`). The core pack is 12 tools on CLI 0.37+: `read_file`, `search_files`, `list_files`, `replace_text`, `batch_replace`, `doc_get`, `doc_set`, `doc_query`, `md_replace_section`, `execute_plan`, `operation_schema`, `server_info`. `doc` accepts `.jsonc` (comments stay on set), plus `.env`, `.ini`, and `.properties`. `doc_set` accepts `if_exists` to skip a missing file or selector. `apply_patch` accepts `apply=false` for a check-only preview. CLI `list-files` inventories ignore-aware paths the same way as MCP `list_files`. `search_files` accepts `files_without_match` (CLI 0.29+). `apply_patch` accepts unified diffs, Codex `*** Begin Patch`, and Aider SEARCH/REPLACE (CLI 0.30+). `doc_query` can list object keys and count array or object length (CLI 0.32+). Search, replace, and tidy dests accept cwd-only globs such as `*.txt` (CLI 0.33+; use `**/*.txt` or `--glob` for nested files). Absolute paths that resolve inside the MCP workspace root are allowed; empty paths, `../`, and outside paths still reject with stable `error_kind` peels. diff --git a/src/commands/configureMcp.ts b/src/commands/configureMcp.ts index 013a567..cc9028b 100644 --- a/src/commands/configureMcp.ts +++ b/src/commands/configureMcp.ts @@ -7,6 +7,7 @@ import { configureMcpTargets, formatMcpConfigureFailureMessage, inspectMcpTargets, + mcpKindsAfterSecondServerWarning, McpConfigureError, readMcpConfigText } from "../mcp/config.js"; @@ -76,12 +77,15 @@ export async function configureMcp(preset?: vscode.WorkspaceFolder): Promise selection.target.kind !== "vscode-workspace"); - if (chosen.length === 0) { - return false; - } + const choice = proceed === "Write file" ? "write" : proceed === "Skip that file" ? "skip" : undefined; + const remaining = mcpKindsAfterSecondServerWarning( + chosen.map((selection) => selection.target.kind), + choice + ); + if (!remaining) { + return false; } + chosen = chosen.filter((selection) => remaining.includes(selection.target.kind)); } const selectedKinds = chosen.map((selection) => selection.target.kind); diff --git a/src/mcp/config.ts b/src/mcp/config.ts index b8ecd60..2bd1f50 100644 --- a/src/mcp/config.ts +++ b/src/mcp/config.ts @@ -26,6 +26,25 @@ export interface McpTargetResult extends McpTargetStatus { readonly changed: boolean; } +/** + * The second-server warning has two buttons. Dismissing it is cancel, not + * "skip that file": Escape must not write the other selected configs. + * Skip drops only the VS Code workspace file. If nothing remains, cancel. + */ +export function mcpKindsAfterSecondServerWarning( + kinds: readonly string[], + choice: "write" | "skip" | undefined +): readonly string[] | undefined { + if (choice !== "write" && choice !== "skip") { + return undefined; + } + if (choice === "write") { + return kinds; + } + const remaining = kinds.filter((kind) => kind !== "vscode-workspace"); + return remaining.length === 0 ? undefined : remaining; +} + /** A configure stop after zero or more targets were already written. */ export class McpConfigureError extends Error { readonly completed: readonly McpTargetResult[]; diff --git a/test/unit/mcpConfig.test.ts b/test/unit/mcpConfig.test.ts index 66a93bb..a68cb44 100644 --- a/test/unit/mcpConfig.test.ts +++ b/test/unit/mcpConfig.test.ts @@ -10,6 +10,7 @@ import { formatMcpConfigureFailureMessage, inspectMcpTargets, mcpCommandForTarget, + mcpKindsAfterSecondServerWarning, McpConfigureError, patchloomEntryIsCurrent, readPatchloomMcpCommand, @@ -687,6 +688,14 @@ test("configureMcpTargets leaves an unreadable config file unchanged", async (t) }); }); +test("dismissing the second-server warning cancels, and skip drops only VS Code", () => { + const kinds = ["vscode-workspace", "portable-workspace"]; + assert.equal(mcpKindsAfterSecondServerWarning(kinds, undefined), undefined); + assert.deepEqual(mcpKindsAfterSecondServerWarning(kinds, "write"), kinds); + assert.deepEqual(mcpKindsAfterSecondServerWarning(kinds, "skip"), ["portable-workspace"]); + assert.equal(mcpKindsAfterSecondServerWarning(["vscode-workspace"], "skip"), undefined); +}); + test("mcpCommandForTarget keeps workspace configs portable and windsurf absolute", () => { assert.equal(mcpCommandForTarget("vscode-workspace", "/usr/local/bin/patchloom"), "patchloom"); assert.equal(mcpCommandForTarget("portable-workspace", "/opt/patchloom"), "patchloom");