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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,13 @@ test/
batchApply.test.ts Batch template and operation count parsing (15 tests)
binary.test.ts Binary discovery, managed install, compatibility, workspace env (59 tests)
binaryDiscovery.test.ts Real executable discovery on PATH (13 tests)
initializeProject.test.ts Status display, agents file classification, formatError (30 tests)
initializeProject.test.ts Status display, agents file classification, formatError (32 tests)
managedLifecycle.test.ts Managed install with real file I/O (22 tests)
mcpConfig.test.ts MCP config with real temp directories (9 tests)
outputChannel.test.ts Output channel logging wrapper (10 tests)
patchloomCli.test.ts Patchloom CLI integration with real binary + managed install e2e MCP (39 tests incl. e2e)
patchloomCli.test.ts Patchloom CLI integration with real binary + managed install e2e MCP (40 tests incl. e2e)
propertyBased.test.ts Property-based tests with fast-check (13 tests)
quickActions.test.ts Quick action command building, path containment, patch merge (57 tests)
quickActions.test.ts Quick action command building, path containment, patch merge (60 tests)
verifyMcp.test.ts MCP server verify and JSON-RPC response parsing (15 tests)
downloadIntegration.test.ts HTTP download, redirect, streaming SHA-256 (9 tests)
suite/
Expand Down
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +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)
choco install patchloom # Windows (Chocolatey; first listing may wait on moderation)
winget install Patchloom.Patchloom # Windows (WinGet; when the community PR is approved)
```
2. Open a project and run **Patchloom: Setup Workspace**

Expand Down Expand Up @@ -63,6 +64,8 @@ Run `Patchloom: Setup Workspace` to walk through everything your project needs:
- **Cursor** (`.cursor/mcp.json`)
- **Windsurf** (`~/.codeium/windsurf/mcp_config.json`)

CLI 0.24.0 exposes **58** MCP tools by default (including `list_files` and `apply_fragment`). For coding agents that need a smaller handshake inventory, set `PATCHLOOM_MCP_SURFACE=core` in the server environment (11 tools: `read_file`, `search_files`, `list_files`, `replace_text`, `batch_replace`, `doc_get`, `doc_set`, `doc_query`, `md_replace_section`, `execute_plan`, `server_info`). Absolute paths that resolve inside the MCP workspace root are allowed; `../` and outside paths still reject.

### Status bar

The status bar shows MCP and binary readiness at a glance:
Expand All @@ -86,6 +89,7 @@ Click it to see full diagnostics, including per-editor MCP configuration status
| **Replace text** | Literal text replacement with diff preview before applying |
| **Insert text after match** | Line-oriented insert after each match (CLI 0.16+) |
| **Insert text before match** | Line-oriented insert before each match (CLI 0.16+) |
| **Apply fragment at anchor** | Morph-style freeform fragment at a unique anchor (`--after` / `--before` / `--old`, CLI 0.22+) |
| **Tidy file** | Whitespace and newline cleanup with diff preview |
| **Set structured value** | Update a JSON, YAML, or TOML key with diff preview |
| **Search text** | Find pattern matches across workspace files (results in output channel) |
Expand Down Expand Up @@ -170,7 +174,7 @@ The extension detects outdated CLI builds and warns with upgrade guidance. It re
Set `patchloom.path` in settings, or add the CLI to your `PATH`.

**CLI compatibility warning**
Run `Patchloom: Open Releases` to download the latest release. The extension requires 0.3.0 or newer; 0.19.0 is recommended.
Run `Patchloom: Open Releases` to download the latest release. The extension requires 0.3.0 or newer; 0.24.0 is recommended.

**Path rejected by workspace guard**
Quick Actions and Batch Apply pass `--contain` so paths stay inside the open workspace folder. On CLI 0.18+, sandbox escapes report `error_kind: guard_rejected` (not a generic `invalid_input`). Keep targets under the workspace root, or open the folder that owns the files.
Expand All @@ -181,6 +185,12 @@ Batch lines use `replace PATH OLD NEW` (and optional flags such as `--fuzzy`). D
**Create or rename destination already exists**
On CLI 0.19+, create/rename conflicts report `error_kind: already_exists` (not a generic `invalid_input`). Use the force flag when overwriting is intentional, or pick a free destination path.

**Binary or invalid UTF-8 target**
On CLI 0.20+, sole-path loads of binary or invalid UTF-8 files report `error_kind: binary` or `invalid_encoding` (not a soft `no_matches`). Use a text file, or force-create when overwriting non-text is intentional.

**Fuzzy match span refused**
On CLI 0.22+, over-wide fuzzy matches can report `error_kind: fuzzy_span_suspicious`. Prefer an exact `old` string, structured `doc`/`md`/`ast` edits, or `apply-fragment` with a unique anchor.

**MCP config not injected**
Run `Patchloom: Configure MCP` and select the target editor config.

Expand Down Expand Up @@ -214,7 +224,7 @@ File bugs and feature requests at [patchloom/patchloom-vscode/issues](https://gi
## Requirements

- VS Code 1.90 or newer (or compatible editors: Cursor, Windsurf, VSCodium)
- [Patchloom CLI](https://github.com/patchloom/patchloom) 0.3.0 or newer (0.19.0+ recommended for stable `error_kind` peels including `already_exists` / `not_found` / `guard_rejected`, clearer create/rename force hints, multi-doc `doc merge --selector`, line-oriented `insert_before`/`insert_after`, batch `replace PATH OLD NEW` parse hints, 56 MCP tools, JSON `applied` honesty, and agent-facing envelopes)
- [Patchloom CLI](https://github.com/patchloom/patchloom) 0.3.0 or newer (0.24.0+ recommended for `list_files` MCP inventory, `apply-fragment`, `error_kind` peels including `binary` / `invalid_encoding` / `fuzzy_span_suspicious` / `already_exists` / `guard_rejected`, optional `PATCHLOOM_MCP_SURFACE=core` 11-tool pack, multi-doc `doc merge --selector`, line-oriented inserts, batch `replace PATH OLD NEW` hints, 58 MCP tools, JSON `applied` honesty, and agent-facing envelopes)

## Contributing

Expand Down
79 changes: 79 additions & 0 deletions src/commands/quickActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,63 @@ export async function runQuickAction(): Promise<void> {
);
}
},
{
label: "Apply fragment at anchor",
description: "Morph-style fragment at a unique anchor (CLI 0.22+)",
detail: "Builds `patchloom apply-fragment <file> --after|--before|--old <anchor> --fragment <text>`",
run: async () => {
const target = await pickWorkspaceFileTarget("Select a file for Patchloom apply-fragment");
if (!target) {
return;
}

const placementPick = await vscode.window.showQuickPick(
[
{
label: "Insert after anchor",
description: "Default for Morph-style snippets",
placement: "after" as const
},
{
label: "Insert before anchor",
placement: "before" as const
},
{
label: "Replace anchor span",
description: "Replace the unique matched span with the fragment",
placement: "old" as const
}
],
{ placeHolder: "How should the fragment be placed relative to the anchor?" }
);
if (!placementPick) {
return;
}

const anchor = await vscode.window.showInputBox({
prompt: "Unique anchor text (exactly one match required by default)",
placeHolder: placementPick.placement === "old" ? "span to replace" : "fn foo() {",
validateInput: (value) => value.length > 0 ? undefined : "Anchor text is required."
});
if (anchor === undefined) {
return;
}

const fragment = await vscode.window.showInputBox({
prompt: "Fragment text (Morph-style // ... existing code ... markers are stripped)",
placeHolder: " let x = 1;"
});
if (fragment === undefined) {
return;
}

await previewAndMaybeApply(
binaryPath,
target,
buildApplyFragmentQuickAction(target.absolutePath, placementPick.placement, anchor, fragment)
);
}
},
{
label: "Tidy file",
description: "Whitespace and newline cleanup with diff preview",
Expand Down Expand Up @@ -989,6 +1046,28 @@ export function buildInsertBeforeMatchQuickAction(
};
}

export type ApplyFragmentPlacement = "after" | "before" | "old";

/**
* Constrained freeform fragment apply (CLI 0.22+). Exactly one of
* `--after`, `--before`, or `--old` is required by the CLI.
*/
export function buildApplyFragmentQuickAction(
targetPath: string,
placement: ApplyFragmentPlacement,
anchor: string,
fragment: string
): PlannedQuickAction {
const flag =
placement === "after" ? "--after" : placement === "before" ? "--before" : "--old";
return {
title: `Apply fragment in ${path.basename(targetPath)}`,
targetPath,
targetArgIndices: [1],
args: ["apply-fragment", targetPath, flag, anchor, "--fragment", fragment]
};
}

export function buildTidyQuickAction(targetPath: string, fixes: readonly TidyFix[]): PlannedQuickAction {
const args = ["tidy", "fix", targetPath];
if (fixes.includes("ensure-final-newline")) {
Expand Down
26 changes: 26 additions & 0 deletions test/unit/initializeProject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,32 @@ test("formatCliOutput surfaces already_exists kind (CLI 0.19+)", () => {
);
});

test("formatCliOutput surfaces binary kind (CLI 0.20+)", () => {
const stdout = JSON.stringify({
ok: false,
error: "target is a binary file: assets/logo.png",
error_kind: "binary",
applied: false
});
assert.equal(
formatCliOutput({ exitCode: 1, stdout, stderr: "" }),
"binary: target is a binary file: assets/logo.png"
);
});

test("formatCliOutput surfaces fuzzy_span_suspicious kind (CLI 0.22+)", () => {
const stdout = JSON.stringify({
ok: false,
error: "fuzzy match span is suspiciously wide for the old string",
error_kind: "fuzzy_span_suspicious",
applied: false
});
assert.equal(
formatCliOutput({ exitCode: 1, stdout, stderr: "" }),
"fuzzy_span_suspicious: fuzzy match span is suspiciously wide for the old string"
);
});

test("classifyAgentsFile returns missing when AGENTS.md does not exist", () => {
assert.equal(classifyAgentsFile(undefined, "# Rules\n"), "missing");
});
Expand Down
21 changes: 21 additions & 0 deletions test/unit/patchloomCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { classifyAgentsFile } from "../../src/commands/initializeProject.js";
import { buildStatusDetails, preferredStatusAction } from "../../src/commands/showStatus.js";
import {
buildDocMergeQuickAction,
buildApplyFragmentQuickAction,
buildInsertAfterMatchQuickAction,
buildReplaceQuickAction,
retargetQuickAction,
Expand Down Expand Up @@ -560,6 +561,26 @@ describe("patchloom CLI integration", async () => {
});
});

test("apply-fragment --after via Quick Action args (CLI 0.22+)", async (t) => {
const { stdout, stderr } = await execFileAsync(binaryPath, ["--version"], { timeout: 5000 });
const version = parsePatchloomVersion(`${stdout}${stderr}`);
if (!version || comparePatchloomVersions(version, "0.22.0") < 0) {
t.skip(`requires patchloom >= 0.22.0 (found ${version ?? "unknown"})`);
return;
}

await withTempDir(async (dir) => {
const file = path.join(dir, "lib.rs");
await fs.writeFile(file, "fn foo() {\n let a = 1;\n}\n", "utf8");

const action = buildApplyFragmentQuickAction(file, "after", "fn foo() {", " let x = 2;");
await execFileAsync(binaryPath, withApplyFlag(action.args), { timeout: 5000 });

const content = await fs.readFile(file, "utf8");
assert.equal(content, "fn foo() {\n let x = 2;\n let a = 1;\n}\n");
});
});

test("doc append adds an item to a JSON array", async () => {
await withTempDir(async (dir) => {
const file = path.join(dir, "config.json");
Expand Down
43 changes: 43 additions & 0 deletions test/unit/quickActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
buildAppendQuickAction,
buildApplyFragmentQuickAction,
buildCreateQuickAction,
buildInsertAfterMatchQuickAction,
buildInsertBeforeMatchQuickAction,
Expand Down Expand Up @@ -61,6 +62,48 @@ test("buildInsertBeforeMatchQuickAction builds replace --insert-before (CLI 0.16
]);
});

test("buildApplyFragmentQuickAction builds apply-fragment --after (CLI 0.22+)", () => {
const action = buildApplyFragmentQuickAction(
"/workspace/demo/lib.rs",
"after",
"fn foo() {",
" let x = 1;"
);

assert.equal(action.title, "Apply fragment in lib.rs");
assert.deepEqual(action.targetArgIndices, [1]);
assert.deepEqual(action.args, [
"apply-fragment",
"/workspace/demo/lib.rs",
"--after",
"fn foo() {",
"--fragment",
" let x = 1;"
]);
});

test("buildApplyFragmentQuickAction supports --before and --old placements", () => {
const before = buildApplyFragmentQuickAction("/ws/a.ts", "before", "return true;", "// note");
assert.deepEqual(before.args, [
"apply-fragment", "/ws/a.ts", "--before", "return true;", "--fragment", "// note"
]);

const replaceSpan = buildApplyFragmentQuickAction("/ws/a.ts", "old", "old_span", "new_span");
assert.deepEqual(replaceSpan.args, [
"apply-fragment", "/ws/a.ts", "--old", "old_span", "--fragment", "new_span"
]);
});

test("retargetQuickAction preserves apply-fragment path index", () => {
const action = buildApplyFragmentQuickAction("/workspace/demo/a.ts", "after", "anchor", "frag");
const retargeted = retargetQuickAction(action, "/workspace/demo/b.ts");

assert.equal(retargeted.targetPath, "/workspace/demo/b.ts");
assert.deepEqual(retargeted.args, [
"apply-fragment", "/workspace/demo/b.ts", "--after", "anchor", "--fragment", "frag"
]);
});

test("retargetQuickAction preserves insert-after args", () => {
const action = buildInsertAfterMatchQuickAction("/workspace/demo/a.ts", "foo", "bar");
const retargeted = retargetQuickAction(action, "/workspace/demo/b.ts");
Expand Down
8 changes: 8 additions & 0 deletions walkthrough/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ choco install patchloom

First listing on the community repository may wait on Chocolatey moderation.

## WinGet (Windows)

```bash
winget install Patchloom.Patchloom
```

Available after the community package PR is approved.

## Cargo

```bash
Expand Down
Loading