diff --git a/.supertool.json b/.supertool.json index 8aa368a..6ae2d54 100644 --- a/.supertool.json +++ b/.supertool.json @@ -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", diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a3e90e..61f8f3b 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 +- **`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 &&` 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). @@ -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 (): 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..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 — 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: ` 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). diff --git a/docs/input-forms.md b/docs/input-forms.md index 7e29b57..ed2a8a0 100644 --- a/docs/input-forms.md +++ b/docs/input-forms.md @@ -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 diff --git a/docs/operations/meta.md b/docs/operations/meta.md index b9c6635..94dbedd 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. | +| `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 @@ -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 diff --git a/supertool.py b/supertool.py index 525b58c..1fd83ff 100755 --- a/supertool.py +++ b/supertool.py @@ -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", } @@ -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, @@ -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 = "" @@ -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 diff --git a/tests/test_meta_ops.py b/tests/test_meta_ops.py index 8314ace..39696a4 100644 --- a/tests/test_meta_ops.py +++ b/tests/test_meta_ops.py @@ -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 diff --git a/tests/test_stdin_clash_341.py b/tests/test_stdin_clash_341.py new file mode 100644 index 0000000..1ed2699 --- /dev/null +++ b/tests/test_stdin_clash_341.py @@ -0,0 +1,81 @@ +"""Dual `@-` stdin clash — clear error instead of an opaque parse failure. + +sys.stdin is a single stream. Two `@-` (stdin) ops in one call both call +sys.stdin.read(); the first drains it, the second reads empty and dies with a +`@file ... parse error` that names neither the cause nor the fix. main() now +detects the clash in a pre-pass (sibling to the cwd: pre-pass) and errors with a +pointer to the escape hatches. Issue #341. +""" +from __future__ import annotations + +import pytest + +import supertool + + +@pytest.fixture +def stub_dispatch(monkeypatch): + """Record dispatched ops; never touch real stdin or the filesystem.""" + seen: list[str] = [] + monkeypatch.setattr(supertool, "dispatch", lambda a: (seen.append(a), "")[-1]) + monkeypatch.setattr(supertool, "log_call", lambda *a, **k: None) + return seen + + +def test_two_stdin_ops_error_before_dispatch(stub_dispatch, capsys) -> None: + rc = supertool.main(["edit:@-", "edit:@-"]) + assert rc == 1 + assert stub_dispatch == [] # rejected before any op ran + err = capsys.readouterr().err + assert "only one '@-'" in err + assert "got 2" in err + assert "batch:@-" in err # points at the fold-into-one escape hatch + + +def test_stdin_clash_lists_the_offending_ops(stub_dispatch, capsys) -> None: + rc = supertool.main(["paste:@-", "vim:@-", "read:foo"]) + assert rc == 1 + err = capsys.readouterr().err + assert "paste:@-" in err and "vim:@-" in err + assert "read:foo" not in err # only stdin ops named + + +def test_single_stdin_op_passes(stub_dispatch) -> None: + rc = supertool.main(["edit:@-"]) + assert rc == 0 + assert stub_dispatch == ["edit:@-"] # guard did not fire + + +def test_one_stdin_plus_file_payloads_passes(stub_dispatch) -> None: + rc = supertool.main(["edit:@-", "edit:@.max/e1.toml", "read:foo"]) + assert rc == 0 + assert stub_dispatch == ["edit:@-", "edit:@.max/e1.toml", "read:foo"] + + +def test_no_stdin_ops_passes(stub_dispatch) -> None: + rc = supertool.main(["read:foo", "grep:bar:src/"]) + assert rc == 0 + assert stub_dispatch == ["read:foo", "grep:bar:src/"] + + +def test_bare_dash_arg_is_not_treated_as_stdin(stub_dispatch) -> None: + """A literal '@-' with no op prefix isn't an `op:@-` stdin op — no false clash.""" + rc = supertool.main(["edit:@-", "@-"]) + assert rc == 0 # only one real stdin op + assert stub_dispatch == ["edit:@-", "@-"] + + +def test_triple_colon_stdin_form_is_caught(stub_dispatch, capsys) -> None: + """`op:::@-` reads stdin too (parts split on ':::') — must clash like `op:@-`.""" + rc = supertool.main(["edit:::@-", "edit:::@-"]) + assert rc == 1 + assert stub_dispatch == [] + assert "only one '@-'" in capsys.readouterr().err + + +def test_mixed_colon_forms_clash(stub_dispatch, capsys) -> None: + """A single-colon and a triple-colon stdin op together are still two readers.""" + rc = supertool.main(["edit:@-", "paste:::@-"]) + assert rc == 1 + err = capsys.readouterr().err + assert "edit:@-" in err and "paste:::@-" in err