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 @@ -33,7 +33,7 @@ src/
configureMcp.ts Configure MCP command: multi-target MCP config injection
initializeProject.ts Initialize Project command: generate/diff AGENTS.md
managedInstall.ts Managed Install commands: install, update, reinstall Patchloom binary
quickActions.ts Quick Action command: replace, tidy, doc set, search, create, doc get, patch merge
quickActions.ts Quick Action command: replace, tidy, doc set, search, create, append, prepend, doc get, patch merge
batchApply.ts Batch Apply command: atomic multi-operation plan via JSON
setupWorkspace.ts Setup Workspace command: guided readiness walkthrough
showStatus.ts Show Status command: diagnostics display
Expand All @@ -50,13 +50,13 @@ test/
batchApply.test.ts Batch template and operation count parsing (11 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 (24 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 (34 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 (47 tests)
quickActions.test.ts Quick action command building, path containment, patch merge (50 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
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,14 @@ Click it to see full diagnostics, including per-editor MCP configuration status
| **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) |
| **Create file** | Scaffold a new file and open it in the editor |
| **Create file** | Scaffold a new file with optional content and open it in the editor |
| **Append to file** | Append content to an existing file |
| **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 |
| **Merge patch (three-way)** | Apply a stale patch using three-way merge (v0.2.0+) |

Workspace Quick Actions and Batch Apply pass `--contain` so CLI paths stay inside the workspace root (CLI 0.10+). Patch merge skips containment when the patch file may live outside the workspace.

### Batch operations

`Patchloom: Batch Apply` opens a line-oriented plan template where you can compose multiple operations (replace, tidy, doc set). The extension pipes the plan to `patchloom batch --apply` so all changes land atomically.
Expand All @@ -107,7 +110,7 @@ The extension detects outdated CLI builds and warns with upgrade guidance. It re
| Command | Description |
|---------|-------------|
| `Patchloom: Setup Workspace` | Guided walkthrough for binary, AGENTS.md, and MCP readiness |
| `Patchloom: Initialize Project` | Generate or diff `AGENTS.md` from `patchloom agent-rules` |
| `Patchloom: Initialize Project` | Generate or diff `AGENTS.md` from `patchloom agent-rules` (mode: all/cli/mcp, platform: all/linux/windows) |
| `Patchloom: Configure MCP` | Inject Patchloom MCP server config into editor config files |
| `Patchloom: Quick Action` | Build a Patchloom CLI command from an interactive picker |
| `Patchloom: Batch Apply` | Open a batch plan and execute all operations atomically |
Expand Down Expand Up @@ -148,7 +151,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.7.0 is recommended.
Run `Patchloom: Open Releases` to download the latest release. The extension requires 0.3.0 or newer; 0.10.0 is recommended.

**MCP config not injected**
Run `Patchloom: Configure MCP` and select the target editor config.
Expand Down Expand Up @@ -183,7 +186,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.7.0+ recommended for 43 MCP tools, LLM-aligned CLI arguments, post-write formatter hook, and transaction engine unification)
- [Patchloom CLI](https://github.com/patchloom/patchloom) 0.3.0 or newer (0.10.0+ recommended for 54 MCP tools, correct preview exit codes, optional `--contain` path guarding, schema-driven MCP descriptions, and agent reliability fixes)

## Contributing

Expand Down
3 changes: 2 additions & 1 deletion src/commands/batchApply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ export async function batchApply(): Promise<void> {

const plan = doc.getText();
const log = getPatchloomLog();
const args = ["batch", "--apply"];
// Global --contain rejects plan ops that escape the workspace root (CLI 0.10+).
const args = ["--contain", "batch", "--apply"];
log?.logCommand(binaryPath, args, folder.uri.fsPath);

const result = await executePatchloomWithStdin(binaryPath, args, folder.uri.fsPath, plan);
Expand Down
57 changes: 54 additions & 3 deletions src/commands/initializeProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,36 @@ export async function initializeProject(): Promise<void> {
return;
}

const modePick = await vscode.window.showQuickPick(
[
{ label: "All (CLI + MCP)", description: "Default", mode: "all" as const },
{ label: "CLI only", description: "Omit MCP section", mode: "cli" as const },
{ label: "MCP only", description: "Lead with MCP tools", mode: "mcp" as const }
],
{ placeHolder: "Which agent-rules integration mode?" }
);
if (!modePick) {
return;
}

const platformPick = await vscode.window.showQuickPick(
[
{ label: "All platforms", description: "Default", platform: "all" as const },
{ label: "Linux / macOS", description: "POSIX shell examples", platform: "linux" as const },
{ label: "Windows", description: "Windows shell examples", platform: "windows" as const }
],
{ placeHolder: "Which platform for shell examples?" }
);
if (!platformPick) {
return;
}

let rules: string;
try {
rules = await generateAgentRules(binaryPath, folder.uri.fsPath);
rules = await generateAgentRules(binaryPath, folder.uri.fsPath, {
mode: modePick.mode,
platform: platformPick.platform
});
} catch (error) {
await vscode.window.showErrorMessage(`Failed to run patchloom agent-rules in ${folder.name}: ${formatError(error)}`);
return;
Expand Down Expand Up @@ -87,9 +114,33 @@ export function classifyAgentsFile(existingContent: string | undefined, generate
: "different";
}

export async function generateAgentRules(binaryPath: string, cwd: string): Promise<string> {
const log = getPatchloomLog();
export type AgentRulesMode = "all" | "cli" | "mcp";
export type AgentRulesPlatform = "all" | "linux" | "windows";

export interface AgentRulesOptions {
readonly mode?: AgentRulesMode;
readonly platform?: AgentRulesPlatform;
}

/** Build `patchloom agent-rules` argv, omitting default `all` flags. */
export function buildAgentRulesArgs(options: AgentRulesOptions = {}): string[] {
const args = ["agent-rules"];
if (options.mode && options.mode !== "all") {
args.push("--mode", options.mode);
}
if (options.platform && options.platform !== "all") {
args.push("--platform", options.platform);
}
return args;
}

export async function generateAgentRules(
binaryPath: string,
cwd: string,
options: AgentRulesOptions = {}
): Promise<string> {
const log = getPatchloomLog();
const args = buildAgentRulesArgs(options);
log?.logCommand(binaryPath, args, cwd);
try {
const { stdout, stderr } = await execFileAsync(binaryPath, args, {
Expand Down
75 changes: 67 additions & 8 deletions src/commands/quickActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ export async function runQuickAction(): Promise<void> {
{
label: "Create a new file",
description: "Scaffold a new file in the workspace",
detail: "Builds `patchloom create <path>`",
detail: "Builds `patchloom create <path> --content <text> --apply`",
run: async () => {
const folder = await activeWorkspaceFolder({
promptIfMany: true,
Expand All @@ -230,7 +230,16 @@ export async function runQuickAction(): Promise<void> {
return;
}

const action = buildCreateQuickAction(absolutePath);
// Empty content is allowed; CLI requires --content or --stdin and --apply to write.
const content = await vscode.window.showInputBox({
prompt: "Initial file content (leave empty for an empty file)",
placeHolder: "// new file"
});
if (content === undefined) {
return;
}

const action = buildCreateQuickAction(absolutePath, content);
const result = await executePatchloom(binaryPath, action.args, folder.uri.fsPath);

if (result.exitCode !== 0) {
Expand Down Expand Up @@ -266,6 +275,28 @@ export async function runQuickAction(): Promise<void> {
await previewAndMaybeApply(binaryPath, target, buildAppendQuickAction(target.absolutePath, content));
}
},
{
label: "Prepend to file",
description: "Prepend content to the beginning of an existing file",
detail: "Builds `patchloom prepend <file> --content <text>` (CLI 0.9+)",
run: async () => {
const target = await pickWorkspaceFileTarget("Select a file to prepend to with Patchloom");
if (!target) {
return;
}

const content = await vscode.window.showInputBox({
prompt: "Content to prepend",
placeHolder: "// header comment",
validateInput: (value) => value.length > 0 ? undefined : "Content is required."
});
if (content === undefined) {
return;
}

await previewAndMaybeApply(binaryPath, target, buildPrependQuickAction(target.absolutePath, content));
}
},
{
label: "Read structured value",
description: "Read a value from JSON, YAML, or TOML",
Expand Down Expand Up @@ -738,7 +769,8 @@ export async function runQuickAction(): Promise<void> {
}

const action = buildPatchMergeQuickAction(patchUri[0].fsPath, allowConflicts.allow);
const result = await executePatchloom(binaryPath, action.args, folder.uri.fsPath);
// Patch files may live outside the workspace; skip --contain so external patches work.
const result = await executePatchloom(binaryPath, action.args, folder.uri.fsPath, { contain: false });
const log = getPatchloomLog();

if (result.exitCode === 8) {
Expand Down Expand Up @@ -858,12 +890,13 @@ export function buildSearchQuickAction(workspacePath: string, pattern: string, g
};
}

export function buildCreateQuickAction(filePath: string): PlannedQuickAction {
export function buildCreateQuickAction(filePath: string, content = ""): PlannedQuickAction {
return {
title: `Create ${path.basename(filePath)}`,
targetPath: filePath,
targetArgIndices: [1],
args: ["create", filePath]
// CLI requires --content/--stdin and --apply; preview-only create returns exit 2 and does not write.
args: ["create", filePath, "--content", content, "--apply"]
};
}

Expand All @@ -876,6 +909,15 @@ export function buildAppendQuickAction(targetPath: string, content: string): Pla
};
}

export function buildPrependQuickAction(targetPath: string, content: string): PlannedQuickAction {
return {
title: `Prepend to ${path.basename(targetPath)}`,
targetPath,
targetArgIndices: [1],
args: ["prepend", targetPath, "--content", content]
};
}

export function buildDocGetQuickAction(targetPath: string, selector: string): PlannedQuickAction {
return {
title: `Get ${selector} from ${path.basename(targetPath)}`,
Expand Down Expand Up @@ -1026,6 +1068,16 @@ export function withApplyFlag(args: readonly string[]): string[] {
return args.includes("--apply") ? [...args] : [...args, "--apply"];
}

/**
* Prepend the global `--contain` flag so CLI ops cannot escape the cwd workspace.
* Global flags must appear before the subcommand (`patchloom --contain replace ...`).
*/
export function withContainFlag(args: readonly string[]): string[] {
return args[0] === "--contain" || args.includes("--contain")
? [...args]
: ["--contain", ...args];
}

async function previewAndMaybeApply(
binaryPath: string,
target: WorkspaceFileTarget,
Expand Down Expand Up @@ -1236,16 +1288,23 @@ async function ensureWorkspaceFileReady(target: WorkspaceFileTarget): Promise<bo
return true;
}

export interface ExecutePatchloomOptions {
/** When true (default), prefix args with global `--contain` for workspace path guarding. */
readonly contain?: boolean;
}

async function executePatchloom(
binaryPath: string,
args: readonly string[],
cwd: string
cwd: string,
options: ExecutePatchloomOptions = {}
): Promise<PatchloomCommandResult> {
const finalArgs = options.contain === false ? [...args] : withContainFlag(args);
const log = getPatchloomLog();
log?.logCommand(binaryPath, args, cwd);
log?.logCommand(binaryPath, finalArgs, cwd);

try {
const { stdout, stderr } = await execFileAsync(binaryPath, args, {
const { stdout, stderr } = await execFileAsync(binaryPath, finalArgs, {
cwd,
timeout: 30_000,
maxBuffer: 8 * 1024 * 1024,
Expand Down
31 changes: 28 additions & 3 deletions test/unit/initializeProject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import assert from "node:assert/strict";
import * as path from "node:path";
import test from "node:test";
import { MINIMUM_SUPPORTED_PATCHLOOM_VERSION } from "../../src/binary/patchloom.js";
import { classifyAgentsFile, generateAgentRules } from "../../src/commands/initializeProject.js";
import {
buildAgentRulesArgs,
classifyAgentsFile,
generateAgentRules
} from "../../src/commands/initializeProject.js";
import { buildStatusDetails, preferredStatusAction } from "../../src/commands/showStatus.js";
import { buildPatchloomMcpEntry, configureMcpTargets, inspectMcpTargets } from "../../src/mcp/config.js";
import { setPatchloomLog } from "../../src/logging/outputChannel.js";
Expand Down Expand Up @@ -373,6 +377,27 @@ test("configureMcpTargets creates or updates only the selected target kinds", as
assert.match(writes.get(cursorPath) ?? "", /other/);
});

test("buildAgentRulesArgs omits default all modes", () => {
assert.deepEqual(buildAgentRulesArgs(), ["agent-rules"]);
assert.deepEqual(buildAgentRulesArgs({ mode: "all", platform: "all" }), ["agent-rules"]);
});

test("buildAgentRulesArgs includes non-default mode and platform", () => {
assert.deepEqual(buildAgentRulesArgs({ mode: "mcp" }), ["agent-rules", "--mode", "mcp"]);
assert.deepEqual(buildAgentRulesArgs({ platform: "windows" }), [
"agent-rules",
"--platform",
"windows"
]);
assert.deepEqual(buildAgentRulesArgs({ mode: "cli", platform: "linux" }), [
"agent-rules",
"--mode",
"cli",
"--platform",
"linux"
]);
});

test("generateAgentRules logs error to output channel on CLI failure", async () => {
const logged: { exitCode: number; stdout: string; stderr: string }[] = [];
const commands: { binary: string; args: readonly string[]; cwd: string }[] = [];
Expand All @@ -385,15 +410,15 @@ test("generateAgentRules logs error to output channel on CLI failure", async ()
});
try {
await assert.rejects(
() => generateAgentRules("/nonexistent/patchloom", "/tmp"),
() => generateAgentRules("/nonexistent/patchloom", "/tmp", { mode: "mcp", platform: "linux" }),
(err: Error) => {
assert.match(err.message, /ENOENT|not found|No such file/i);
return true;
}
);
assert.equal(commands.length, 1, "logCommand should be called once");
assert.equal(commands[0].binary, "/nonexistent/patchloom");
assert.deepEqual(commands[0].args, ["agent-rules"]);
assert.deepEqual(commands[0].args, ["agent-rules", "--mode", "mcp", "--platform", "linux"]);
assert.equal(commands[0].cwd, "/tmp");
assert.equal(logged.length, 1, "logResult should be called once on failure");
assert.equal(logged[0].exitCode, 1);
Expand Down
Loading
Loading