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 @@ -34,7 +34,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, append, prepend, doc get, patch merge
quickActions.ts Quick Action command: replace, tidy, doc set, search, create, append, prepend, doc get, doc keys, doc len, patch merge
batchApply.ts Batch Apply command: atomic multi-operation plan (line-oriented, not JSON)
setupWorkspace.ts Setup Workspace command: guided readiness walkthrough
showStatus.ts Show Status command: diagnostics display
Expand All @@ -60,9 +60,9 @@ test/
mcpRegister.test.ts Native MCP definition helper for binary path (6 tests)
statusRefresh.test.ts Status and MCP refresh order after input change (1 test)
outputChannel.test.ts Output channel logging wrapper (22 tests)
patchloomCli.test.ts Patchloom CLI integration with real binary + managed install e2e MCP (49 tests incl. e2e)
patchloomCli.test.ts Patchloom CLI integration with real binary + managed install e2e MCP (51 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 (90 tests)
quickActions.test.ts Quick action command building, path containment, patch merge (94 tests)
verifyMcp.test.ts MCP server verify and JSON-RPC response parsing (15 tests)
downloadIntegration.test.ts HTTP download, redirect, streaming SHA-256 (12 tests)
suite/
Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Run `Patchloom: Setup Workspace` to walk through everything your project needs:

When configuring, pick **Full tool inventory** (default) or **Core pack**. Core sets `PATCHLOOM_MCP_SURFACE=core` on the server entry. Existing servers in JSON or JSONC (`//` comments, trailing commas) stay in the file. A config that is not an object is left unchanged and the command reports an error.

CLI **0.31.0** (and 0.24+) exposes **58** MCP tools by default (including `list_files` and `apply_fragment`). The core pack is 11 tools: `read_file`, `search_files`, `list_files`, `replace_text`, `batch_replace`, `doc_get`, `doc_set`, `doc_query`, `md_replace_section`, `execute_plan`, `server_info`. `search_files` accepts `files_without_match` (CLI 0.29+). `apply_patch` accepts unified diffs, Codex `*** Begin Patch`, and Aider SEARCH/REPLACE (CLI 0.30+). Absolute paths that resolve inside the MCP workspace root are allowed; empty paths, `../`, and outside paths still reject with stable `error_kind` peels.
CLI **0.33.0** (and 0.24+) exposes **58** MCP tools by default (including `list_files` and `apply_fragment`). The core pack is 11 tools: `read_file`, `search_files`, `list_files`, `replace_text`, `batch_replace`, `doc_get`, `doc_set`, `doc_query`, `md_replace_section`, `execute_plan`, `server_info`. `search_files` accepts `files_without_match` (CLI 0.29+). `apply_patch` accepts unified diffs, Codex `*** Begin Patch`, and Aider SEARCH/REPLACE (CLI 0.30+). `doc_query` can list object keys and count array or object length (CLI 0.32+). Search, replace, and tidy dests accept cwd-only globs such as `*.txt` (CLI 0.33+; use `**/*.txt` or `--glob` for nested files). Absolute paths that resolve inside the MCP workspace root are allowed; empty paths, `../`, and outside paths still reject with stable `error_kind` peels.

### Status bar

Expand Down Expand Up @@ -100,6 +100,8 @@ Click it to see full diagnostics, including per-editor MCP configuration status
| **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 |
| **List structured keys** | List object keys at a selector (`doc keys`, CLI 0.32+; `.` is the document root) |
| **Count structured length** | Count array items or object keys (`doc len`, CLI 0.32+) |
| **Delete structured value** | Remove a key from JSON, YAML, or TOML with diff preview |
| **Delete matching array items** | Remove array items matching a predicate (`doc delete-where`, CLI 0.27+; array path plus `key=value` predicate) |
| **Merge into structured file** | Merge a partial JSON object into a config file (optional multi-doc selector, CLI 0.16+) |
Expand Down Expand Up @@ -179,7 +181,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 / upgrade path**
The extension requires Patchloom **0.3.0** or newer; **0.31.0** is recommended. Which fix to use depends on how the CLI was resolved (status shows Source):
The extension requires Patchloom **0.3.0** or newer; **0.33.0** is recommended. Which fix to use depends on how the CLI was resolved (status shows Source):

1. **Source: managed install** → **Patchloom: Update Patchloom** (checksum-verified GitHub release into extension storage)
2. **Source: PATH** → upgrade that install in place (**Scoop** `scoop update patchloom` on Windows; Homebrew / npm / cargo / the official installer elsewhere). Managed Install will not override a PATH binary.
Expand All @@ -203,7 +205,11 @@ On CLI 0.30+, selectors accept `!=`, `>`, `>=`, `<`, `<=`, and `[!key]` (for exa
On CLI 0.29+, `search -L` / `--files-without-match` lists files that do not contain the pattern. Combining it with `--files-with-matches` or `--count` is `invalid_input`. When every scanned file contains the pattern, the CLI reports `error_kind: no_matches` and the text `no files without matches for 'PATTERN' in SCOPE` (that is not a content miss).

**YAML mapping alias stayed an alias**
On CLI 0.31+, `doc set` on a mapping that is only `service_a: *shared` writes a merge key plus local fields (`<<: *shared` and your new keys) instead of inlining the whole object. Sequence items (`- *shared`) still expand or stay not-applied.
On CLI 0.31+, `doc set` on a mapping that is only `service_a: *shared` writes a merge key plus local fields (`<<: *shared` and your new keys) instead of inlining the whole object. Sequence items (`- *shared`) still expand or stay not-applied. CLI 0.32+ also keeps the merge key when you grow an inherited array, delete an inherited field, or write an empty object.

On CLI 0.32+, **List structured keys** and **Count structured length** run `doc keys` and `doc len`. Use `.` for the document root. An array on `doc keys` is `type_error`. A wildcard or predicate (`items[*]`) is `ambiguous`.

On CLI 0.33+, a search, replace, or tidy dest of `*.txt` matches files in the current directory only. `sub/*.txt` stays in that directory. Use `**/*.txt` or `--glob '*.txt'` when you want nested files. That dest glob is not the same as the Search Quick Action `--glob` field, which still walks nested files.

**Patch apply formats**
On CLI 0.30+, `patch apply` (and MCP `apply_patch`) accepts unified diffs, Codex `*** Begin Patch`, and Aider SEARCH/REPLACE. Update and SEARCH matches must be unique unless you pass `--replace-all` (SEARCH/REPLACE only). The Quick Action **Apply patch (unified / Begin Patch / SEARCH-REPLACE)** builds `patch apply`. **Merge patch (three-way)** is still `patch merge` for stale unified diffs.
Expand Down Expand Up @@ -262,7 +268,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.31.0+ recommended** for YAML alias-to-merge on `doc set`, create/rename `parent path is not a directory`, numeric selector compares (`servers[port>8000]`), `search -L` / `files_without_match`, Codex Begin Patch and Aider SEARCH/REPLACE on `patch apply`, `agent-rules --surface core` honoring `--mode`, empty-path fail-closed (`path must not be empty`), `suggested_op` on fail-closed doc navigation, `not_regular_file` soft peels, ambiguous markdown headings, `list_files` MCP inventory, `apply-fragment`, full `error_kind` peels (`binary` / `invalid_encoding` / `fuzzy_span_suspicious` / `already_exists` / `guard_rejected` / `ambiguous`), optional `PATCHLOOM_MCP_SURFACE=core` 11-tool pack, multi-doc `doc merge --selector`, line-oriented inserts, batch `replace PATH OLD NEW` hints, 58 MCP tools, and agent-facing JSON envelopes)
- [Patchloom CLI](https://github.com/patchloom/patchloom) 0.3.0 or newer (**0.33.0+ recommended** for dest globs on search/replace/tidy (`*.txt` is cwd-only), `doc keys` / `doc len`, YAML merge-key edits that keep `<<:` and comments, Windows dest/`--cwd` rules, charset / EditorConfig `charset`, YAML alias-to-merge on `doc set`, create/rename `parent path is not a directory`, numeric selector compares (`servers[port>8000]`), `search -L` / `files_without_match`, Codex Begin Patch and Aider SEARCH/REPLACE on `patch apply`, `agent-rules --surface core` honoring `--mode`, empty-path fail-closed (`path must not be empty`), `suggested_op` on fail-closed doc navigation, `not_regular_file` soft peels, ambiguous markdown headings, `list_files` MCP inventory, `apply-fragment`, full `error_kind` peels (`binary` / `invalid_encoding` / `fuzzy_span_suspicious` / `already_exists` / `guard_rejected` / `ambiguous`), optional `PATCHLOOM_MCP_SURFACE=core` 11-tool pack, multi-doc `doc merge --selector`, line-oriented inserts, batch `replace PATH OLD NEW` hints, 58 MCP tools, and agent-facing JSON envelopes)

## Contributing

Expand Down
2 changes: 1 addition & 1 deletion src/commands/configureMcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export async function configureMcp(): Promise<void> {
[
{
label: "Full tool inventory",
description: "Default (58 tools on CLI 0.24+; verified on 0.31)",
description: "Default (58 tools on CLI 0.24+; verified on 0.33)",
surface: "full" as const
},
{
Expand Down
87 changes: 87 additions & 0 deletions src/commands/quickActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,73 @@ export async function runQuickAction(): Promise<void> {
await vscode.window.showInformationMessage(`${selector} = ${value} (copied to clipboard)`);
}
},
{
label: "List structured keys",
description: "List object keys at a JSON, YAML, or TOML selector",
detail: "Builds `patchloom doc keys <file> <selector>` (CLI 0.32+). Use `.` for the document root.",
run: async () => {
const target = await pickStructuredDocumentTarget("doc keys");
if (!target) {
return;
}

const selector = await vscode.window.showInputBox({
prompt: "Selector path (use . for the document root)",
placeHolder: ".",
value: ".",
validateInput: (value) => value.length > 0 ? undefined : "Selector is required."
});
if (selector === undefined) {
return;
}

const action = buildDocKeysQuickAction(target.absolutePath, selector);
const result = await executePatchloom(binaryPath, action, target.workspaceFolder.uri.fsPath);

if (result.exitCode !== 0) {
await vscode.window.showErrorMessage(`Patchloom doc keys failed: ${formatCliOutput(result)}`);
return;
}

presentCliResultInOutput(getPatchloomLog(), result);
const value = result.stdout.trim();
await vscode.env.clipboard.writeText(value);
await vscode.window.showInformationMessage(`Keys at ${selector} copied to clipboard`);
}
},
{
label: "Count structured length",
description: "Count array items or object keys at a selector",
detail: "Builds `patchloom doc len <file> <selector>` (CLI 0.32+). Use `.` for the document root.",
run: async () => {
const target = await pickStructuredDocumentTarget("doc len");
if (!target) {
return;
}

const selector = await vscode.window.showInputBox({
prompt: "Selector path (use . for the document root)",
placeHolder: "items",
validateInput: (value) => value.length > 0 ? undefined : "Selector is required."
});
if (selector === undefined) {
return;
}

const action = buildDocLenQuickAction(target.absolutePath, selector);
const result = await executePatchloom(binaryPath, action, target.workspaceFolder.uri.fsPath);

if (result.exitCode !== 0) {
await vscode.window.showErrorMessage(`Patchloom doc len failed: ${formatCliOutput(result)}`);
return;
}

presentCliResultInOutput(getPatchloomLog(), result);
const value = result.stdout.trim();
await vscode.env.clipboard.writeText(value);
await vscode.window.showInformationMessage(`${selector} length = ${value} (copied to clipboard)`);
}
},
{
label: "Delete structured value",
description: "Remove a key from JSON, YAML, or TOML with diff preview",
Expand Down Expand Up @@ -1387,6 +1454,26 @@ export function buildDocGetQuickAction(targetPath: string, selector: string): Pl
};
}

export function buildDocKeysQuickAction(targetPath: string, selector: string): PlannedQuickAction {
const args = withEndOfOptions(["doc", "keys"], [targetPath, selector]);
return {
title: `Keys at ${selector} in ${path.basename(targetPath)}`,
targetPath,
targetArgIndices: [3],
args
};
}

export function buildDocLenQuickAction(targetPath: string, selector: string): PlannedQuickAction {
const args = withEndOfOptions(["doc", "len"], [targetPath, selector]);
return {
title: `Length of ${selector} in ${path.basename(targetPath)}`,
targetPath,
targetArgIndices: [3],
args
};
}

export function buildDocDeleteQuickAction(targetPath: string, selector: string): PlannedQuickAction {
const args = withEndOfOptions(["doc", "delete"], [targetPath, selector]);
return {
Expand Down
57 changes: 57 additions & 0 deletions test/unit/patchloomCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import {
buildPatchApplyQuickAction,
buildDocDeleteWhereQuickAction,
buildDocGetQuickAction,
buildDocKeysQuickAction,
buildDocLenQuickAction,
buildDocMergeQuickAction,
buildDocSetQuickAction,
buildDocUpdateQuickAction,
Expand Down Expand Up @@ -742,6 +744,61 @@ describe("patchloom CLI integration", async () => {
});
});

test("doc keys and doc len via Quick Action args (CLI 0.32+)", async (t) => {
const { stdout, stderr } = await execFileAsync(binaryPath, ["--version"], { timeout: 5000 });
const version = parsePatchloomVersion(`${stdout}${stderr}`);
if (!version || comparePatchloomVersions(version, "0.32.0") < 0) {
t.skip(`requires patchloom >= 0.32.0 (found ${version ?? "unknown"})`);
return;
}

await withTempDir(async (dir) => {
const file = path.join(dir, "cfg.json");
await fs.writeFile(file, JSON.stringify({ port: 8080, name: "demo" }), "utf8");

const keysAction = buildDocKeysQuickAction(file, ".");
const keys = await execFileAsync(
binaryPath,
serializePatchloomArgs({ args: keysAction.args }),
{ timeout: 5000 }
);
assert.match(keys.stdout, /port/);
assert.match(keys.stdout, /name/);

const lenAction = buildDocLenQuickAction(file, ".");
const len = await execFileAsync(
binaryPath,
serializePatchloomArgs({ args: lenAction.args }),
{ timeout: 5000 }
);
assert.match(len.stdout.trim(), /^2$/);
});
});

test("search dest glob is cwd-only (CLI 0.33+)", async (t) => {
const { stdout, stderr } = await execFileAsync(binaryPath, ["--version"], { timeout: 5000 });
const version = parsePatchloomVersion(`${stdout}${stderr}`);
if (!version || comparePatchloomVersions(version, "0.33.0") < 0) {
t.skip(`requires patchloom >= 0.33.0 (found ${version ?? "unknown"})`);
return;
}

await withTempDir(async (dir) => {
await fs.writeFile(path.join(dir, "hit.txt"), "needle\n", "utf8");
await fs.mkdir(path.join(dir, "nested"));
await fs.writeFile(path.join(dir, "nested", "miss.txt"), "needle\n", "utf8");

const action = buildSearchQuickAction("*.txt", "needle");
const result = await execFileAsync(
binaryPath,
serializePatchloomArgs({ args: action.args }),
{ cwd: dir, timeout: 5000 }
);
assert.match(result.stdout, /hit\.txt/);
assert.doesNotMatch(result.stdout, /nested/);
});
});

test("doc set YAML alias via Quick Action args (CLI 0.31+)", async (t) => {
const { stdout, stderr } = await execFileAsync(binaryPath, ["--version"], { timeout: 5000 });
const version = parsePatchloomVersion(`${stdout}${stderr}`);
Expand Down
18 changes: 18 additions & 0 deletions test/unit/quickActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
buildDocDeleteWhereQuickAction,
buildDocEnsureQuickAction,
buildDocGetQuickAction,
buildDocKeysQuickAction,
buildDocLenQuickAction,
buildDocMergeQuickAction,
buildDocMoveQuickAction,
buildDocPrependQuickAction,
Expand Down Expand Up @@ -430,6 +432,22 @@ test("buildDocGetQuickAction builds a doc get command", () => {
assert.deepEqual(action.targetArgIndices, [3]);
});

test("buildDocKeysQuickAction builds a doc keys command", () => {
const action = buildDocKeysQuickAction("/workspace/demo/package.json", ".");

assert.equal(action.title, "Keys at . in package.json");
assert.deepEqual(action.args, ["doc", "keys", "--", "/workspace/demo/package.json", "."]);
assert.deepEqual(action.targetArgIndices, [3]);
});

test("buildDocLenQuickAction builds a doc len command", () => {
const action = buildDocLenQuickAction("/workspace/demo/data.json", "items");

assert.equal(action.title, "Length of items in data.json");
assert.deepEqual(action.args, ["doc", "len", "--", "/workspace/demo/data.json", "items"]);
assert.deepEqual(action.targetArgIndices, [3]);
});

// --- #33: resolveWorkspaceRelativePath path containment ---

test("resolveWorkspaceRelativePath accepts path inside workspace", () => {
Expand Down
Loading