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 @@ -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/
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions src/commands/batchApply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
37 changes: 32 additions & 5 deletions src/commands/quickActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ export async function runQuickAction(): Promise<void> {
{
label: "Merge into structured file",
description: "Merge a partial JSON object into a config file",
detail: "Builds `patchloom doc merge <file> --value <json>`",
detail: "Builds `patchloom doc merge <file> [--selector <path>] --value <json>` (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) {
Expand All @@ -382,6 +382,14 @@ export async function runQuickAction(): Promise<void> {
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"}',
Expand All @@ -391,7 +399,11 @@ export async function runQuickAction(): Promise<void> {
return;
}

await previewAndMaybeApply(binaryPath, target, buildDocMergeQuickAction(target.absolutePath, value));
await previewAndMaybeApply(
binaryPath,
target,
buildDocMergeQuickAction(target.absolutePath, value, selector)
);
}
},
{
Expand Down Expand Up @@ -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
};
}

Expand Down
23 changes: 18 additions & 5 deletions test/unit/batchApply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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"]);
});
35 changes: 34 additions & 1 deletion test/unit/patchloomCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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");
Expand Down
39 changes: 39 additions & 0 deletions test/unit/quickActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"');

Expand Down
Loading