From 4a30e0e88897cd904dce06b443d6bc4a82cc38d9 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 8 Jul 2026 08:49:46 -0400 Subject: [PATCH 1/2] fix: align create Quick Action and docs with patchloom 0.10 CLI create requires --content/--stdin and --apply to write files; preview-only create returns exit 2. Prompt for content and pass both flags. Recommend CLI 0.10.0 and 54 MCP tools in the README. Signed-off-by: Sebastien Tardif --- README.md | 4 ++-- src/commands/quickActions.ts | 18 ++++++++++---- test/unit/quickActions.test.ts | 43 ++++++++++++++++++++++++++++------ 3 files changed, 52 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 115693d..d9407d5 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,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. @@ -183,7 +183,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 diff --git a/src/commands/quickActions.ts b/src/commands/quickActions.ts index 92e9353..521325c 100644 --- a/src/commands/quickActions.ts +++ b/src/commands/quickActions.ts @@ -203,7 +203,7 @@ export async function runQuickAction(): Promise { { label: "Create a new file", description: "Scaffold a new file in the workspace", - detail: "Builds `patchloom create `", + detail: "Builds `patchloom create --content --apply`", run: async () => { const folder = await activeWorkspaceFolder({ promptIfMany: true, @@ -230,7 +230,16 @@ export async function runQuickAction(): Promise { 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) { @@ -858,12 +867,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"] }; } diff --git a/test/unit/quickActions.test.ts b/test/unit/quickActions.test.ts index 1ba81d8..7e9306f 100644 --- a/test/unit/quickActions.test.ts +++ b/test/unit/quickActions.test.ts @@ -152,14 +152,31 @@ test("buildSearchQuickAction includes glob when provided", () => { assert.deepEqual(action.targetArgIndices, [4]); }); -test("buildCreateQuickAction builds a create command", () => { - const action = buildCreateQuickAction("/workspace/demo/src/newfile.ts"); +test("buildCreateQuickAction builds a create command with content and apply", () => { + const action = buildCreateQuickAction("/workspace/demo/src/newfile.ts", "hello"); assert.equal(action.title, "Create newfile.ts"); - assert.deepEqual(action.args, ["create", "/workspace/demo/src/newfile.ts"]); + assert.deepEqual(action.args, [ + "create", + "/workspace/demo/src/newfile.ts", + "--content", + "hello", + "--apply" + ]); assert.deepEqual(action.targetArgIndices, [1]); }); +test("buildCreateQuickAction allows empty content", () => { + const action = buildCreateQuickAction("/workspace/demo/empty.txt"); + assert.deepEqual(action.args, [ + "create", + "/workspace/demo/empty.txt", + "--content", + "", + "--apply" + ]); +}); + test("buildSearchQuickAction preserves spaces in pattern as a single arg", () => { const action = buildSearchQuickAction("/workspace/demo", "hello world"); @@ -220,9 +237,15 @@ test("buildSearchQuickAction with regex special characters", () => { }); test("buildCreateQuickAction with spaces in path", () => { - const action = buildCreateQuickAction("/workspace/my project/src/new file.ts"); + const action = buildCreateQuickAction("/workspace/my project/src/new file.ts", "x"); assert.equal(action.title, "Create new file.ts"); - assert.deepEqual(action.args, ["create", "/workspace/my project/src/new file.ts"]); + assert.deepEqual(action.args, [ + "create", + "/workspace/my project/src/new file.ts", + "--content", + "x", + "--apply" + ]); }); test("buildDocGetQuickAction with deeply nested selector", () => { @@ -232,9 +255,15 @@ test("buildDocGetQuickAction with deeply nested selector", () => { }); test("buildCreateQuickAction with unicode filename", () => { - const action = buildCreateQuickAction("/workspace/demo/docs/日本語.md"); + const action = buildCreateQuickAction("/workspace/demo/docs/日本語.md", "# title"); assert.equal(action.title, "Create 日本語.md"); - assert.deepEqual(action.args, ["create", "/workspace/demo/docs/日本語.md"]); + assert.deepEqual(action.args, [ + "create", + "/workspace/demo/docs/日本語.md", + "--content", + "# title", + "--apply" + ]); }); test("buildSearchQuickAction with unicode pattern", () => { From feb0d25f0f147d934f96089f98c4f053e739c653 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 8 Jul 2026 08:52:03 -0400 Subject: [PATCH 2/2] feat: optional CLI 0.10 product gaps (prepend, contain, agent-rules) Add file prepend Quick Action, pass global --contain on workspace CLI ops (skip for external patch merge), and agent-rules mode/platform picks for Initialize Project. Includes unit tests and README/AGENTS docs. Signed-off-by: Sebastien Tardif --- AGENTS.md | 6 +-- README.md | 7 +++- src/commands/batchApply.ts | 3 +- src/commands/initializeProject.ts | 57 +++++++++++++++++++++++++++-- src/commands/quickActions.ts | 57 +++++++++++++++++++++++++++-- test/unit/initializeProject.test.ts | 31 ++++++++++++++-- test/unit/quickActions.test.ts | 40 +++++++++++++++++++- 7 files changed, 184 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f4ecfef..1a28b2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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/ diff --git a/README.md b/README.md index d9407d5..efb0b11 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 | diff --git a/src/commands/batchApply.ts b/src/commands/batchApply.ts index af5cc2b..fe5601f 100644 --- a/src/commands/batchApply.ts +++ b/src/commands/batchApply.ts @@ -53,7 +53,8 @@ export async function batchApply(): Promise { 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); diff --git a/src/commands/initializeProject.ts b/src/commands/initializeProject.ts index 85f324d..70a91a5 100644 --- a/src/commands/initializeProject.ts +++ b/src/commands/initializeProject.ts @@ -29,9 +29,36 @@ export async function initializeProject(): Promise { 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; @@ -87,9 +114,33 @@ export function classifyAgentsFile(existingContent: string | undefined, generate : "different"; } -export async function generateAgentRules(binaryPath: string, cwd: string): Promise { - 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 { + const log = getPatchloomLog(); + const args = buildAgentRulesArgs(options); log?.logCommand(binaryPath, args, cwd); try { const { stdout, stderr } = await execFileAsync(binaryPath, args, { diff --git a/src/commands/quickActions.ts b/src/commands/quickActions.ts index 521325c..3ed5880 100644 --- a/src/commands/quickActions.ts +++ b/src/commands/quickActions.ts @@ -275,6 +275,28 @@ export async function runQuickAction(): Promise { 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 --content ` (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", @@ -747,7 +769,8 @@ export async function runQuickAction(): Promise { } 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) { @@ -886,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)}`, @@ -1036,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, @@ -1246,16 +1288,23 @@ async function ensureWorkspaceFileReady(target: WorkspaceFileTarget): Promise { + 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, diff --git a/test/unit/initializeProject.test.ts b/test/unit/initializeProject.test.ts index 0303fa2..7b407d2 100644 --- a/test/unit/initializeProject.test.ts +++ b/test/unit/initializeProject.test.ts @@ -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"; @@ -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 }[] = []; @@ -385,7 +410,7 @@ 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; @@ -393,7 +418,7 @@ test("generateAgentRules logs error to output channel on CLI failure", async () ); 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); diff --git a/test/unit/quickActions.test.ts b/test/unit/quickActions.test.ts index 7e9306f..8116d7f 100644 --- a/test/unit/quickActions.test.ts +++ b/test/unit/quickActions.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { buildAppendQuickAction, buildCreateQuickAction, + buildPrependQuickAction, buildDocAppendQuickAction, buildDocDeleteQuickAction, buildDocEnsureQuickAction, @@ -25,7 +26,8 @@ import { isStructuredDocumentPath, resolveWorkspaceRelativePath, retargetQuickAction, - withApplyFlag + withApplyFlag, + withContainFlag } from "../../src/commands/quickActions.js"; test("buildReplaceQuickAction builds a replace command for one file", () => { @@ -96,6 +98,42 @@ test("withApplyFlag appends apply once", () => { ]); }); +test("withContainFlag prefixes global --contain once", () => { + assert.deepEqual(withContainFlag(["replace", "old", "--new", "new", "f.txt"]), [ + "--contain", + "replace", + "old", + "--new", + "new", + "f.txt" + ]); + assert.deepEqual(withContainFlag(["--contain", "batch", "--apply"]), [ + "--contain", + "batch", + "--apply" + ]); + assert.deepEqual(withContainFlag(["doc", "set", "a.json", "port", "1", "--contain"]), [ + "doc", + "set", + "a.json", + "port", + "1", + "--contain" + ]); +}); + +test("buildPrependQuickAction builds a file prepend command", () => { + const action = buildPrependQuickAction("/workspace/demo/src/main.ts", "// copyright\n"); + assert.equal(action.title, "Prepend to main.ts"); + assert.deepEqual(action.args, [ + "prepend", + "/workspace/demo/src/main.ts", + "--content", + "// copyright\n" + ]); + assert.deepEqual(action.targetArgIndices, [1]); +}); + test("isStructuredDocumentPath recognizes supported structured formats", () => { assert.equal(isStructuredDocumentPath("package.json"), true); assert.equal(isStructuredDocumentPath("config.yaml"), true);