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"
},
"help": {
"syntax": "help:OP",
"description": "Print the full reference for a single op (syntax, description, example) from .supertool.json — the same metadata 'ops' lists, but scoped to one op and never compacted, so payload shapes like vim's are readable without grepping source.",
"example": "help:vim",
"hint": true
},
"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",
Expand Down
2 changes: 2 additions & 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

- **`help:OP` op prints the full reference for a single op** — `help:vim` emits that op's syntax, uncompacted description, and example, read straight from `.supertool.json` (builtin-ops → custom ops → aliases). The motivation: op hints and the `vim` example line told the reader to "see `./supertool help vim`", but no `help` op existed — `./supertool help vim` returned `unknown operation: help`, a dead front door, so discovering a non-obvious payload shape (vim's macro grammar, the `@file` field names) meant grepping source. `help` reuses the existing config as the single source of truth — the same metadata `ops` lists, but scoped to one op and never compacted, so a long description (vim's is ~1.5KB) prints in full instead of being dropped by `ops-compact`'s self-explanatory filter. An unknown op errors with a pointer to `ops`; a real built-in with no config entry says "has no documented help" rather than "unknown operation", distinguishing the two. Read-only and parallel-safe. Use `help:OP` when you know the op but not its payload; use `ops` when you don't yet know the op. Closes [#341](https://github.com/Digital-Process-Tools/claude-supertool/issues/341).
- **`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).
Expand All @@ -20,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Two `@-` (stdin) ops in one call now error clearly instead of an opaque parse failure** — `./supertool 'edit:@-' 'edit:@-'` made both ops call `sys.stdin.read()`; the first drained the single stream, so the second read empty and died with `@file ... parse error (<stdin>): Expecting value: line 1 column 1`, naming neither the real cause (the stream was already consumed) nor the fix. main() now detects more than one `op:@-` in a pre-pass — sibling to the existing `cwd:` pre-pass, before any dispatch — and exits with `stdin: only one '@-' op is allowed per call (got N: ...)`, listing the offending ops and pointing at the two escape hatches: give all but one a real `@file` path, or fold them into a single `batch:@-` ops array. A lone `@-` (the common case) is unchanged; a bare `@-` arg with no `op:` prefix isn't counted. Closes [#341](https://github.com/Digital-Process-Tools/claude-supertool/issues/341).
- **An MCP server's own timeout no longer surfaces as a phantom `+1` diagnostic** — after an edit, `lsp-diag` reported a `0 → 1` count where the single "diag" was actually `orchestrator timeout after 3s`, an infrastructure condition, not a code finding (phpstan had already covered the same file clean). The cause: cclsp swallows its *internal* LSP-orchestrator timeout and returns it as normal MCP text content with the `isError` flag unset — so it flowed through the shared dispatch as a real result and the lsp-diag adapter counted it as an advisory, reading like the edit introduced a regression. Detection now lives in the shared `_mcp_call_or_message` (so every MCP-backed op — `diag`/`hover`/`rename` — benefits, not just lsp-diag) with two signals: the structural MCP `isError` flag (spec-standard, any server) and a configurable per-server `infra_patterns` substring list (default `["orchestrator timeout", "timed out after"]`) for servers that report timeouts as plain text. A matched result is returned prefixed `op: …` — the same shape as supertool's own MCP errors — so the adapter's existing `op:`-prefix guard drops it instead of counting it. No count delta, no false regression, and the timeout value itself stays per-server configurable via `mcp.<name>.timeout`. Closes [#346](https://github.com/Digital-Process-Tools/claude-supertool/issues/346).
- **`rector-mcp` engine-glitch suppression is config-driven and now catches `toMutatingScope() on null`** — the warm rector daemon intermittently trips a PHP fatal inside rector's own engine (`Call to a member function toMutatingScope() on null`), a non-deterministic glitch a cold `rector` CLI does not reproduce. Like the June `System error: ClassReflection` case it surfaced as a false red *and* — because the validator cache keys on file content — froze into the cache and replayed on every later run until the file changed (the 2100-poisoned-entries failure mode, new signature). The fix moves glitch knowledge out of the core and into the adapter: the core had been string-matching `System error:` in `_validator_result_is_cacheable`, violating `SCHEMA.md`'s "validator core never parses tool-specific output". That branch is removed — the core cache filter is now fully generic, keying only off non-deterministic error *codes*. Tradeoff: the core no longer has a message-level backstop, so a brand-new, uncatalogued glitch signature can still reach the cache as a `rector.error` until it is added to the config list — the same failure mode, now bounded to *unknown* signatures and fixed by a one-line config edit rather than a code change. Signatures live as a config prop, `validators.rector.engine_glitches` in `.supertool.json` (a JSON list of case-sensitive substrings the adapter reads straight from `.supertool.json` — no env, the same file `daemon.py` already reads from cwd), defaulting to `["System error:", "toMutatingScope() on null"]` when absent. The adapter drops a matching error at the source, so a glitch never surfaces or reaches the cache; a new signature is a one-line config edit, not a code change. Part of [#345](https://github.com/Digital-Process-Tools/claude-supertool/issues/345).
- **`git-diff:PATH` no longer reports a missing/untracked path as "No changes."** — a path absent or untracked in the current repo produced an empty diff that printed `No changes.`, indistinguishable from a clean tracked file. Combined with a stale CWD (e.g. a `cd` into another repo) this is a silent false-negative: the op reads as "nothing changed" when it actually looked in the wrong place. Path mode now checks `git ls-files` first — a missing path warns `not found under <cwd> — wrong CWD?` and exits 1, an untracked on-disk path warns `untracked (not in git)`, and only a genuinely clean tracked file still says `No changes.`. Every mode also stamps a `Repo: <toplevel>` header so a wrong-repo invocation is visible at a glance (the trigger: `git-diff:.supertool.json` silently returned "No changes" while anchored to the wrong clone). Closes [#336](https://github.com/Digital-Process-Tools/claude-supertool/issues/336).
Expand Down
2 changes: 2 additions & 0 deletions docs/input-forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ Mutating ops (`edit`, `replace`, `replace_lines`, `paste`, `vim`) accept a paylo
./supertool 'paste:@-' < my-paste.toml
```

**Only one `@-` per call.** stdin is a single stream: two `@-` ops both read it — the first drains it, the second reads empty and fails. supertool rejects this up front (`only one '@-' op is allowed per call`) rather than letting it surface as an opaque parse error. For several payload edits in one call, give all but one a real `@file` path, or fold them into a single `batch:@-` ops array.

The payload holds the fields that would otherwise go after `:::`. Format auto-detected from the first non-whitespace character — `{` or `[` → **JSON**, anything else → **TOML**.

### JSON — concise, machine-friendly
Expand Down
9 changes: 9 additions & 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. |
| `help` | `help:OP` | Print the full reference for a single op — syntax, full (uncompacted) description, and example — read from `.supertool.json`. Discovers an op's payload shape (e.g. `vim`'s macro grammar) without grepping source. Errors with a pointer to `ops` for an unknown or undocumented op. |
| `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 All @@ -28,6 +29,14 @@ Check installed version:
./supertool 'version'
```

Discover one op's full payload shape — the front door for ops with non-obvious input (e.g. `vim`):

```bash
./supertool 'help:vim'
```

`help:OP` prints that op's uncompacted description from `.supertool.json`. Use it when an op's signature isn't enough; use `ops` when you don't yet know which op you want.

Compact variant used by the session-start hook to stay under Claude Code's ~7KB hook output cap:

```bash
Expand Down
61 changes: 60 additions & 1 deletion supertool.py
Original file line number Diff line number Diff line change
Expand Up @@ -725,7 +725,7 @@ def _rtk_run(args: List[str], timeout: int = 30) -> str | None:
"read", "grep", "glob", "ls", "head", "tail", "wc", "stat",
"map", "tree", "around", "around_line", "between", "diff", "blame",
"version", "validate", "validate_staged", "format_staged", "workspace",
"resolve", "diag", "hover",
"resolve", "diag", "hover", "help",
}


Expand Down Expand Up @@ -8386,6 +8386,46 @@ def op_version() -> str:
return f"supertool {VERSION}\n"


def op_help(op_name: str) -> str:
"""Output the full reference for a single op from .supertool.json.

Same metadata `ops` lists, but scoped to one op and never compacted — so
payload shapes (e.g. vim's macro grammar) are readable without grepping
source. Looks through builtin-ops, then custom ops, then aliases.
"""
if not op_name:
return ("ERROR: help needs an op name — help:OP (e.g. help:vim).\n"
"Run 'ops' for the full list.\n")
config = _load_config()
for section in ("builtin-ops", "ops", "aliases"):
entry = config.get(section, {})
if not isinstance(entry, dict) or op_name not in entry:
continue
info = entry[op_name]
if not isinstance(info, dict):
continue
out: List[str] = [str(info.get("syntax", op_name))]
desc = info.get("description", "")
if desc:
out.append("")
out.append(str(desc))
ops_list = info.get("ops", [])
if ops_list:
out.append("")
out.append("Ops: " + " ".join(str(o) for o in ops_list))
example = info.get("example", "")
if example:
out.append("")
out.append(f"Example: {example}")
return "\n".join(out) + "\n"
if op_name in _BUILTIN_OPS:
return (f"ERROR: op '{op_name}' has no documented help in "
f".supertool.json. It's a valid built-in — run 'ops' for the "
f"list, or see docs/operations.\n")
return (f"ERROR: no help for op: {op_name}\n"
f"Run 'ops' for the full list of operations.\n")


# Threshold above which compact ops output gets a "truncation likely" warning.
# Claude Code's hook-stdout cap appears to be ~7KB; anything over that gets
# saved to disk and only a ~2KB preview is injected into the model's context,
Expand Down Expand Up @@ -11501,6 +11541,8 @@ def _dispatch_impl(arg: str) -> str:
elif op == "workspace":
ws_path = parts[1] if len(parts) > 1 else ""
body = op_workspace(ws_path)
elif op == "help":
body = op_help(parts[1] if len(parts) > 1 else "")
elif op in ("introduction", "output-format", "ops", "ops-compact", "version"):
# Meta-ops use markdown headers instead of --- header ---
header = ""
Expand Down Expand Up @@ -12194,6 +12236,23 @@ def main(argv: List[str]) -> int:
if not argv:
return 0

# At most one '@-' (stdin) op per call. sys.stdin is a single stream:
# the first op's sys.stdin.read() drains it, so a second '@-' reads empty
# and dies with an opaque '@file ... parse error' that names neither the
# cause nor the fix. Detect the clash up front and point at the escape
# hatches (per-op @file, or one batch:@- ops array). Issue #341.
stdin_ops = [a for a in argv
if ":" in a and a.split(":", 1)[1].lstrip(":") == "@-"]
if len(stdin_ops) > 1:
sys.stderr.write(
"stdin: only one '@-' op is allowed per call "
f"(got {len(stdin_ops)}: {', '.join(stdin_ops)}). sys.stdin is a "
"single stream — the second '@-' reads empty and fails. Give the "
"others a file payload (e.g. edit:@.max/e1.toml), or fold them "
"into one 'batch:@-' ops array.\n"
)
return 1

# Normal batched-ops mode
total_out_bytes = 0
any_failure = False
Expand Down
82 changes: 82 additions & 0 deletions tests/test_meta_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,3 +380,85 @@ def test_hook_output_cap_constant_exists() -> None:
"""The cap constant is a module-level int — tunable without code changes."""
assert isinstance(supertool._HOOK_OUTPUT_CAP_BYTES, int)
assert supertool._HOOK_OUTPUT_CAP_BYTES > 0


# ---------------------------------------------------------------------------
# op_help — per-op reference, scoped and uncompacted (issue #341)
# ---------------------------------------------------------------------------

def test_help_renders_full_op_reference(tmp_path: Path, monkeypatch) -> None:
"""help:OP prints syntax, full description, and example for a builtin op."""
_set_config(monkeypatch, tmp_path, {
"builtin-ops": {
"vim": {
"syntax": "vim:::PATH:::SCRIPT",
"description": "Cursor-based multi-action edit. /PAT then iTEXT\\\\e.",
"example": "vim:::f.md:::/Foo\\\\eo1. Bar",
}
}
})
out = supertool.op_help("vim")
assert "vim:::PATH:::SCRIPT" in out
assert "Cursor-based multi-action edit" in out
assert "Example: vim:::f.md:::/Foo" in out


def test_help_finds_custom_op_and_alias(tmp_path: Path, monkeypatch) -> None:
"""help:OP looks through custom ops and aliases, not just builtins."""
_set_config(monkeypatch, tmp_path, {
"ops": {"phpstan": {"syntax": "phpstan:PATH", "description": "Static analysis"}},
"aliases": {"survey": {"syntax": "survey:PATH", "description": "Survey pack",
"ops": ["read:{path}", "map:{path}"]}},
})
custom = supertool.op_help("phpstan")
assert "phpstan:PATH" in custom
assert "Static analysis" in custom
alias = supertool.op_help("survey")
assert "survey:PATH" in alias
assert "Survey pack" in alias
assert "Ops:" in alias


def test_help_missing_arg_errors_with_pointer(tmp_path: Path, monkeypatch) -> None:
"""help with no op name errors clearly and points at 'ops'."""
_set_config(monkeypatch, tmp_path, {"builtin-ops": {}})
out = supertool.op_help("")
assert out.startswith("ERROR: help needs an op name")
assert "ops" in out


def test_help_unknown_op_errors(tmp_path: Path, monkeypatch) -> None:
"""help:UNKNOWN errors with a pointer to the full list."""
_set_config(monkeypatch, tmp_path, {"builtin-ops": {}})
out = supertool.op_help("definitely_not_an_op")
assert "ERROR: no help for op: definitely_not_an_op" in out
assert "ops" in out


def test_help_known_builtin_without_docs(tmp_path: Path, monkeypatch) -> None:
"""A real builtin with no config entry says so rather than 'unknown op'."""
_set_config(monkeypatch, tmp_path, {"builtin-ops": {}})
op = next(iter(supertool._BUILTIN_OPS))
out = supertool.op_help(op)
assert "has no documented help" in out
assert op in out


def test_help_routes_via_dispatch(tmp_path: Path, monkeypatch) -> None:
"""`./supertool 'help:vim'` routes through dispatch() with a header."""
_set_config(monkeypatch, tmp_path, {
"builtin-ops": {
"vim": {"syntax": "vim:::PATH:::SCRIPT", "description": "Macro edit",
"example": "vim:::f:::x"}
}
})
out = supertool.dispatch("help:vim")
assert "--- help:vim ---" in out
assert "Macro edit" in out
err = supertool.dispatch("help:nope")
assert "no help for op: nope" in err


def test_help_in_parallel_safe_set() -> None:
"""help is read-only — must be batchable in parallel with other reads."""
assert "help" in supertool._PARALLEL_SAFE_OPS
Loading
Loading