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..d7f1fb7 100755 --- a/supertool.py +++ b/supertool.py @@ -11960,6 +11960,36 @@ 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 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 + 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 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 + 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..46e5b60 --- /dev/null +++ b/tests/test_cwd_op.py @@ -0,0 +1,133 @@ +"""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 + + +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