diff --git a/AGENTS.md b/AGENTS.md index a4d13de..529cb55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,16 +47,16 @@ src/ workspace/readiness.ts Workspace readiness: environment detection, folder selection test/ unit/ Unit tests (node:test, dependency-injected, no VS Code API) - batchApply.test.ts Batch template and operation count parsing (13 tests) + batchApply.test.ts Batch template and operation count parsing (14 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 (26 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 (35 tests incl. e2e) + patchloomCli.test.ts Patchloom CLI integration with real binary + managed install e2e MCP (36 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 (51 tests) + quickActions.test.ts Quick action command building, path containment, patch merge (54 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 11cd8b0..f5b70e5 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ Click it to see full diagnostics, including per-editor MCP configuration status | **Prepend to file** | Prepend content to the start of an existing file (CLI 0.9+) | | **Read structured value** | Read a JSON/YAML/TOML key and copy to clipboard | | **Delete structured value** | Remove a key from JSON, YAML, or TOML with diff preview | -| **Merge into structured file** | Merge a partial JSON object into a config file | +| **Merge into structured file** | Merge a partial JSON object into a config file (optional multi-doc selector, CLI 0.16+) | | **Append to array** | Append a value to a JSON, YAML, or TOML array | | **Prepend to array** | Prepend a value to a JSON, YAML, or TOML array | | **Ensure structured value** | Idempotent set: write only if the key is missing | @@ -168,7 +168,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.15.2 is recommended. +Run `Patchloom: Open Releases` to download the latest release. The extension requires 0.3.0 or newer; 0.16.0 is recommended. **MCP config not injected** Run `Patchloom: Configure MCP` and select the target editor config. @@ -203,7 +203,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.15.2+ recommended for 56 MCP tools, JSON `applied` honesty, doc query envelopes, `md insert-after-section`, optional `--contain` path guarding, fuzzy replace floors, and agent reliability fixes) +- [Patchloom CLI](https://github.com/patchloom/patchloom) 0.3.0 or newer (0.16.0+ recommended for multi-doc `doc merge --selector`, line-oriented `insert_before`/`insert_after`, shared binary/UTF-8 path honesty, 56 MCP tools, JSON `applied` honesty, doc query envelopes, `md insert-after-section`, optional `--contain` path guarding, and fuzzy replace floors) ## Contributing diff --git a/src/commands/batchApply.ts b/src/commands/batchApply.ts index db0ffa8..fd5f9bf 100644 --- a/src/commands/batchApply.ts +++ b/src/commands/batchApply.ts @@ -9,6 +9,7 @@ export const BATCH_TEMPLATE = [ "replace src/example.ts \"old text\" \"new text\"", "replace src/example.ts \"typo_here\" \"fixed\" --fuzzy --min-fuzzy-score 0.80", "doc.set package.json version \"2.0.0\"", + "doc.merge multi-doc.yaml 0 \"{\\\"debug\\\": true}\"", "file.append src/example.ts \"new appended line\"", "md.insert_after_section README.md \"## Config\" \"## FAQ\"", "tidy.fix src/example.ts", diff --git a/src/commands/quickActions.ts b/src/commands/quickActions.ts index d2e0d75..0d674bb 100644 --- a/src/commands/quickActions.ts +++ b/src/commands/quickActions.ts @@ -368,7 +368,7 @@ export async function runQuickAction(): Promise { { label: "Merge into structured file", description: "Merge a partial JSON object into a config file", - detail: "Builds `patchloom doc merge --value `", + detail: "Builds `patchloom doc merge [--selector ] --value ` (selector for multi-doc YAML, CLI 0.16+)", run: async () => { const target = await pickWorkspaceFileTarget("Select a JSON, YAML, or TOML file for Patchloom doc merge"); if (!target) { @@ -382,6 +382,14 @@ export async function runQuickAction(): Promise { return; } + const selector = await vscode.window.showInputBox({ + prompt: "Merge selector (optional). Multi-document YAML needs a document index (CLI 0.16+)", + placeHolder: "leave empty for root, or 0 / [0] for the first multi-doc document" + }); + if (selector === undefined) { + return; + } + const value = await vscode.window.showInputBox({ prompt: "Partial JSON object to merge", placeHolder: '{"debug": true, "logLevel": "verbose"}', @@ -391,7 +399,11 @@ export async function runQuickAction(): Promise { return; } - await previewAndMaybeApply(binaryPath, target, buildDocMergeQuickAction(target.absolutePath, value)); + await previewAndMaybeApply( + binaryPath, + target, + buildDocMergeQuickAction(target.absolutePath, value, selector) + ); } }, { @@ -974,12 +986,27 @@ export function buildDocDeleteQuickAction(targetPath: string, selector: string): }; } -export function buildDocMergeQuickAction(targetPath: string, value: string): PlannedQuickAction { +export function buildDocMergeQuickAction( + targetPath: string, + value: string, + selector?: string +): PlannedQuickAction { + const trimmedSelector = selector?.trim() ?? ""; + const args = ["doc", "merge", targetPath]; + if (trimmedSelector.length > 0) { + args.push("--selector", trimmedSelector); + } + args.push("--value", value); + + const title = trimmedSelector.length > 0 + ? `Merge into ${trimmedSelector} in ${path.basename(targetPath)}` + : `Merge into ${path.basename(targetPath)}`; + return { - title: `Merge into ${path.basename(targetPath)}`, + title, targetPath, targetArgIndices: [2], - args: ["doc", "merge", targetPath, "--value", value] + args }; } diff --git a/test/unit/batchApply.test.ts b/test/unit/batchApply.test.ts index b12b056..f3c2744 100644 --- a/test/unit/batchApply.test.ts +++ b/test/unit/batchApply.test.ts @@ -6,17 +6,18 @@ import { parseBatchOperationCount } from "../../src/commands/batchApply.js"; -test("buildBatchTemplate returns line-oriented format with six operations", () => { +test("buildBatchTemplate returns line-oriented format with seven operations", () => { const template = buildBatchTemplate(); const lines = template.split("\n").filter((line) => line.trim().length > 0); - assert.equal(lines.length, 6); + assert.equal(lines.length, 7); assert.ok(lines[0].startsWith("replace "), "first line should be a replace operation"); assert.ok(lines[1].startsWith("replace ") && lines[1].includes("--fuzzy"), "second line should be fuzzy replace"); assert.ok(lines[2].startsWith("doc.set "), "third line should be a doc.set operation"); - assert.ok(lines[3].startsWith("file.append "), "fourth line should be a file.append operation"); - assert.ok(lines[4].startsWith("md.insert_after_section "), "fifth line should be md.insert_after_section"); - assert.ok(lines[5].startsWith("tidy.fix "), "sixth line should be a tidy.fix operation"); + assert.ok(lines[3].startsWith("doc.merge "), "fourth line should be multi-doc doc.merge"); + assert.ok(lines[4].startsWith("file.append "), "fifth line should be a file.append operation"); + assert.ok(lines[5].startsWith("md.insert_after_section "), "sixth line should be md.insert_after_section"); + assert.ok(lines[6].startsWith("tidy.fix "), "seventh line should be a tidy.fix operation"); }); test("buildBatchTemplate ends with a newline", () => { @@ -94,6 +95,18 @@ test("buildBatchTemplate includes fuzzy replace and md.insert_after_section exam ); }); +test("buildBatchTemplate doc.merge line uses path selector value (CLI 0.16 multi-doc)", () => { + const lines = buildBatchTemplate().split("\n"); + const mergeLine = lines.find((l) => l.startsWith("doc.merge ")); + assert.ok(mergeLine, "template should contain a doc.merge line"); + assert.match( + mergeLine, + /doc\.merge \S+ \S+ ".+"/, + "doc.merge should have path, selector, and quoted value (path selector value)" + ); + assert.match(mergeLine, /\s0\s/, "example should merge into document 0"); +}); + test("buildBatchApplyArgs prefixes global --contain before batch --apply", () => { assert.deepEqual(buildBatchApplyArgs(), ["--contain", "batch", "--apply"]); }); diff --git a/test/unit/patchloomCli.test.ts b/test/unit/patchloomCli.test.ts index 20e2c51..e93ca2f 100644 --- a/test/unit/patchloomCli.test.ts +++ b/test/unit/patchloomCli.test.ts @@ -18,12 +18,18 @@ import test, { describe } from "node:test"; import { findOnPath, parsePatchloomVersion, + comparePatchloomVersions, assessPatchloomCompatibility, resolvePatchloomStatusWithInputs } from "../../src/binary/patchloom.js"; import { classifyAgentsFile } from "../../src/commands/initializeProject.js"; import { buildStatusDetails, preferredStatusAction } from "../../src/commands/showStatus.js"; -import { buildReplaceQuickAction, retargetQuickAction, withApplyFlag } from "../../src/commands/quickActions.js"; +import { + buildDocMergeQuickAction, + buildReplaceQuickAction, + retargetQuickAction, + withApplyFlag +} from "../../src/commands/quickActions.js"; import { configureMcpTargets, inspectMcpTargets } from "../../src/mcp/config.js"; import { performManagedInstall } from "../../src/install/managed.js"; @@ -506,6 +512,33 @@ describe("patchloom CLI integration", async () => { }); }); + test("doc merge --selector targets multi-document YAML via Quick Action args (CLI 0.16+)", async (t) => { + const { stdout, stderr } = await execFileAsync(binaryPath, ["--version"], { timeout: 5000 }); + const version = parsePatchloomVersion(`${stdout}${stderr}`); + if (!version || comparePatchloomVersions(version, "0.16.0") < 0) { + t.skip(`requires patchloom >= 0.16.0 (found ${version ?? "unknown"})`); + return; + } + + await withTempDir(async (dir) => { + const file = path.join(dir, "multi.yaml"); + await fs.writeFile(file, "---\na: 1\n---\nb: 2\n", "utf8"); + + const action = buildDocMergeQuickAction(file, '{"c": 3}', "0"); + await execFileAsync(binaryPath, withApplyFlag(action.args), { timeout: 5000 }); + + const content = await fs.readFile(file, "utf8"); + assert.match(content, /a:\s*1/, "first document field preserved"); + assert.match(content, /c:\s*3/, "merged key landed in selected document"); + assert.match(content, /b:\s*2/, "second document preserved"); + // Document 0 is before the second --- separator; merge must not rewrite doc 1 only. + const secondSep = content.indexOf("---", content.indexOf("---") + 3); + assert.ok(secondSep > 0, "multi-doc separator should remain"); + assert.ok(content.indexOf("c:") < secondSep, "merged key belongs in first document"); + assert.ok(content.indexOf("b:") > secondSep, "second document body stays after separator"); + }); + }); + 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 8a8d193..dbbb4fb 100644 --- a/test/unit/quickActions.test.ts +++ b/test/unit/quickActions.test.ts @@ -328,6 +328,45 @@ test("buildDocMergeQuickAction builds a doc merge command", () => { assert.deepEqual(action.args, ["doc", "merge", "/workspace/demo/package.json", "--value", '{"debug": true}']); }); +test("buildDocMergeQuickAction includes --selector for multi-doc merge (CLI 0.16+)", () => { + const action = buildDocMergeQuickAction( + "/workspace/demo/multi.yaml", + '{"debug": true}', + "0" + ); + + assert.equal(action.title, "Merge into 0 in multi.yaml"); + assert.deepEqual(action.targetArgIndices, [2]); + assert.deepEqual(action.args, [ + "doc", "merge", "/workspace/demo/multi.yaml", + "--selector", "0", + "--value", '{"debug": true}' + ]); +}); + +test("buildDocMergeQuickAction omits --selector when selector is blank", () => { + const action = buildDocMergeQuickAction("/workspace/demo/config.toml", '{"x": 1}', " "); + + assert.equal(action.title, "Merge into config.toml"); + assert.deepEqual(action.args, [ + "doc", "merge", "/workspace/demo/config.toml", + "--value", '{"x": 1}' + ]); + assert.ok(!action.args.includes("--selector")); +}); + +test("retargetQuickAction preserves doc merge selector flags", () => { + const action = buildDocMergeQuickAction("/workspace/demo/a.yaml", '{"k": true}', "[0]"); + const retargeted = retargetQuickAction(action, "/workspace/demo/b.yaml"); + + assert.equal(retargeted.targetPath, "/workspace/demo/b.yaml"); + assert.deepEqual(retargeted.args, [ + "doc", "merge", "/workspace/demo/b.yaml", + "--selector", "[0]", + "--value", '{"k": true}' + ]); +}); + test("buildDocAppendQuickAction builds a doc append command", () => { const action = buildDocAppendQuickAction("/workspace/demo/config.yaml", "tags", '"v2"');