diff --git a/AGENTS.md b/AGENTS.md index 440beaa..f4611c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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/ diff --git a/README.md b/README.md index 5169a35..f9470af 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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+) | @@ -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. @@ -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. @@ -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 diff --git a/src/commands/configureMcp.ts b/src/commands/configureMcp.ts index dbcb1a0..e4b1116 100644 --- a/src/commands/configureMcp.ts +++ b/src/commands/configureMcp.ts @@ -48,7 +48,7 @@ export async function configureMcp(): Promise { [ { 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 }, { diff --git a/src/commands/quickActions.ts b/src/commands/quickActions.ts index 87a6fb5..0e93f4f 100644 --- a/src/commands/quickActions.ts +++ b/src/commands/quickActions.ts @@ -630,6 +630,73 @@ export async function runQuickAction(): Promise { 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 ` (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 ` (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", @@ -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 { diff --git a/test/unit/patchloomCli.test.ts b/test/unit/patchloomCli.test.ts index 1086f9d..98cdfbf 100644 --- a/test/unit/patchloomCli.test.ts +++ b/test/unit/patchloomCli.test.ts @@ -29,6 +29,8 @@ import { buildPatchApplyQuickAction, buildDocDeleteWhereQuickAction, buildDocGetQuickAction, + buildDocKeysQuickAction, + buildDocLenQuickAction, buildDocMergeQuickAction, buildDocSetQuickAction, buildDocUpdateQuickAction, @@ -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}`); diff --git a/test/unit/quickActions.test.ts b/test/unit/quickActions.test.ts index e0f3714..ed257ea 100644 --- a/test/unit/quickActions.test.ts +++ b/test/unit/quickActions.test.ts @@ -15,6 +15,8 @@ import { buildDocDeleteWhereQuickAction, buildDocEnsureQuickAction, buildDocGetQuickAction, + buildDocKeysQuickAction, + buildDocLenQuickAction, buildDocMergeQuickAction, buildDocMoveQuickAction, buildDocPrependQuickAction, @@ -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", () => {