From 75b9032b43c62199b5d5a18fead72be85bdc43f2 Mon Sep 17 00:00:00 2001 From: Florian DAVID <150798857+fdaviddpt@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:18:48 +0200 Subject: [PATCH 1/2] feat(cwd): add cwd:PATH op to set working dir for a whole call (#339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cwd:PATH as the first op chdir's once before dispatch, then is stripped, so every following op resolves against PATH. Removes the `cd &&` prefix cross-repo sessions needed on every call — a leading `cd` trips the use-supertool hook and a stale cwd silently poisons relative path resolution (the #336 trap). Handled in the pre-pass like --plain, so it never races the parallel read path or forces a batch sequential. Op not flag (keeps the single grammar); required-first so a mid-call cwd can't half-apply against the old dir. ~/$VAR expanded; non-directory or non-first errors before any op runs. Docs: op table (index/meta), builtin-ops entry, CHANGELOG. 5 tests. Co-Authored-By: Max --- .supertool.json | 6 +++ CHANGELOG.md | 1 + docs/operations/index.md | 3 +- docs/operations/meta.md | 1 + supertool.py | 24 +++++++++++ tests/test_cwd_op.py | 93 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 tests/test_cwd_op.py diff --git a/.supertool.json b/.supertool.json index 91c615a..8aa368a 100644 --- a/.supertool.json +++ b/.supertool.json @@ -120,6 +120,12 @@ "description": "Show supertool version", "example": "version" }, + "cwd": { + "syntax": "cwd:PATH", + "description": "Set working dir for the whole call. MUST be first op. chdir before dispatch, then stripped. Avoids `cd ... &&` prefix (hook-safe, no stale-cwd poisoning). ~/$VAR expanded", + "example": "cwd:~/repo", + "hint": true + }, "tree": { "syntax": "tree:PATH[:DEPTH]", "description": "Dir tree, depth=N (def 3). Hides dotfiles", diff --git a/CHANGELOG.md b/CHANGELOG.md index 52d2dee..99c02b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`cwd:PATH` op sets the working directory for a whole call** — `cwd:~/repo` as the *first* op chdir's once before any dispatch, so every following op resolves against `PATH`, then it's stripped (it never reaches the dispatch loop). Mirrors `cd PATH && ./supertool …` without the `cd`: a line starting with `cd` trips the `use-supertool` hook, and a forgotten/stale `cd` silently poisons relative path resolution (the exact trap that made `git-diff:.supertool.json` report "No changes" against the wrong clone in #336). Cross-repo sessions — a product repo plus the supertool clone — otherwise needed a `cd &&` prefix on every other call. It's an op, not a `--flag`, to keep supertool's single grammar; handled in the pre-pass (like `--plain`) so it can't race the parallel read path or force a batch sequential. Required-first on purpose: an op after it would have already resolved against the old dir, so a mid-call `cwd:` is rejected (`must be the first op`) rather than silently half-applied. `~`/`$VAR` are expanded; a non-directory target errors before any op runs. Closes [#339](https://github.com/Digital-Process-Tools/claude-supertool/issues/339). - **Validators/formatters gained a per-spec `exclude` glob** — a validator or formatter spec may now set `"exclude": "*tests/*"` (or a list of globs, skip-if-any-matches) to skip files even when its `match` glob hits. The motivating case: editing a PHPUnit test file fired the `phpmd` validator, which reported `TooManyPublicMethods` (every `testXxx()` is public) — pure noise, since no real gate scans tests (CI mess-detection scans the source dir only; the pre-push git-hook skips any `/tests/` path). Validators were gated by a single positive `match` glob with no exclude, so there was no config-only fix. `exclude` is per-spec on purpose: a blanket "skip tests" would break `phpunit`, which *must* run on test files. Generalizes the prior `PSR_EXCLUDE` env hack into a first-class config key. Closes [#335](https://github.com/Digital-Process-Tools/claude-supertool/issues/335). - **`gl-mr` now lists the per-file name-status by default** — the MR dashboard prints a `## Files (N)` block with one `A`/`D`/`R`/`M` line per changed path, sourced from the paginated `merge_requests/:iid/diffs` endpoint (so the change type comes straight from GitLab's `new_file`/`deleted_file`/`renamed_file` flags, no git shellout). "What got removed?" is the high-signal question when reviewing an MR, and previously `gl-mr` gave only a file *count* — forcing a separate `git diff --name-status master...branch` round-trip, exactly the borrowed round-trip the variants exist to kill (the concrete trigger was auditing a deleted-migration concern on a real MR). The list is capped at 50 files with a `… +N more` marker so large MRs don't blow context; `gl-mr:N:full` uncaps it (paginating up to 500 files) alongside the existing comment uncap. Any API/parse failure silently omits the block. Closes [#332](https://github.com/Digital-Process-Tools/claude-supertool/issues/332). - **`git-status` gained a `:full` mode** — `git-status:full` (alias `:porcelain`) uncaps every list in the dashboard (staged/unstaged/untracked files, other branches, stashes), printing the complete untruncated set instead of the default `... (N more)` markers. The default view stays capped (20 staged/unstaged, 10 untracked/branches, 5 stashes) — a cheap overview that answers ahead/behind + dirty + MR. The bug wasn't the truncation (correct for the common case) but the lack of an escape hatch: driving precise staging — e.g. excluding a few pre-existing untracked items from a large commit — needs the full machine-readable list, which previously forced a drop back to raw `git status --porcelain`. Closes [#330](https://github.com/Digital-Process-Tools/claude-supertool/issues/330). diff --git a/docs/operations/index.md b/docs/operations/index.md index 6577866..e4a21aa 100644 --- a/docs/operations/index.md +++ b/docs/operations/index.md @@ -11,7 +11,7 @@ | **Symbol map** | `map` | [map.md](map.md) | | **Edits** | `edit`, `replace`, `replace_dry`, `replace_lines`, `paste`, `vim` | [edits.md](edits.md) | | **Validate / Format** | `validate`, `format`, `validate_staged`, `format_staged` | — | -| **Meta** | `introduction`, `output-format`, `ops`, `version` | [meta.md](meta.md) | +| **Meta** | `cwd`, `introduction`, `output-format`, `ops`, `version` | [meta.md](meta.md) | ## Full op table @@ -40,6 +40,7 @@ | `tree` | `tree:PATH` or `tree:PATH:DEPTH` | Directory structure with depth limit (default 3). Hides dotfiles. Files listed before subdirectories. | | `blame` | `blame:PATH:LINE` or `blame:PATH:LINE:N` | Git blame for N lines (default 5) around a specific line number. Requires git repo. | | `version` | `version` | Show supertool version. | +| `cwd` | `cwd:PATH` | Set the working dir for the whole call. **Must be the first op** — chdir's once before any dispatch (so every following op resolves against `PATH`), then is stripped. Mirrors `cd PATH && …` without the `cd` (which trips the use-supertool hook and risks stale-cwd path poisoning). `~`/`$VAR` expanded; non-directory or non-first → error before any op runs. | | `edit` | `edit:::OLD:::NEW:::PATH` | Single-file, single-occurrence edit (mirrors native Edit). Errors if 0 or >1 matches. **Bypasses native Edit must-Read state** — saves a round-trip when you already know the unique snippet. Use `:::` separator so content with `:` works. | | `replace_lines` | `replace_lines:::PATH:::START:::END:::CONTENT` | Swap lines `[START, END]` (1-indexed, inclusive) with CONTENT. `END < START` = pure insert before line START. Empty CONTENT = delete. Receipt shows new line numbers + ±2 context. | | `paste` | `paste:::PATH:::CONTENT` | **NARROW USE:** replace ENTIRE file. Only for creating a new file or fully rewriting one. NOT for partial edits — `vim` is the default for those. Atomic, creates file + parent dirs if missing. CONTENT via triple-colon → holds any chars (`:`, quotes, braces, newlines). | diff --git a/docs/operations/meta.md b/docs/operations/meta.md index b16ee7f..b9c6635 100644 --- a/docs/operations/meta.md +++ b/docs/operations/meta.md @@ -10,6 +10,7 @@ Ops for self-documentation and version introspection. Used primarily in session- | `output-format` | `output-format` | Output format examples from `.supertool.json`. Shows what responses look like. | | `ops` | `ops` | Full operations reference from `.supertool.json` — built-in ops, custom ops, and aliases with descriptions and examples. | | `version` | `version` | Show supertool version. | +| `cwd` | `cwd:PATH` | Set the working dir for the whole call. **Must be the first op** — chdir's once before dispatch, then is stripped, so every following op resolves against `PATH`. Replaces a `cd PATH && ./supertool …` prefix (which trips the use-supertool hook and risks stale-cwd path poisoning) for cross-repo sessions. `~`/`$VAR` expanded; non-directory or non-first → error before any op runs. | ## Common patterns diff --git a/supertool.py b/supertool.py index 6caedd9..3d8f6e3 100755 --- a/supertool.py +++ b/supertool.py @@ -11960,6 +11960,30 @@ def main(argv: List[str]) -> int: ) return 1 + # cwd:PATH — must be the FIRST op. chdir once before any dispatch so every + # remaining op resolves against PATH (mirrors `cd PATH && …`), then strip + # it. Handled here in the pre-pass (like --plain) — never reaches dispatch, + # so it can't race the parallel read path or force a batch sequential. + # Required-first keeps the rule unambiguous: appearing later is an error, + # not a silently-honored mid-call cwd switch. + cwd_positions = [i for i, a in enumerate(argv) if a.split(":", 1)[0] == "cwd"] + if cwd_positions: + if cwd_positions != [0]: + sys.stderr.write("cwd: must be the first op (cwd:PATH op1 op2 ...)\n") + return 1 + spec = argv[0] + if ":" not in spec: + sys.stderr.write("cwd: requires a path (cwd:PATH)\n") + return 1 + target = os.path.expanduser(os.path.expandvars(spec.split(":", 1)[1])) + if not os.path.isdir(target): + sys.stderr.write(f"cwd: not a directory: {target}\n") + return 1 + os.chdir(target) + argv = argv[1:] + if not argv: + return 0 + # Normal batched-ops mode total_out_bytes = 0 any_failure = False diff --git a/tests/test_cwd_op.py b/tests/test_cwd_op.py new file mode 100644 index 0000000..3aba366 --- /dev/null +++ b/tests/test_cwd_op.py @@ -0,0 +1,93 @@ +"""cwd: op — set the working dir for a whole call. + +Cross-repo sessions otherwise need a `cd && ./supertool …` prefix on +every call (shell cwd resets between Bash invocations). A leading `cd` trips the +use-supertool hook and risks cwd-poisoning (relative greps/diffs resolving +against the wrong repo). + +`cwd:PATH` mirrors `cd PATH && …`: it MUST be the first op, is consumed in a +pre-pass before dispatch (chdir once, then stripped), so the remaining ops all +resolve against PATH and keep their parallel fast-path. Required-first keeps the +mental model unambiguous — every op after it runs in the new dir. +""" +from __future__ import annotations + +import os + +import pytest + +import supertool + + +@pytest.fixture +def restore_cwd(): + """cwd: mutates process cwd; snapshot + restore so it doesn't bleed.""" + saved = os.getcwd() + yield + os.chdir(saved) + + +def test_cwd_op_chdirs_before_dispatch_and_is_stripped(tmp_path, monkeypatch, restore_cwd) -> None: + seen_ops: list[str] = [] + seen_cwd: list[str] = [] + monkeypatch.setattr( + supertool, "dispatch", + lambda a: (seen_ops.append(a), seen_cwd.append(os.getcwd()), "")[-1], + ) + monkeypatch.setattr(supertool, "log_call", lambda *a, **k: None) + + rc = supertool.main([f"cwd:{tmp_path}", "read:foo"]) + + assert rc == 0 + assert seen_ops == ["read:foo"] # cwd op stripped + assert os.path.realpath(seen_cwd[0]) == os.path.realpath(str(tmp_path)) # chdir'd before op ran + + +def test_cwd_op_expands_tilde(monkeypatch, restore_cwd) -> None: + seen_cwd: list[str] = [] + monkeypatch.setattr( + supertool, "dispatch", + lambda a: (seen_cwd.append(os.getcwd()), "")[-1], + ) + monkeypatch.setattr(supertool, "log_call", lambda *a, **k: None) + + rc = supertool.main(["cwd:~", "read:foo"]) + + assert rc == 0 + assert os.path.realpath(seen_cwd[0]) == os.path.realpath(os.path.expanduser("~")) + + +def test_cwd_op_missing_dir_errors_without_chdir(tmp_path, monkeypatch, restore_cwd) -> None: + before = os.getcwd() + called: list[str] = [] + monkeypatch.setattr(supertool, "dispatch", lambda a: called.append(a) or "") + monkeypatch.setattr(supertool, "log_call", lambda *a, **k: None) + + rc = supertool.main([f"cwd:{tmp_path}/does-not-exist", "read:foo"]) + + assert rc == 1 + assert called == [] # bailed before dispatch + assert os.getcwd() == before # cwd untouched + + +def test_cwd_op_must_be_first(tmp_path, monkeypatch, restore_cwd) -> None: + before = os.getcwd() + called: list[str] = [] + monkeypatch.setattr(supertool, "dispatch", lambda a: called.append(a) or "") + monkeypatch.setattr(supertool, "log_call", lambda *a, **k: None) + + rc = supertool.main(["read:foo", f"cwd:{tmp_path}"]) + + assert rc == 1 + assert called == [] # rejected before any dispatch + assert os.getcwd() == before # cwd untouched + + +def test_no_cwd_op_leaves_cwd_untouched(monkeypatch, restore_cwd) -> None: + before = os.getcwd() + monkeypatch.setattr(supertool, "dispatch", lambda a: "") + monkeypatch.setattr(supertool, "log_call", lambda *a, **k: None) + + supertool.main(["read:foo"]) + + assert os.getcwd() == before From bc816a121c7dc89e611274255a9497ea3cad6004 Mon Sep 17 00:00:00 2001 From: Florian DAVID <150798857+fdaviddpt@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:37:30 +0200 Subject: [PATCH 2/2] fix(cwd): clearer errors for empty path and multiple cwd ops (#339) Review follow-up: empty `cwd:` now errors "empty path" instead of the confusing "not a directory: " (blank). Multiple cwd ops error "only one cwd: op is allowed" instead of the misdirecting "must be the first op". Adds 3 tests: $VAR expansion, empty-path, double-cwd (8 total). Co-Authored-By: Max --- supertool.py | 6 ++++++ tests/test_cwd_op.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/supertool.py b/supertool.py index 3d8f6e3..d7f1fb7 100755 --- a/supertool.py +++ b/supertool.py @@ -11968,6 +11968,9 @@ def main(argv: List[str]) -> int: # not a silently-honored mid-call cwd switch. cwd_positions = [i for i, a in enumerate(argv) if a.split(":", 1)[0] == "cwd"] if cwd_positions: + if len(cwd_positions) > 1: + sys.stderr.write("cwd: only one cwd: op is allowed per call\n") + return 1 if cwd_positions != [0]: sys.stderr.write("cwd: must be the first op (cwd:PATH op1 op2 ...)\n") return 1 @@ -11976,6 +11979,9 @@ def main(argv: List[str]) -> int: sys.stderr.write("cwd: requires a path (cwd:PATH)\n") return 1 target = os.path.expanduser(os.path.expandvars(spec.split(":", 1)[1])) + if not target: + sys.stderr.write("cwd: empty path (cwd:PATH)\n") + return 1 if not os.path.isdir(target): sys.stderr.write(f"cwd: not a directory: {target}\n") return 1 diff --git a/tests/test_cwd_op.py b/tests/test_cwd_op.py index 3aba366..46e5b60 100644 --- a/tests/test_cwd_op.py +++ b/tests/test_cwd_op.py @@ -91,3 +91,43 @@ def test_no_cwd_op_leaves_cwd_untouched(monkeypatch, restore_cwd) -> None: supertool.main(["read:foo"]) assert os.getcwd() == before + + +def test_cwd_op_expands_env_var(monkeypatch, restore_cwd) -> None: + seen_cwd: list[str] = [] + monkeypatch.setattr( + supertool, "dispatch", + lambda a: (seen_cwd.append(os.getcwd()), "")[-1], + ) + monkeypatch.setattr(supertool, "log_call", lambda *a, **k: None) + + rc = supertool.main(["cwd:$HOME", "read:foo"]) + + assert rc == 0 + assert os.path.realpath(seen_cwd[0]) == os.path.realpath(os.path.expanduser("~")) + + +def test_cwd_op_empty_path_errors(monkeypatch, restore_cwd) -> None: + before = os.getcwd() + called: list[str] = [] + monkeypatch.setattr(supertool, "dispatch", lambda a: called.append(a) or "") + monkeypatch.setattr(supertool, "log_call", lambda *a, **k: None) + + rc = supertool.main(["cwd:", "read:foo"]) + + assert rc == 1 + assert called == [] # bailed before dispatch + assert os.getcwd() == before + + +def test_cwd_op_rejects_multiple(tmp_path, monkeypatch, restore_cwd) -> None: + before = os.getcwd() + called: list[str] = [] + monkeypatch.setattr(supertool, "dispatch", lambda a: called.append(a) or "") + monkeypatch.setattr(supertool, "log_call", lambda *a, **k: None) + + rc = supertool.main([f"cwd:{tmp_path}", "read:foo", f"cwd:{tmp_path}"]) + + assert rc == 1 + assert called == [] # rejected before any dispatch + assert os.getcwd() == before