Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

<p align="center">
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down
7 changes: 5 additions & 2 deletions src/binary/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
14 changes: 9 additions & 5 deletions src/commands/configureMcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
configureMcpTargets,
formatMcpConfigureFailureMessage,
inspectMcpTargets,
mcpKindsAfterSecondServerWarning,
McpConfigureError,
readMcpConfigText
} from "../mcp/config.js";
Expand Down Expand Up @@ -76,12 +77,15 @@ export async function configureMcp(preset?: vscode.WorkspaceFolder): Promise<boo
"Write file",
"Skip that file"
);
if (proceed !== "Write file") {
chosen = chosen.filter((selection) => 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);
Expand Down
7 changes: 6 additions & 1 deletion src/commands/quickActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
19 changes: 19 additions & 0 deletions src/mcp/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
2 changes: 2 additions & 0 deletions test/unit/launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
9 changes: 9 additions & 0 deletions test/unit/mcpConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
formatMcpConfigureFailureMessage,
inspectMcpTargets,
mcpCommandForTarget,
mcpKindsAfterSecondServerWarning,
McpConfigureError,
patchloomEntryIsCurrent,
readPatchloomMcpCommand,
Expand Down Expand Up @@ -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");
Expand Down
1 change: 1 addition & 0 deletions test/unit/quickActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading