diff --git a/AGENTS.md b/AGENTS.md index 9e19c62..726389f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/ diff --git a/README.md b/README.md index 30c89be..2aae815 100644 --- a/README.md +++ b/README.md @@ -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** @@ -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: @@ -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) | @@ -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. @@ -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. @@ -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 diff --git a/src/commands/quickActions.ts b/src/commands/quickActions.ts index 851092a..31a6d4d 100644 --- a/src/commands/quickActions.ts +++ b/src/commands/quickActions.ts @@ -141,6 +141,63 @@ export async function runQuickAction(): Promise { ); } }, + { + label: "Apply fragment at anchor", + description: "Morph-style fragment at a unique anchor (CLI 0.22+)", + detail: "Builds `patchloom apply-fragment --after|--before|--old --fragment `", + 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", @@ -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")) { diff --git a/test/unit/initializeProject.test.ts b/test/unit/initializeProject.test.ts index 0abf514..4c4f633 100644 --- a/test/unit/initializeProject.test.ts +++ b/test/unit/initializeProject.test.ts @@ -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"); }); diff --git a/test/unit/patchloomCli.test.ts b/test/unit/patchloomCli.test.ts index fd06030..1c320a8 100644 --- a/test/unit/patchloomCli.test.ts +++ b/test/unit/patchloomCli.test.ts @@ -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, @@ -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"); diff --git a/test/unit/quickActions.test.ts b/test/unit/quickActions.test.ts index 91d51a5..fd3dfe1 100644 --- a/test/unit/quickActions.test.ts +++ b/test/unit/quickActions.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { buildAppendQuickAction, + buildApplyFragmentQuickAction, buildCreateQuickAction, buildInsertAfterMatchQuickAction, buildInsertBeforeMatchQuickAction, @@ -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"); diff --git a/walkthrough/install.md b/walkthrough/install.md index f6079dc..1674a87 100644 --- a/walkthrough/install.md +++ b/walkthrough/install.md @@ -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