Skip to content

Commit f268e1f

Browse files
authored
feat: optional CLI 0.10 product gaps (prepend, contain, agent-rules) (#189)
* 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 <sebtardif@ncf.ca> * 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 <sebtardif@ncf.ca> --------- Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
1 parent 8eb26ca commit f268e1f

7 files changed

Lines changed: 184 additions & 17 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ src/
3333
configureMcp.ts Configure MCP command: multi-target MCP config injection
3434
initializeProject.ts Initialize Project command: generate/diff AGENTS.md
3535
managedInstall.ts Managed Install commands: install, update, reinstall Patchloom binary
36-
quickActions.ts Quick Action command: replace, tidy, doc set, search, create, doc get, patch merge
36+
quickActions.ts Quick Action command: replace, tidy, doc set, search, create, append, prepend, doc get, patch merge
3737
batchApply.ts Batch Apply command: atomic multi-operation plan via JSON
3838
setupWorkspace.ts Setup Workspace command: guided readiness walkthrough
3939
showStatus.ts Show Status command: diagnostics display
@@ -50,13 +50,13 @@ test/
5050
batchApply.test.ts Batch template and operation count parsing (11 tests)
5151
binary.test.ts Binary discovery, managed install, compatibility, workspace env (59 tests)
5252
binaryDiscovery.test.ts Real executable discovery on PATH (13 tests)
53-
initializeProject.test.ts Status display, agents file classification, formatError (24 tests)
53+
initializeProject.test.ts Status display, agents file classification, formatError (26 tests)
5454
managedLifecycle.test.ts Managed install with real file I/O (22 tests)
5555
mcpConfig.test.ts MCP config with real temp directories (9 tests)
5656
outputChannel.test.ts Output channel logging wrapper (10 tests)
5757
patchloomCli.test.ts Patchloom CLI integration with real binary + managed install e2e MCP (34 tests incl. e2e)
5858
propertyBased.test.ts Property-based tests with fast-check (13 tests)
59-
quickActions.test.ts Quick action command building, path containment, patch merge (47 tests)
59+
quickActions.test.ts Quick action command building, path containment, patch merge (50 tests)
6060
verifyMcp.test.ts MCP server verify and JSON-RPC response parsing (15 tests)
6161
downloadIntegration.test.ts HTTP download, redirect, streaming SHA-256 (9 tests)
6262
suite/

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,14 @@ Click it to see full diagnostics, including per-editor MCP configuration status
8383
| **Tidy file** | Whitespace and newline cleanup with diff preview |
8484
| **Set structured value** | Update a JSON, YAML, or TOML key with diff preview |
8585
| **Search text** | Find pattern matches across workspace files (results in output channel) |
86-
| **Create file** | Scaffold a new file and open it in the editor |
86+
| **Create file** | Scaffold a new file with optional content and open it in the editor |
8787
| **Append to file** | Append content to an existing file |
88+
| **Prepend to file** | Prepend content to the start of an existing file (CLI 0.9+) |
8889
| **Read structured value** | Read a JSON/YAML/TOML key and copy to clipboard |
8990
| **Merge patch (three-way)** | Apply a stale patch using three-way merge (v0.2.0+) |
9091

92+
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.
93+
9194
### Batch operations
9295

9396
`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
107110
| Command | Description |
108111
|---------|-------------|
109112
| `Patchloom: Setup Workspace` | Guided walkthrough for binary, AGENTS.md, and MCP readiness |
110-
| `Patchloom: Initialize Project` | Generate or diff `AGENTS.md` from `patchloom agent-rules` |
113+
| `Patchloom: Initialize Project` | Generate or diff `AGENTS.md` from `patchloom agent-rules` (mode: all/cli/mcp, platform: all/linux/windows) |
111114
| `Patchloom: Configure MCP` | Inject Patchloom MCP server config into editor config files |
112115
| `Patchloom: Quick Action` | Build a Patchloom CLI command from an interactive picker |
113116
| `Patchloom: Batch Apply` | Open a batch plan and execute all operations atomically |

src/commands/batchApply.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ export async function batchApply(): Promise<void> {
5353

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

5960
const result = await executePatchloomWithStdin(binaryPath, args, folder.uri.fsPath, plan);

src/commands/initializeProject.ts

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,36 @@ export async function initializeProject(): Promise<void> {
2929
return;
3030
}
3131

32+
const modePick = await vscode.window.showQuickPick(
33+
[
34+
{ label: "All (CLI + MCP)", description: "Default", mode: "all" as const },
35+
{ label: "CLI only", description: "Omit MCP section", mode: "cli" as const },
36+
{ label: "MCP only", description: "Lead with MCP tools", mode: "mcp" as const }
37+
],
38+
{ placeHolder: "Which agent-rules integration mode?" }
39+
);
40+
if (!modePick) {
41+
return;
42+
}
43+
44+
const platformPick = await vscode.window.showQuickPick(
45+
[
46+
{ label: "All platforms", description: "Default", platform: "all" as const },
47+
{ label: "Linux / macOS", description: "POSIX shell examples", platform: "linux" as const },
48+
{ label: "Windows", description: "Windows shell examples", platform: "windows" as const }
49+
],
50+
{ placeHolder: "Which platform for shell examples?" }
51+
);
52+
if (!platformPick) {
53+
return;
54+
}
55+
3256
let rules: string;
3357
try {
34-
rules = await generateAgentRules(binaryPath, folder.uri.fsPath);
58+
rules = await generateAgentRules(binaryPath, folder.uri.fsPath, {
59+
mode: modePick.mode,
60+
platform: platformPick.platform
61+
});
3562
} catch (error) {
3663
await vscode.window.showErrorMessage(`Failed to run patchloom agent-rules in ${folder.name}: ${formatError(error)}`);
3764
return;
@@ -87,9 +114,33 @@ export function classifyAgentsFile(existingContent: string | undefined, generate
87114
: "different";
88115
}
89116

90-
export async function generateAgentRules(binaryPath: string, cwd: string): Promise<string> {
91-
const log = getPatchloomLog();
117+
export type AgentRulesMode = "all" | "cli" | "mcp";
118+
export type AgentRulesPlatform = "all" | "linux" | "windows";
119+
120+
export interface AgentRulesOptions {
121+
readonly mode?: AgentRulesMode;
122+
readonly platform?: AgentRulesPlatform;
123+
}
124+
125+
/** Build `patchloom agent-rules` argv, omitting default `all` flags. */
126+
export function buildAgentRulesArgs(options: AgentRulesOptions = {}): string[] {
92127
const args = ["agent-rules"];
128+
if (options.mode && options.mode !== "all") {
129+
args.push("--mode", options.mode);
130+
}
131+
if (options.platform && options.platform !== "all") {
132+
args.push("--platform", options.platform);
133+
}
134+
return args;
135+
}
136+
137+
export async function generateAgentRules(
138+
binaryPath: string,
139+
cwd: string,
140+
options: AgentRulesOptions = {}
141+
): Promise<string> {
142+
const log = getPatchloomLog();
143+
const args = buildAgentRulesArgs(options);
93144
log?.logCommand(binaryPath, args, cwd);
94145
try {
95146
const { stdout, stderr } = await execFileAsync(binaryPath, args, {

src/commands/quickActions.ts

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,28 @@ export async function runQuickAction(): Promise<void> {
275275
await previewAndMaybeApply(binaryPath, target, buildAppendQuickAction(target.absolutePath, content));
276276
}
277277
},
278+
{
279+
label: "Prepend to file",
280+
description: "Prepend content to the beginning of an existing file",
281+
detail: "Builds `patchloom prepend <file> --content <text>` (CLI 0.9+)",
282+
run: async () => {
283+
const target = await pickWorkspaceFileTarget("Select a file to prepend to with Patchloom");
284+
if (!target) {
285+
return;
286+
}
287+
288+
const content = await vscode.window.showInputBox({
289+
prompt: "Content to prepend",
290+
placeHolder: "// header comment",
291+
validateInput: (value) => value.length > 0 ? undefined : "Content is required."
292+
});
293+
if (content === undefined) {
294+
return;
295+
}
296+
297+
await previewAndMaybeApply(binaryPath, target, buildPrependQuickAction(target.absolutePath, content));
298+
}
299+
},
278300
{
279301
label: "Read structured value",
280302
description: "Read a value from JSON, YAML, or TOML",
@@ -747,7 +769,8 @@ export async function runQuickAction(): Promise<void> {
747769
}
748770

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

753776
if (result.exitCode === 8) {
@@ -886,6 +909,15 @@ export function buildAppendQuickAction(targetPath: string, content: string): Pla
886909
};
887910
}
888911

912+
export function buildPrependQuickAction(targetPath: string, content: string): PlannedQuickAction {
913+
return {
914+
title: `Prepend to ${path.basename(targetPath)}`,
915+
targetPath,
916+
targetArgIndices: [1],
917+
args: ["prepend", targetPath, "--content", content]
918+
};
919+
}
920+
889921
export function buildDocGetQuickAction(targetPath: string, selector: string): PlannedQuickAction {
890922
return {
891923
title: `Get ${selector} from ${path.basename(targetPath)}`,
@@ -1036,6 +1068,16 @@ export function withApplyFlag(args: readonly string[]): string[] {
10361068
return args.includes("--apply") ? [...args] : [...args, "--apply"];
10371069
}
10381070

1071+
/**
1072+
* Prepend the global `--contain` flag so CLI ops cannot escape the cwd workspace.
1073+
* Global flags must appear before the subcommand (`patchloom --contain replace ...`).
1074+
*/
1075+
export function withContainFlag(args: readonly string[]): string[] {
1076+
return args[0] === "--contain" || args.includes("--contain")
1077+
? [...args]
1078+
: ["--contain", ...args];
1079+
}
1080+
10391081
async function previewAndMaybeApply(
10401082
binaryPath: string,
10411083
target: WorkspaceFileTarget,
@@ -1246,16 +1288,23 @@ async function ensureWorkspaceFileReady(target: WorkspaceFileTarget): Promise<bo
12461288
return true;
12471289
}
12481290

1291+
export interface ExecutePatchloomOptions {
1292+
/** When true (default), prefix args with global `--contain` for workspace path guarding. */
1293+
readonly contain?: boolean;
1294+
}
1295+
12491296
async function executePatchloom(
12501297
binaryPath: string,
12511298
args: readonly string[],
1252-
cwd: string
1299+
cwd: string,
1300+
options: ExecutePatchloomOptions = {}
12531301
): Promise<PatchloomCommandResult> {
1302+
const finalArgs = options.contain === false ? [...args] : withContainFlag(args);
12541303
const log = getPatchloomLog();
1255-
log?.logCommand(binaryPath, args, cwd);
1304+
log?.logCommand(binaryPath, finalArgs, cwd);
12561305

12571306
try {
1258-
const { stdout, stderr } = await execFileAsync(binaryPath, args, {
1307+
const { stdout, stderr } = await execFileAsync(binaryPath, finalArgs, {
12591308
cwd,
12601309
timeout: 30_000,
12611310
maxBuffer: 8 * 1024 * 1024,

test/unit/initializeProject.test.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import assert from "node:assert/strict";
22
import * as path from "node:path";
33
import test from "node:test";
44
import { MINIMUM_SUPPORTED_PATCHLOOM_VERSION } from "../../src/binary/patchloom.js";
5-
import { classifyAgentsFile, generateAgentRules } from "../../src/commands/initializeProject.js";
5+
import {
6+
buildAgentRulesArgs,
7+
classifyAgentsFile,
8+
generateAgentRules
9+
} from "../../src/commands/initializeProject.js";
610
import { buildStatusDetails, preferredStatusAction } from "../../src/commands/showStatus.js";
711
import { buildPatchloomMcpEntry, configureMcpTargets, inspectMcpTargets } from "../../src/mcp/config.js";
812
import { setPatchloomLog } from "../../src/logging/outputChannel.js";
@@ -373,6 +377,27 @@ test("configureMcpTargets creates or updates only the selected target kinds", as
373377
assert.match(writes.get(cursorPath) ?? "", /other/);
374378
});
375379

380+
test("buildAgentRulesArgs omits default all modes", () => {
381+
assert.deepEqual(buildAgentRulesArgs(), ["agent-rules"]);
382+
assert.deepEqual(buildAgentRulesArgs({ mode: "all", platform: "all" }), ["agent-rules"]);
383+
});
384+
385+
test("buildAgentRulesArgs includes non-default mode and platform", () => {
386+
assert.deepEqual(buildAgentRulesArgs({ mode: "mcp" }), ["agent-rules", "--mode", "mcp"]);
387+
assert.deepEqual(buildAgentRulesArgs({ platform: "windows" }), [
388+
"agent-rules",
389+
"--platform",
390+
"windows"
391+
]);
392+
assert.deepEqual(buildAgentRulesArgs({ mode: "cli", platform: "linux" }), [
393+
"agent-rules",
394+
"--mode",
395+
"cli",
396+
"--platform",
397+
"linux"
398+
]);
399+
});
400+
376401
test("generateAgentRules logs error to output channel on CLI failure", async () => {
377402
const logged: { exitCode: number; stdout: string; stderr: string }[] = [];
378403
const commands: { binary: string; args: readonly string[]; cwd: string }[] = [];
@@ -385,15 +410,15 @@ test("generateAgentRules logs error to output channel on CLI failure", async ()
385410
});
386411
try {
387412
await assert.rejects(
388-
() => generateAgentRules("/nonexistent/patchloom", "/tmp"),
413+
() => generateAgentRules("/nonexistent/patchloom", "/tmp", { mode: "mcp", platform: "linux" }),
389414
(err: Error) => {
390415
assert.match(err.message, /ENOENT|not found|No such file/i);
391416
return true;
392417
}
393418
);
394419
assert.equal(commands.length, 1, "logCommand should be called once");
395420
assert.equal(commands[0].binary, "/nonexistent/patchloom");
396-
assert.deepEqual(commands[0].args, ["agent-rules"]);
421+
assert.deepEqual(commands[0].args, ["agent-rules", "--mode", "mcp", "--platform", "linux"]);
397422
assert.equal(commands[0].cwd, "/tmp");
398423
assert.equal(logged.length, 1, "logResult should be called once on failure");
399424
assert.equal(logged[0].exitCode, 1);

test/unit/quickActions.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import test from "node:test";
33
import {
44
buildAppendQuickAction,
55
buildCreateQuickAction,
6+
buildPrependQuickAction,
67
buildDocAppendQuickAction,
78
buildDocDeleteQuickAction,
89
buildDocEnsureQuickAction,
@@ -25,7 +26,8 @@ import {
2526
isStructuredDocumentPath,
2627
resolveWorkspaceRelativePath,
2728
retargetQuickAction,
28-
withApplyFlag
29+
withApplyFlag,
30+
withContainFlag
2931
} from "../../src/commands/quickActions.js";
3032

3133
test("buildReplaceQuickAction builds a replace command for one file", () => {
@@ -96,6 +98,42 @@ test("withApplyFlag appends apply once", () => {
9698
]);
9799
});
98100

101+
test("withContainFlag prefixes global --contain once", () => {
102+
assert.deepEqual(withContainFlag(["replace", "old", "--new", "new", "f.txt"]), [
103+
"--contain",
104+
"replace",
105+
"old",
106+
"--new",
107+
"new",
108+
"f.txt"
109+
]);
110+
assert.deepEqual(withContainFlag(["--contain", "batch", "--apply"]), [
111+
"--contain",
112+
"batch",
113+
"--apply"
114+
]);
115+
assert.deepEqual(withContainFlag(["doc", "set", "a.json", "port", "1", "--contain"]), [
116+
"doc",
117+
"set",
118+
"a.json",
119+
"port",
120+
"1",
121+
"--contain"
122+
]);
123+
});
124+
125+
test("buildPrependQuickAction builds a file prepend command", () => {
126+
const action = buildPrependQuickAction("/workspace/demo/src/main.ts", "// copyright\n");
127+
assert.equal(action.title, "Prepend to main.ts");
128+
assert.deepEqual(action.args, [
129+
"prepend",
130+
"/workspace/demo/src/main.ts",
131+
"--content",
132+
"// copyright\n"
133+
]);
134+
assert.deepEqual(action.targetArgIndices, [1]);
135+
});
136+
99137
test("isStructuredDocumentPath recognizes supported structured formats", () => {
100138
assert.equal(isStructuredDocumentPath("package.json"), true);
101139
assert.equal(isStructuredDocumentPath("config.yaml"), true);

0 commit comments

Comments
 (0)