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: 6 additions & 0 deletions .supertool.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <repo> &&` 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).
Expand Down
3 changes: 2 additions & 1 deletion docs/operations/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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). |
Expand Down
1 change: 1 addition & 0 deletions docs/operations/meta.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
30 changes: 30 additions & 0 deletions supertool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
133 changes: 133 additions & 0 deletions tests/test_cwd_op.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""cwd: op — set the working dir for a whole call.

Cross-repo sessions otherwise need a `cd <repo> && ./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
Loading