mirror of
https://github.com/instructkr/claw-code.git
synced 2026-06-05 17:32:43 -04:00
Compare commits
3 Commits
22fdaeae2c
...
b5bead9028
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5bead9028 | ||
|
|
41678eb097 | ||
|
|
ecd3e4ceb9 |
@@ -6371,13 +6371,13 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
|
||||
431. **DONE — `skills uninstall <name>` resolves locally instead of requiring Anthropic credentials** — fixed 2026-06-03 in `fix: keep skills lifecycle local`. `claw skills uninstall nonexistent-skill-xyz --output-format json` now stays on the local skills lifecycle surface and emits `kind:"skills"`, `action:"uninstall"`, `error_kind:"skill_not_found"`, `skills_dir`, `available_names`, and a hint without provider credentials. `claw skills install` no-arg emits typed `missing_argument` with `argument:"install_source"`; `claw skills install <bogus-name>` emits typed `invalid_install_source` with `source`, `source_kind`, `reason`, and a recovery hint. Installed skill roundtrips remove the installed files through the shared local lifecycle helper. `claw agents create <name>` now scaffolds `.claw/agents/<name>.toml` and lists through the existing TOML agent discovery surface. Regression coverage: `skills_lifecycle_errors_have_typed_local_json_795_431`, `skills_install_uninstall_roundtrip_stays_local_431`, `agents_create_scaffolds_toml_and_lists_locally_431`, local command routing tests, parser discriminant tests, and command help/docs assertions.
|
||||
|
||||
|
||||
432. **`--allowedTools` validator inconsistency: tool name list is half snake_case (`bash`, `read_file`, `write_file`, `edit_file`, `glob_search`, `grep_search`) and half PascalCase (`WebFetch`, `WebSearch`, `TodoWrite`, `Skill`, `Agent`, `Sleep`) with three UPPERCASE entries (`REPL`, `LSP`, `MCP`); accepts undocumented CamelCase aliases (`Read`, `Write`, `Edit`) and silently translates them to snake_case; argument parsing consumes the next positional when value is missing** — dogfooded 2026-05-11 by Jobdori on `fad53e2d` in response to Clawhip pinpoint nudge at `1503283046856655029`. Reproduction: `claw --allowedTools status --output-format json` → `{"error":"unsupported tool in --allowedTools: status (expected one of: bash, read_file, write_file, edit_file, glob_search, grep_search, WebFetch, WebSearch, TodoWrite, Skill, Agent, ToolSearch, NotebookEdit, Sleep, SendUserMessage, Config, EnterPlanMode, ExitPlanMode, StructuredOutput, REPL, PowerShell, AskUserQuestion, TaskCreate, RunTaskPacket, TaskGet, TaskList, TaskStop, TaskUpdate, TaskOutput, WorkerCreate, WorkerGet, WorkerObserve, WorkerResolveTrust, WorkerAwaitReady, WorkerSendPrompt, WorkerRestart, WorkerTerminate, WorkerObserveCompletion, TeamCreate, TeamDelete, CronCreate, CronDelete, CronList, LSP, ListMcpResources, ReadMcpResource, McpAuth, RemoteTrigger, MCP, TestingPermission)","kind":"unknown"}`. The `status` subcommand was consumed as the `--allowedTools` value because the flag parser doesn't distinguish missing-value from end-of-flag-args. The error reveals **the supported tool list mixes naming conventions inconsistently within a single error message**: snake_case (`bash`, `read_file`, `write_file`, `edit_file`, `glob_search`, `grep_search`), PascalCase (`WebFetch`, `WebSearch`, `TodoWrite`, `Skill`, `Agent`, `Sleep`, `Config`, `PowerShell`, `AskUserQuestion`, `TaskCreate`, `WorkerCreate`, `TeamCreate`, `CronCreate`), UPPERCASE (`REPL`, `LSP`, `MCP`), and CamelCase compounds (`McpAuth`, `RemoteTrigger`). **Hidden alias mapping**: `claw --allowedTools Read,Write,Edit status --output-format json` is accepted and returns `allowed_tools.entries:["edit_file","read_file","write_file"]` — proving the validator has an undocumented CamelCase→snake_case alias map (`Read`→`read_file`, `Write`→`write_file`, `Edit`→`edit_file`) that is not surfaced in the error message. Users who copy-paste tool names from Claude Code documentation work, users who copy from the validator error don't. **Sibling missing-value bug:** `claw --allowedTools status` with `status` as a positional subcommand is interpreted as `--allowedTools=status`, swallowing the subcommand. The flag parser must require a value for `--allowedTools` and emit `kind:"missing_argument"` when followed by a recognized subcommand or `--`-prefixed flag instead of silently treating the next arg as a tool name. **Sibling typed-kind bug:** both errors use `kind:"unknown"` instead of typed `kind:"invalid_tool_name"` / `kind:"missing_argument"` — the catch-all keeps appearing (#422/#423/#424/#428/#430/#431/#432). **Required fix shape:** (a) standardize the canonical tool-name registry on one casing convention (snake_case is most CLI-ergonomic) and update both the registry and all CamelCase aliases; (b) document and expose the alias map (`tool_aliases:{Read:"read_file",...}`) in `claw doctor`/`status` and in the validator error; (c) flag parser must require a value for `--allowedTools` and refuse to consume a recognized subcommand or `-`/`--`-prefixed token as the value, emit `kind:"missing_argument"` with `argument:"--allowedTools"`; (d) emit `kind:"invalid_tool_name"` with `tool_name:` and `available:[]` fields instead of `kind:"unknown"`; (e) regression test that `claw --allowedTools <subcommand>` rejects with `missing_argument`, and that the canonical name list in errors uses the same casing as the alias map. **Why this matters:** `--allowedTools` is the primary surface for restricting claw's tool surface area (security-relevant). Inconsistent naming between the validator error and the alias map means users following the error message guidance pick names that work in some places and fail in others. The missing-value bug silently swallows a subcommand, leading to confusing "unsupported tool: status" errors when the user actually wanted to run `claw status`. Cross-references #94/#97/#101/#106/#115/#123 (permission-rule audit), #428 (default permission_mode), #422/#423/#424/#428/#430/#431 (`kind:"unknown"` catch-all). Source: Jobdori live dogfood, `fad53e2d`, 2026-05-11.
|
||||
432. **DONE — `--allowedTools` uses a canonical snake_case registry with typed diagnostics and documented aliases** — fixed 2026-06-04 in `fix: type allowed tools validation`. `GlobalToolRegistry::normalize_allowed_tools` now normalizes built-in, plugin, runtime, and MCP wrapper tool names to canonical snake_case allow-list entries while still accepting documented aliases such as `read`, `Read`, and legacy provider-facing names like `WebFetch`/`MCPTool`. Provider tool definitions and CLI/subagent executors compare against canonical names, so aliases do not break internal dispatch. `claw --allowedTools status --output-format json` now refuses to consume `status` as a value and emits typed `missing_argument` JSON with `argument:"--allowedTools"`; unsupported names emit typed `invalid_tool_name` JSON with `tool_name`, `available`, and `tool_aliases`. `status --output-format json` exposes `allowed_tools.available` and `allowed_tools.aliases`, and help/usage docs describe canonical names plus aliases. Regression coverage: `parses_allowed_tools_flags_with_aliases_and_lists`, `rejects_allowed_tools_followed_by_subcommand_or_flag_432`, `rejects_unknown_allowed_tools`, `allowed_tools_errors_have_typed_json_and_alias_map_432`, `allowed_tools_normalize_to_canonical_snake_case_and_aliases_432`, status JSON alias assertions, MCP wrapper normalization coverage, and classifier coverage for `invalid_tool_name`.
|
||||
|
||||
|
||||
433. **Repeated `--output-format` flag silently takes the last value without warning — `claw --output-format json --output-format text status` produces text output, no signal that the prior `json` was overridden; sibling: `--output-format` value is case-sensitive (`JSON` rejected as `kind:"unknown"`); sibling: no `CLAW_OUTPUT_FORMAT` env var for default format override** — dogfooded 2026-05-11 by Jobdori on `ce39d5c5` in response to Clawhip pinpoint nudge at `1503290592556220488`. Reproduction: `claw --output-format json --output-format text status` returns the text-format `Status\n Model claude-opus-4-6...` table — the first `--output-format json` was silently overridden. No warning, no `format_overridden:true` field, no stderr message. Scripts that compose flag arrays from multiple sources (`flags=("${BASE_FLAGS[@]}" --output-format json)` while `BASE_FLAGS` already contains `--output-format text`) silently get the wrong format. **Three sibling findings in same probe:** (a) **case-sensitivity drift**: `claw --output-format JSON status` returns `{"error":"unsupported value for --output-format: JSON (expected text or json)","kind":"unknown"}` — error message tells user to use lowercase `json` but doesn't accept the uppercase form that users often type from muscle memory. Most CLI flag-value validators (cargo, kubectl, gh) are case-insensitive for enum values or accept both forms with normalization. (b) **`kind:"unknown"` for invalid format value**: same catch-all bucket bug as #422/#423/#424/#428/#430/#431/#432 — should be `kind:"invalid_output_format"` with `value:` and `expected:["text","json"]` fields. (c) **no env-var default for output format**: `CLAW_OUTPUT_FORMAT=json claw status` silently ignored — no env override for the global default, forcing scripts to repeat `--output-format json` on every invocation. Other major CLIs honor `KUBECTL_OUTPUT=`, `AWS_DEFAULT_OUTPUT=`, `GH_NO_PROMPT=` etc. (d) **silently-ignored env vars `CLAW_LOG`/`RUST_LOG`**: no env-based log level control surfaced in `claw doctor` — debug logging requires undocumented `RUST_LOG=` (Rust convention) but `claw --help` doesn't mention either. **Required fix shape:** (a) repeated `--output-format` (or any flag that takes a value, not a count flag) emits a warning to stderr (`warning: --output-format specified multiple times; using last value 'text'`) and adds a `format_source:"flag", format_overridden:[]` field to the JSON envelope; (b) accept case-insensitive enum values for `--output-format` (`JSON`, `Json`, `json` all work), document the canonical lowercase form in `--help`; (c) emit `kind:"invalid_output_format"` (not `kind:"unknown"`) when value is invalid; (d) accept `CLAW_OUTPUT_FORMAT` env var as the default for `--output-format`, with flag-overrides-env precedence documented; (e) document `RUST_LOG` / `CLAW_LOG` in `--help` or doctor output as the log-level env vars; (f) regression test: repeated flag emits stderr warning + JSON metadata field; case-insensitive enum accepts all three casings; env-var default is honored when flag is absent. **Why this matters:** scripts that compose flag arrays from multiple sources (CI envs + per-invocation flags) silently get the wrong output format. Case-sensitive enum values trip up users typing from muscle memory. Missing env-var defaults force per-invocation flag repetition. Cross-references #422/#423/#424/#428/#430/#431/#432 (`kind:"unknown"` catch-all cluster). Source: Jobdori live dogfood, `ce39d5c5`, 2026-05-11.
|
||||
433. **DONE — `--output-format` selection is typed, case-insensitive, env-configurable, and auditable** — fixed 2026-06-04 in `fix: type output format selection`. `CliOutputFormat::parse` now accepts `text`/`json` in any casing, `CLAW_OUTPUT_FORMAT` seeds the default output format when no CLI output-format flag is present, explicit flags override the env default, and repeated flags emit `warning: --output-format specified multiple times; using last value '...'`. `status --output-format json` exposes `format_source`, `format_raw`, and `format_overridden`; invalid values return typed `invalid_output_format` JSON with `value`, `expected:["text","json"]`, and a recovery hint instead of `kind:"unknown"`. Top-level help documents `CLAW_OUTPUT_FORMAT`, `CLAW_LOG`, and `RUST_LOG`, and doctor system JSON surfaces those env values. Regression coverage: `output_format_flags_and_env_have_typed_contract_433` and `classify_error_kind_returns_correct_discriminants`. Verification: `cargo fmt --manifest-path rust/Cargo.toml --all -- --check`, focused output-format and classifier tests, `scripts/roadmap-check-ids.sh`, `git diff --check`, `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli --no-run`, and `cargo build --manifest-path rust/Cargo.toml --workspace --locked`.
|
||||
|
||||
|
||||
434. **POSIX `--` end-of-flags separator is not recognized — `claw -- "-prompt-with-dash"` returns `{"error":"unknown option: --","hint":"Did you mean -V?","kind":"cli_parse"}` instead of treating subsequent args as positional; shorthand prompt mode cannot accept dash-prefixed prompts at all** — dogfooded 2026-05-11 by Jobdori on `0e5f6958` in response to Clawhip pinpoint nudge at `1503298142286905484`. Reproduction: `claw -- "-prompt-with-dash" --output-format json` returns `{"error":"unknown option: --","hint":"Did you mean -V?\nRun \`claw --help\` for usage.","kind":"cli_parse"}`. The POSIX/GNU CLI convention — universally honored by cargo, git, npm, gh, kubectl, grep, ls, find, etc. — is that `--` terminates flag parsing and treats everything after it as positional arguments. claw rejects `--` itself as an unknown flag. **Sibling misleading-suggestion bug (recurring from #429):** the `cli_parse` hint suggests `Did you mean -V?` for `--`. `-V` is the version flag; `--` is the end-of-flags separator. They have no semantic relationship; the auto-complete is matching on prefix-character similarity only. **Sibling shorthand-prompt limitation:** `claw "-just a prompt" --output-format json` returns `{"error":"unknown option: -just a prompt","kind":"cli_parse"}` and `claw "--bogus-flag-like" --output-format json` returns the same. The shorthand non-interactive prompt mode (documented as `claw [--model MODEL] [--output-format text|json] TEXT`) cannot accept any TEXT that starts with `-` or `--`, even when the entire string is shell-quoted as a single token. Users must use the explicit `prompt` verb (`claw prompt "-prompt-with-dash"` works) to escape this, but the explicit verb is documented as alternative not required. **Required fix shape:** (a) accept POSIX `--` as the end-of-flags marker globally — every arg after `--` is positional; (b) shorthand prompt mode must distinguish "this looks like a flag" from "this is a quoted positional that happens to start with `-`" by looking at whether the token matches any registered flag name (`-h`, `-V`, `--help`, `--version`, etc.) — strings that don't match any flag should be treated as prompt text; (c) fix the "Did you mean" hint algorithm to filter by semantic category (don't suggest `-V` for `--`, suggest "use \`--\` to terminate flag parsing" if the user types just `--`); (d) regression test: `claw -- "-foo"` reaches the runtime with prompt=`-foo`; `claw "-not-a-flag"` is treated as shorthand prompt when no registered flag matches; canonical `--` is recognized. **Why this matters:** POSIX `--` is the universal mechanism for passing arbitrary text (filenames starting with `-`, prompts containing flag-like syntax, log lines, etc.) to a CLI. Failing on `--` makes claw fundamentally unergonomic in shell pipelines (`echo "-q for quiet" | xargs claw` fails). The shorthand-prompt limitation forces users to remember the `prompt` verb specifically when their prompt happens to start with `-`. Cross-references #422 (unknown subcommand fallthrough), #423 (stdin not consumed by prompt), #429 ("Did you mean --acp" misleading suggestion). Source: Jobdori live dogfood, `0e5f6958`, 2026-05-11.
|
||||
434. **DONE — POSIX `--` and dash-prefixed shorthand prompts stay on the prompt path** — fixed 2026-06-04 in CI recovery after `41678eb` turned Rust CI red. Global argument parsing now treats `--` as an end-of-flags separator, stops JSON-output pre-scans before the separator, and forwards every following token as positional prompt text. Shorthand prompt mode accepts dash-prefixed text that is not a registered or near-miss CLI flag (`-not-a-flag`, `--bogus-flag-like literal`) while still rejecting real typo-like options such as `--resum` with the `--resume` suggestion. Direct `/status` invocation again remains REPL/session-only so the existing parser contract is restored. Help, USAGE, and rust README document the `claw -- "-prompt-with-dash"` form. Regression coverage: `parses_dash_prefixed_prompt_text_434`, plus rerun CI-red parser tests `parses_bare_prompt_and_json_output_flag` and `parses_direct_agents_mcp_and_skills_slash_commands`. Verification: `cargo fmt --manifest-path rust/Cargo.toml --all -- --check`, focused parser tests, `scripts/roadmap-check-ids.sh`, `git diff --check -- USAGE.md rust/README.md rust/crates/rusty-claude-cli/src/main.rs ROADMAP.md`, docs source-of-truth/release-readiness/unit helper checks, `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli --no-run`, `cargo build --manifest-path rust/Cargo.toml --workspace --locked`, and `cargo clippy --manifest-path rust/Cargo.toml --workspace`. Local `cargo test --manifest-path rust/Cargo.toml --workspace` still hits the pre-existing Darwin-only `runtime::worker_boot::startup_preflight_warns_when_git_metadata_is_not_writable` permission assertion after all CLI parser tests pass; the red GitHub jobs were parser failures on head `41678eb`.
|
||||
|
||||
|
||||
435. **`claw --resume latest` on a fresh workspace exit code is 0 in text mode but 1 in JSON mode (text mode lies about success); sibling: failed `--resume` creates the `.claw/sessions/<fingerprint>/` directory tree as a filesystem side effect of the failure** — dogfooded 2026-05-11 by Jobdori on `e29010ed` in response to Clawhip pinpoint nudge at `1503305692566655096`. Reproduction (fresh empty dir, no `.claw/`, no sessions): `claw --resume latest` (text mode) prints `failed to restore session: no managed sessions found in .claw/sessions/0ead448127a2de44/` and exits **0**. Same invocation with `--output-format json` correctly exits **1** with `kind:"session_load_failed"`. Exit-code parity broken on the same input depending on format flag. **Sibling filesystem-side-effect bug:** after the failed `--resume latest` on a fresh empty workspace, the directory `.claw/sessions/0ead448127a2de44/` (the workspace-fingerprint partition) is created on disk despite the operation failing. The user did not opt into creating workspace metadata — they asked to resume an existing session, the resume failed, and now there's a partition directory hanging around. The fingerprint directory ought to be created lazily on first successful session save, not as a side effect of every resume attempt. **Three sibling findings in the same probe:** (a) **`claw --compact` alone (no other args) drops into the interactive REPL with the ANSI welcome banner** — `--compact` is documented as a modifier that strips tool call details in text mode for piping (`--compact ... useful for piping`), not as a verb that activates the REPL. Running `claw --compact` with no positional should be a no-op or an error explaining the flag needs a subcommand or prompt; entering the REPL is the wrong default. (b) **`claw --compact "hello"` (shorthand prompt) returns `{"error":"unknown subcommand: hello.","hint":"Did you mean help","kind":"unknown"}` — `--compact` disables shorthand prompt mode entirely**, treating the positional as a subcommand instead of as prompt text. Users must use the explicit `prompt` verb (`claw --compact prompt "hello"`) which contradicts the `claw [flags] TEXT` usage line in `--help`. (c) `kind:"unknown"` again for the unknown-subcommand error in --compact path — same catch-all bucket bug appearing for the 11th time across pinpoints. **Required fix shape:** (a) exit code 1 for all `failed_to_restore` / `session_load_failed` text-mode failures; text mode should print to stderr and exit non-zero, not print to stdout and exit 0; (b) defer `.claw/sessions/<fingerprint>/` creation to first successful save; failed `--resume` must not leave filesystem droppings; (c) `claw --compact` alone (no positional, no subcommand, stdin is TTY) should emit `kind:"missing_argument"` with `argument:"prompt or subcommand"` rather than activating the REPL; (d) `--compact` must be transparent to shorthand prompt mode parsing — `claw --compact "hello"` is equivalent to `claw --compact prompt "hello"`, both should reach the prompt path; (e) emit typed `kind:"unknown_subcommand"` not `kind:"unknown"` for fallthrough cases. **Why this matters:** scripts that gate on `$?` after `claw --resume latest` see success on text mode and failure on JSON mode — the same operation, two outcomes. The filesystem side effect pollutes a user's worktree with workspace partitions they didn't ask for, and CI pipelines that snapshot `.claw/` size silently grow on every failed `--resume`. Cross-references #422 (exit-code parity across error envelopes), #423 (`kind:"unknown"` for `missing_argument`), #434 (shorthand prompt limitations). Source: Jobdori live dogfood, `e29010ed`, 2026-05-11.
|
||||
|
||||
13
USAGE.md
13
USAGE.md
@@ -99,6 +99,12 @@ cd rust
|
||||
./target/debug/claw "explain rust/crates/runtime/src/lib.rs"
|
||||
```
|
||||
|
||||
Use the POSIX `--` end-of-flags separator when the shorthand prompt itself begins with `-` or `--`:
|
||||
|
||||
```bash
|
||||
./target/debug/claw -- "-summarize this dash-prefixed text"
|
||||
```
|
||||
|
||||
### JSON output for scripting
|
||||
|
||||
```bash
|
||||
@@ -198,6 +204,10 @@ cd rust
|
||||
|
||||
Global workspace override flags: `--cwd PATH`, `-C PATH`, and `--directory PATH` are accepted before any subcommand. They are validated before command dispatch and take precedence over the process `$PWD`; invalid paths return typed `invalid_cwd` JSON errors in JSON mode.
|
||||
|
||||
`--allowedTools` accepts canonical snake_case tool names (for example `read_file`, `glob_search`, `web_fetch`) plus documented aliases such as `read`, `glob`, `Read`, and `WebFetch`. `claw status --output-format json` exposes `allowed_tools.available` and `allowed_tools.aliases`, and invalid values return typed `invalid_tool_name` JSON with `tool_name`, `available`, and `tool_aliases`. A missing value before a subcommand or another flag returns `missing_argument` with `argument:"--allowedTools"`.
|
||||
|
||||
`--output-format` accepts `text` or `json` case-insensitively and normalizes to the canonical lowercase modes. `CLAW_OUTPUT_FORMAT=json` sets the default output format for scripts, while an explicit `--output-format` flag takes precedence. Repeating the flag emits a stderr warning and JSON status envelopes expose `format_source`, `format_raw`, and `format_overridden` so composed flag arrays are auditable; invalid values return typed `invalid_output_format` JSON with `value` and `expected:["text","json"]`.
|
||||
|
||||
Supported permission modes (default: `workspace-write`):
|
||||
|
||||
- `read-only` allows inspection-only local tools such as file reads, glob/grep searches, local skills, and status-style reporting. It does not allow workspace mutation, network-fetch/search tools, or arbitrary command execution.
|
||||
@@ -442,6 +452,9 @@ The name "codex" appears in the Claw Code ecosystem but it does **not** refer to
|
||||
export HTTPS_PROXY="http://proxy.corp.example:3128"
|
||||
export HTTP_PROXY="http://proxy.corp.example:3128"
|
||||
export NO_PROXY="localhost,127.0.0.1,.corp.example"
|
||||
export CLAW_OUTPUT_FORMAT="json" # default non-interactive output format; flags override it
|
||||
export CLAW_LOG="debug" # claw-specific log level selector surfaced by help/doctor
|
||||
export RUST_LOG="claw=debug" # Rust logging convention surfaced by help/doctor
|
||||
|
||||
cd rust
|
||||
./target/debug/claw prompt "hello via the corporate proxy"
|
||||
|
||||
@@ -122,11 +122,11 @@ claw [OPTIONS] [COMMAND]
|
||||
|
||||
Flags:
|
||||
--model MODEL
|
||||
--output-format text|json
|
||||
--output-format text|json (case-insensitive; CLAW_OUTPUT_FORMAT supplies the default, flags override env)
|
||||
--permission-mode MODE
|
||||
--cwd PATH, -C PATH, --directory PATH
|
||||
--dangerously-skip-permissions, --skip-permissions
|
||||
--allowedTools TOOLS
|
||||
--allowedTools TOOLS canonical snake_case names or aliases; status JSON exposes allowed_tools.available/aliases
|
||||
--resume [SESSION.jsonl|session-id|latest]
|
||||
--version, -V
|
||||
|
||||
@@ -147,6 +147,8 @@ Top-level commands:
|
||||
```
|
||||
|
||||
`claw acp` is a local discoverability surface for editor-first users: it reports the current ACP/Zed status without starting the runtime. As of April 16, 2026, claw-code does **not** ship an ACP/Zed daemon or JSON-RPC entrypoint yet, and `claw acp serve` is only a status alias until the real protocol surface lands. Status queries exit 0 and expose the same machine-readable contract via `--output-format json`; malformed ACP invocations exit 1 with `kind: unsupported_acp_invocation`.
|
||||
`--output-format` accepts `text` or `json` in any casing. `CLAW_OUTPUT_FORMAT=json` selects JSON as the default for non-interactive commands, explicit flags override it, repeated flags warn on stderr, and status JSON exposes `format_source`, `format_raw`, and `format_overridden`. Help and doctor output also surface `CLAW_LOG` / `RUST_LOG` as the logging environment knobs.
|
||||
Shorthand prompt mode honors the POSIX `--` end-of-flags separator, so `claw -- "-prompt-with-dash"` and unknown dash-prefixed non-flag text stay on the prompt path instead of being treated as CLI options.
|
||||
`claw dump-manifests` is self-contained: it emits the Rust resolver inventory for the selected workspace (commands, tools, agents, skills, and bootstrap phases) without requiring an upstream Claude Code TypeScript checkout. Use `--manifests-dir PATH` only to scope resolver discovery to another directory.
|
||||
|
||||
The command surface is moving quickly. For the canonical live help text, run:
|
||||
|
||||
@@ -26,7 +26,7 @@ use std::ops::{Deref, DerefMut};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::{Duration, Instant, UNIX_EPOCH};
|
||||
|
||||
@@ -63,7 +63,8 @@ use runtime::{
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
use tools::{
|
||||
execute_tool, mvp_tool_specs, GlobalToolRegistry, RuntimeToolDefinition, ToolSearchOutput,
|
||||
canonical_allowed_tool_name, execute_tool, mvp_tool_specs, GlobalToolRegistry,
|
||||
RuntimeToolDefinition, ToolSearchOutput,
|
||||
};
|
||||
|
||||
const DEFAULT_MODEL: &str = "anthropic/claude-opus-4-7";
|
||||
@@ -300,6 +301,17 @@ const CLI_OPTION_SUGGESTIONS: &[&str] = &[
|
||||
"-p",
|
||||
];
|
||||
|
||||
fn is_registered_cli_flag_token(value: &str) -> bool {
|
||||
let flag = value.split_once('=').map_or(value, |(flag, _)| flag);
|
||||
CLI_OPTION_SUGGESTIONS.contains(&flag)
|
||||
}
|
||||
|
||||
fn should_reject_unknown_option_like(value: &str) -> bool {
|
||||
is_registered_cli_flag_token(value)
|
||||
|| (value.starts_with("--")
|
||||
&& suggest_closest_term(value, CLI_OPTION_SUGGESTIONS).is_some())
|
||||
}
|
||||
|
||||
type AllowedToolSet = BTreeSet<String>;
|
||||
type RuntimePluginStateBuildOutput = (
|
||||
Option<Arc<Mutex<RuntimeMcpState>>>,
|
||||
@@ -312,10 +324,7 @@ fn main() {
|
||||
// When --output-format json is active, emit errors as JSON so downstream
|
||||
// tools can parse failures the same way they parse successes (ROADMAP #42).
|
||||
let argv: Vec<String> = std::env::args().collect();
|
||||
let json_output = argv
|
||||
.windows(2)
|
||||
.any(|w| w[0] == "--output-format" && w[1] == "json")
|
||||
|| argv.iter().any(|a| a == "--output-format=json");
|
||||
let json_output = raw_args_request_json_output(&argv[1..]);
|
||||
if json_output {
|
||||
// #77/#696: classify error by prefix so downstream claws can route
|
||||
// without regex-scraping prose. Keep the legacy `type`/`kind`
|
||||
@@ -356,6 +365,27 @@ fn main() {
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if kind == "invalid_output_format" {
|
||||
if let Some(object) = error_json.as_object_mut() {
|
||||
object.insert(
|
||||
"value".to_string(),
|
||||
serde_json::json!(invalid_output_format_value(&message)),
|
||||
);
|
||||
object.insert("expected".to_string(), serde_json::json!(["text", "json"]));
|
||||
}
|
||||
} else if kind == "invalid_tool_name" {
|
||||
let (tool_name, available, aliases) = invalid_tool_name_details(&message);
|
||||
if let Some(object) = error_json.as_object_mut() {
|
||||
if let Some(tool_name) = tool_name {
|
||||
object.insert("tool_name".to_string(), serde_json::json!(tool_name));
|
||||
}
|
||||
object.insert("available".to_string(), serde_json::json!(available));
|
||||
object.insert("tool_aliases".to_string(), aliases);
|
||||
}
|
||||
} else if kind == "missing_argument" && message.contains("--allowedTools") {
|
||||
if let Some(object) = error_json.as_object_mut() {
|
||||
object.insert("argument".to_string(), serde_json::json!("--allowedTools"));
|
||||
}
|
||||
}
|
||||
// #819/#820/#823: JSON mode error envelopes must go to stdout so machine
|
||||
// consumers can parse failures from stdout byte 0 (parity with all
|
||||
@@ -428,6 +458,10 @@ fn classify_error_kind(message: &str) -> &'static str {
|
||||
"invalid_cwd"
|
||||
} else if message.starts_with("invalid_output_path:") {
|
||||
"invalid_output_path"
|
||||
} else if message.starts_with("invalid_output_format:") {
|
||||
"invalid_output_format"
|
||||
} else if message.starts_with("invalid_tool_name:") {
|
||||
"invalid_tool_name"
|
||||
} else if message.contains("unrecognized argument") || message.contains("unknown option") {
|
||||
"cli_parse"
|
||||
} else if message.starts_with("missing_flag_value:") {
|
||||
@@ -534,6 +568,51 @@ fn split_error_hint(message: &str) -> (String, Option<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_tool_name_details(message: &str) -> (Option<String>, Vec<String>, Value) {
|
||||
let tool_name = message
|
||||
.strip_prefix("invalid_tool_name: unsupported tool in --allowedTools:")
|
||||
.and_then(|rest| rest.lines().next())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let available = message
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("Available:"))
|
||||
.map(|line| {
|
||||
line.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let aliases = message
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("Aliases:"))
|
||||
.map(|line| {
|
||||
line.split(',')
|
||||
.filter_map(|entry| entry.trim().split_once('='))
|
||||
.map(|(alias, canonical)| {
|
||||
(
|
||||
alias.trim().to_string(),
|
||||
Value::String(canonical.trim().to_string()),
|
||||
)
|
||||
})
|
||||
.collect::<Map<_, _>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
(tool_name, available, Value::Object(aliases))
|
||||
}
|
||||
|
||||
fn invalid_output_format_value(message: &str) -> Option<String> {
|
||||
message
|
||||
.strip_prefix("invalid_output_format: unsupported value for --output-format:")
|
||||
.and_then(|rest| rest.lines().next())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
/// #781: derive a stable fallback hint from a classified error kind when the error
|
||||
/// message itself has no `\n`-delimited hint. Returns `None` for kinds where the
|
||||
/// message is self-explanatory or no canonical remediation exists.
|
||||
@@ -576,6 +655,10 @@ fn fallback_hint_for_error_kind(kind: &str) -> Option<&'static str> {
|
||||
"invalid_install_source" => Some(
|
||||
"Pass a local skill directory containing SKILL.md or a standalone markdown file.",
|
||||
),
|
||||
"invalid_tool_name" => Some(
|
||||
"Use canonical snake_case tool names from `available` or documented aliases from `tool_aliases`.",
|
||||
),
|
||||
"invalid_output_format" => Some("Use --output-format text or --output-format json."),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -898,10 +981,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// #824: suppress config deprecation prose warnings to stderr when JSON
|
||||
// output mode is active. Scan the raw argv before parse_args so the
|
||||
// suppression is in place before any settings file is loaded.
|
||||
let json_mode = args
|
||||
.windows(2)
|
||||
.any(|w| w[0] == "--output-format" && w[1] == "json")
|
||||
|| args.iter().any(|a| a == "--output-format=json");
|
||||
let json_mode = raw_args_request_json_output(&args);
|
||||
if json_mode {
|
||||
runtime::suppress_config_warnings_for_json_mode();
|
||||
}
|
||||
@@ -1202,16 +1282,145 @@ enum CliOutputFormat {
|
||||
Json,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum OutputFormatSource {
|
||||
Default,
|
||||
Env,
|
||||
Flag,
|
||||
}
|
||||
|
||||
impl OutputFormatSource {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Default => "default",
|
||||
Self::Env => "env",
|
||||
Self::Flag => "flag",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct OutputFormatSelection {
|
||||
format: CliOutputFormat,
|
||||
source: OutputFormatSource,
|
||||
raw: Option<String>,
|
||||
overridden: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for OutputFormatSelection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
format: CliOutputFormat::Text,
|
||||
source: OutputFormatSource::Default,
|
||||
raw: None,
|
||||
overridden: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static OUTPUT_FORMAT_SELECTION: OnceLock<Mutex<OutputFormatSelection>> = OnceLock::new();
|
||||
|
||||
fn output_format_selection_cell() -> &'static Mutex<OutputFormatSelection> {
|
||||
OUTPUT_FORMAT_SELECTION.get_or_init(|| Mutex::new(OutputFormatSelection::default()))
|
||||
}
|
||||
|
||||
fn set_current_output_format_selection(selection: &OutputFormatSelection) {
|
||||
*output_format_selection_cell()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = selection.clone();
|
||||
}
|
||||
|
||||
fn current_output_format_selection() -> OutputFormatSelection {
|
||||
output_format_selection_cell()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn cli_has_output_format_flag(args: &[String]) -> bool {
|
||||
args.iter()
|
||||
.take_while(|arg| arg.as_str() != "--")
|
||||
.any(|arg| arg == "--output-format" || arg.starts_with("--output-format="))
|
||||
}
|
||||
|
||||
fn raw_args_request_json_output(args: &[String]) -> bool {
|
||||
let mut values = Vec::new();
|
||||
let mut index = 0;
|
||||
while index < args.len() {
|
||||
let arg = &args[index];
|
||||
if arg == "--" {
|
||||
break;
|
||||
}
|
||||
if arg == "--output-format" {
|
||||
if let Some(value) = args.get(index + 1) {
|
||||
values.push(value.as_str());
|
||||
}
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = arg.strip_prefix("--output-format=") {
|
||||
values.push(value);
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
if let Some(value) = values.last() {
|
||||
let value = value.trim();
|
||||
return !value.eq_ignore_ascii_case("text");
|
||||
}
|
||||
env::var("CLAW_OUTPUT_FORMAT").ok().is_some_and(|value| {
|
||||
let value = value.trim();
|
||||
!value.is_empty() && !value.eq_ignore_ascii_case("text")
|
||||
})
|
||||
}
|
||||
|
||||
fn output_format_selection_from_env() -> Result<OutputFormatSelection, String> {
|
||||
match env::var("CLAW_OUTPUT_FORMAT") {
|
||||
Ok(raw) if !raw.trim().is_empty() => Ok(OutputFormatSelection {
|
||||
format: CliOutputFormat::parse(&raw)?,
|
||||
source: OutputFormatSource::Env,
|
||||
raw: Some(raw),
|
||||
overridden: Vec::new(),
|
||||
}),
|
||||
_ => Ok(OutputFormatSelection::default()),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_output_format_flag(
|
||||
selection: &mut OutputFormatSelection,
|
||||
value: &str,
|
||||
) -> Result<CliOutputFormat, String> {
|
||||
let parsed = CliOutputFormat::parse(value)?;
|
||||
if selection.source == OutputFormatSource::Flag {
|
||||
let previous = selection
|
||||
.raw
|
||||
.clone()
|
||||
.unwrap_or_else(|| selection.format.as_str().to_string());
|
||||
eprintln!("warning: --output-format specified multiple times; using last value '{value}'");
|
||||
selection.overridden.push(previous);
|
||||
}
|
||||
selection.format = parsed;
|
||||
selection.source = OutputFormatSource::Flag;
|
||||
selection.raw = Some(value.to_string());
|
||||
set_current_output_format_selection(selection);
|
||||
Ok(parsed)
|
||||
}
|
||||
impl CliOutputFormat {
|
||||
fn parse(value: &str) -> Result<Self, String> {
|
||||
match value {
|
||||
"text" => Ok(Self::Text),
|
||||
"json" => Ok(Self::Json),
|
||||
match value.trim() {
|
||||
value if value.eq_ignore_ascii_case("text") => Ok(Self::Text),
|
||||
value if value.eq_ignore_ascii_case("json") => Ok(Self::Json),
|
||||
other => Err(format!(
|
||||
"unsupported value for --output-format: {other} (expected text or json)"
|
||||
"invalid_output_format: unsupported value for --output-format: {other}\nExpected: text, json\nHint: Use --output-format text or --output-format json."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Text => "text",
|
||||
Self::Json => "json",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
@@ -1220,7 +1429,13 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
// #148: when user passes --model/--model=, capture the raw input so we
|
||||
// can attribute source: "flag" later. None means no flag was supplied.
|
||||
let mut model_flag_raw: Option<String> = None;
|
||||
let mut output_format = CliOutputFormat::Text;
|
||||
let mut output_format_selection = if cli_has_output_format_flag(args) {
|
||||
OutputFormatSelection::default()
|
||||
} else {
|
||||
output_format_selection_from_env()?
|
||||
};
|
||||
set_current_output_format_selection(&output_format_selection);
|
||||
let mut output_format = output_format_selection.format;
|
||||
let mut permission_mode_override = None;
|
||||
let mut wants_help = false;
|
||||
let mut wants_version = false;
|
||||
@@ -1233,6 +1448,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
// flag parsing. None until `-p <text>` is seen.
|
||||
let mut short_p_prompt: Option<String> = None;
|
||||
let mut rest: Vec<String> = Vec::new();
|
||||
let mut positional_after_separator = false;
|
||||
let mut index = 0;
|
||||
|
||||
while index < args.len() {
|
||||
@@ -1284,7 +1500,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
let value = args
|
||||
.get(index + 1)
|
||||
.ok_or_else(|| "missing_flag_value: missing value for --output-format.\nUsage: --output-format text or --output-format json".to_string())?;
|
||||
output_format = CliOutputFormat::parse(value)?;
|
||||
output_format = apply_output_format_flag(&mut output_format_selection, value)?;
|
||||
index += 2;
|
||||
}
|
||||
"--permission-mode" => {
|
||||
@@ -1295,7 +1511,8 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
index += 2;
|
||||
}
|
||||
flag if flag.starts_with("--output-format=") => {
|
||||
output_format = CliOutputFormat::parse(&flag[16..])?;
|
||||
output_format =
|
||||
apply_output_format_flag(&mut output_format_selection, &flag[16..])?;
|
||||
index += 1;
|
||||
}
|
||||
flag if flag.starts_with("--permission-mode=") => {
|
||||
@@ -1347,6 +1564,11 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
allow_broad_cwd = true;
|
||||
index += 1;
|
||||
}
|
||||
"--" => {
|
||||
positional_after_separator = true;
|
||||
rest.extend(args[index + 1..].iter().cloned());
|
||||
break;
|
||||
}
|
||||
"-p" => {
|
||||
// Claw Code compat: -p "prompt" = one-shot prompt.
|
||||
// #755: consume exactly one token so subsequent flags like
|
||||
@@ -1404,20 +1626,35 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
"--allowedTools" | "--allowed-tools" => {
|
||||
let value = args
|
||||
.get(index + 1)
|
||||
.ok_or_else(|| "missing_flag_value: missing value for --allowedTools.\nUsage: --allowedTools <tool-name> e.g. --allowedTools Bash".to_string())?;
|
||||
.ok_or_else(allowed_tools_missing_error)?;
|
||||
if value.starts_with('-') || is_known_top_level_subcommand(value) {
|
||||
return Err(allowed_tools_missing_error());
|
||||
}
|
||||
allowed_tool_values.push(value.clone());
|
||||
index += 2;
|
||||
}
|
||||
flag if flag.starts_with("--allowedTools=") => {
|
||||
allowed_tool_values.push(flag[15..].to_string());
|
||||
let value = flag[15..].to_string();
|
||||
if value.trim().is_empty() {
|
||||
return Err(allowed_tools_missing_error());
|
||||
}
|
||||
allowed_tool_values.push(value);
|
||||
index += 1;
|
||||
}
|
||||
flag if flag.starts_with("--allowed-tools=") => {
|
||||
allowed_tool_values.push(flag[16..].to_string());
|
||||
let value = flag[16..].to_string();
|
||||
if value.trim().is_empty() {
|
||||
return Err(allowed_tools_missing_error());
|
||||
}
|
||||
allowed_tool_values.push(value);
|
||||
index += 1;
|
||||
}
|
||||
other if rest.is_empty() && other.starts_with('-') => {
|
||||
return Err(format_unknown_option(other))
|
||||
if should_reject_unknown_option_like(other) {
|
||||
return Err(format_unknown_option(other));
|
||||
}
|
||||
rest.push(other.to_string());
|
||||
index += 1;
|
||||
}
|
||||
other => {
|
||||
rest.push(other.to_string());
|
||||
@@ -1492,6 +1729,21 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
});
|
||||
}
|
||||
|
||||
if positional_after_separator && !rest.is_empty() {
|
||||
let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode);
|
||||
return Ok(CliAction::Prompt {
|
||||
prompt: rest.join(" "),
|
||||
model,
|
||||
output_format,
|
||||
allowed_tools,
|
||||
permission_mode,
|
||||
compact,
|
||||
base_commit,
|
||||
reasoning_effort: reasoning_effort.clone(),
|
||||
allow_broad_cwd,
|
||||
});
|
||||
}
|
||||
|
||||
if rest.is_empty() {
|
||||
let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode);
|
||||
// When stdin is not a terminal (pipe/redirect) and no prompt is given on the
|
||||
@@ -1830,9 +2082,7 @@ Usage: claw prompt <text> or echo '<text>' | claw prompt".to_string());
|
||||
allow_broad_cwd,
|
||||
),
|
||||
other => {
|
||||
if looks_like_subcommand_typo(other)
|
||||
&& (rest.len() == 1 || output_format == CliOutputFormat::Json)
|
||||
{
|
||||
if !other.starts_with('-') && looks_like_subcommand_typo(other) && rest.len() == 1 {
|
||||
// #825/#826: emit command_not_found before provider startup for
|
||||
// command-shaped tokens that do not match known subcommands.
|
||||
// Text-mode multi-word prompt shorthand remains available, but
|
||||
@@ -2181,13 +2431,10 @@ fn parse_direct_slash_cli_action(
|
||||
let raw = rest.join(" ");
|
||||
match SlashCommand::parse(&raw) {
|
||||
Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help { output_format }),
|
||||
Ok(Some(SlashCommand::Status)) => Ok(CliAction::Status {
|
||||
model,
|
||||
model_flag_raw: None,
|
||||
permission_mode,
|
||||
output_format,
|
||||
allowed_tools,
|
||||
}),
|
||||
Ok(Some(SlashCommand::Status)) => Err(
|
||||
"interactive_only: /status requires a live session.\nStart `claw` and run it there, or use `claw --resume SESSION.jsonl /status` / `claw --resume latest /status`."
|
||||
.to_string(),
|
||||
),
|
||||
Ok(Some(SlashCommand::Sandbox)) => Ok(CliAction::Sandbox { output_format }),
|
||||
Ok(Some(SlashCommand::Diff)) => Ok(CliAction::Diff { output_format }),
|
||||
Ok(Some(SlashCommand::Version)) => Ok(CliAction::Version { output_format }),
|
||||
@@ -2270,6 +2517,9 @@ fn parse_direct_slash_cli_action(
|
||||
}
|
||||
|
||||
fn format_unknown_option(option: &str) -> String {
|
||||
if option == "--" {
|
||||
return "end_of_flags: `--` terminates flag parsing. Pass literal prompt text after it, for example `claw -- \"-literal prompt\"`.\nRun `claw --help` for usage.".to_string();
|
||||
}
|
||||
let mut message = format!("unknown option: {option}");
|
||||
if let Some(suggestion) = suggest_closest_term(option, CLI_OPTION_SUGGESTIONS) {
|
||||
message.push_str("\nDid you mean ");
|
||||
@@ -2391,6 +2641,41 @@ fn suggest_similar_subcommand(input: &str) -> Option<Vec<String>> {
|
||||
(!suggestions.is_empty()).then_some(suggestions)
|
||||
}
|
||||
|
||||
fn is_known_top_level_subcommand(value: &str) -> bool {
|
||||
matches!(
|
||||
value,
|
||||
"help"
|
||||
| "version"
|
||||
| "status"
|
||||
| "sandbox"
|
||||
| "doctor"
|
||||
| "state"
|
||||
| "dump-manifests"
|
||||
| "bootstrap-plan"
|
||||
| "agents"
|
||||
| "agent"
|
||||
| "mcp"
|
||||
| "skills"
|
||||
| "skill"
|
||||
| "plugins"
|
||||
| "plugin"
|
||||
| "marketplace"
|
||||
| "system-prompt"
|
||||
| "acp"
|
||||
| "init"
|
||||
| "export"
|
||||
| "prompt"
|
||||
| "resume"
|
||||
| "session"
|
||||
| "compact"
|
||||
| "config"
|
||||
| "model"
|
||||
| "models"
|
||||
| "settings"
|
||||
| "diff"
|
||||
)
|
||||
}
|
||||
|
||||
fn common_prefix_len(left: &str, right: &str) -> usize {
|
||||
left.chars()
|
||||
.zip(right.chars())
|
||||
@@ -2549,6 +2834,20 @@ fn normalize_allowed_tools(values: &[String]) -> Result<Option<AllowedToolSet>,
|
||||
current_tool_registry()?.normalize_allowed_tools(values)
|
||||
}
|
||||
|
||||
fn allowed_tools_missing_error() -> String {
|
||||
"missing_argument: --allowedTools requires a tool list before subcommands or flags.\nUsage: --allowedTools <tool-name>[,<tool-name>...] e.g. --allowedTools read,glob".to_string()
|
||||
}
|
||||
|
||||
fn allowed_tool_aliases_json(registry: &GlobalToolRegistry) -> Value {
|
||||
Value::Object(
|
||||
registry
|
||||
.allowed_tool_aliases()
|
||||
.into_iter()
|
||||
.map(|(alias, canonical)| (alias, Value::String(canonical)))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn current_tool_registry() -> Result<GlobalToolRegistry, String> {
|
||||
let cwd = env::current_dir().map_err(|error| error.to_string())?;
|
||||
let loader = ConfigLoader::default_for(&cwd);
|
||||
@@ -2652,6 +2951,7 @@ fn print_model_validation_warning_status(
|
||||
let kind = classify_error_kind(error);
|
||||
let (short_reason, inline_hint) = split_error_hint(error);
|
||||
let hint = inline_hint.or_else(|| fallback_hint_for_error_kind(kind).map(String::from));
|
||||
let format_selection = current_output_format_selection();
|
||||
let mut value = status_json_value(
|
||||
None,
|
||||
usage,
|
||||
@@ -2660,6 +2960,7 @@ fn print_model_validation_warning_status(
|
||||
None,
|
||||
None,
|
||||
allowed_tools,
|
||||
Some(&format_selection),
|
||||
);
|
||||
let object = value
|
||||
.as_object_mut()
|
||||
@@ -3089,6 +3390,7 @@ impl DoctorReport {
|
||||
fn json_value(&self) -> Value {
|
||||
let report = self.render();
|
||||
let (ok_count, warn_count, fail_count) = self.counts();
|
||||
let tool_registry = GlobalToolRegistry::builtin();
|
||||
json!({
|
||||
"kind": "doctor",
|
||||
"action": "doctor",
|
||||
@@ -3107,6 +3409,10 @@ impl DoctorReport {
|
||||
.iter()
|
||||
.map(DiagnosticCheck::json_value)
|
||||
.collect::<Vec<_>>(),
|
||||
"allowed_tools": {
|
||||
"available": tool_registry.canonical_allowed_tool_names(),
|
||||
"aliases": allowed_tool_aliases_json(&tool_registry),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3848,6 +4154,15 @@ fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> D
|
||||
format!("Version {}", VERSION),
|
||||
format!("Build target {}", BUILD_TARGET.unwrap_or("<unknown>")),
|
||||
format!("Git SHA {}", GIT_SHA.unwrap_or("<unknown>")),
|
||||
format!(
|
||||
"Output format env CLAW_OUTPUT_FORMAT={}",
|
||||
env::var("CLAW_OUTPUT_FORMAT").unwrap_or_else(|_| "<unset>".to_string())
|
||||
),
|
||||
format!(
|
||||
"Logging env CLAW_LOG={} RUST_LOG={}",
|
||||
env::var("CLAW_LOG").unwrap_or_else(|_| "<unset>".to_string()),
|
||||
env::var("RUST_LOG").unwrap_or_else(|_| "<unset>".to_string())
|
||||
),
|
||||
];
|
||||
if let Some(model) = default_model {
|
||||
details.push(format!("Default model {model}"));
|
||||
@@ -3878,6 +4193,12 @@ fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> D
|
||||
binary_provenance.json_value(),
|
||||
),
|
||||
("default_model".to_string(), json!(default_model)),
|
||||
(
|
||||
"claw_output_format".to_string(),
|
||||
json!(env::var("CLAW_OUTPUT_FORMAT").ok()),
|
||||
),
|
||||
("claw_log".to_string(), json!(env::var("CLAW_LOG").ok())),
|
||||
("rust_log".to_string(), json!(env::var("RUST_LOG").ok())),
|
||||
]))
|
||||
}
|
||||
|
||||
@@ -5325,6 +5646,7 @@ fn run_resume_command(
|
||||
None, // #148: resumed sessions don't have flag provenance
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)),
|
||||
})
|
||||
}
|
||||
@@ -8138,6 +8460,7 @@ fn print_status_snapshot(
|
||||
CliOutputFormat::Text => return Err(error.into()),
|
||||
},
|
||||
};
|
||||
let format_selection = current_output_format_selection();
|
||||
match output_format {
|
||||
CliOutputFormat::Text => println!(
|
||||
"{}",
|
||||
@@ -8160,6 +8483,7 @@ fn print_status_snapshot(
|
||||
Some(&provenance),
|
||||
Some(&permission_mode),
|
||||
allowed_tools,
|
||||
Some(&format_selection),
|
||||
))?
|
||||
),
|
||||
}
|
||||
@@ -8179,6 +8503,7 @@ fn status_json_value(
|
||||
provenance: Option<&ModelProvenance>,
|
||||
permission_provenance: Option<&PermissionModeProvenance>,
|
||||
allowed_tools: Option<&AllowedToolSet>,
|
||||
format_selection: Option<&OutputFormatSelection>,
|
||||
) -> serde_json::Value {
|
||||
// #143: top-level `status` marker so claws can distinguish
|
||||
// a clean run from a degraded run (config parse failed but other fields
|
||||
@@ -8194,6 +8519,10 @@ fn status_json_value(
|
||||
let model_env_var = provenance.and_then(|p| p.env_var.clone());
|
||||
let permission_mode_source = permission_provenance.map(|p| p.source.as_str());
|
||||
let permission_mode_env_var = permission_provenance.and_then(|p| p.env_var);
|
||||
let tool_registry = GlobalToolRegistry::builtin();
|
||||
let available_tool_names = tool_registry.canonical_allowed_tool_names();
|
||||
let tool_aliases = allowed_tool_aliases_json(&tool_registry);
|
||||
let output_format_selection = format_selection.cloned().unwrap_or_default();
|
||||
// #732: always emit an array (empty when unrestricted) so callers can do
|
||||
// `.allowed_tools.entries | length > 0` without a null-check first.
|
||||
let allowed_tool_entries = allowed_tools
|
||||
@@ -8217,7 +8546,12 @@ fn status_json_value(
|
||||
"source": if allowed_tools.is_some() { "flag" } else { "default" },
|
||||
"restricted": allowed_tools.is_some(),
|
||||
"entries": allowed_tool_entries,
|
||||
"available": available_tool_names,
|
||||
"aliases": tool_aliases,
|
||||
},
|
||||
"format_source": output_format_selection.source.as_str(),
|
||||
"format_raw": output_format_selection.raw,
|
||||
"format_overridden": output_format_selection.overridden,
|
||||
"binary_provenance": context.binary_provenance.json_value(),
|
||||
"usage": {
|
||||
"messages": usage.message_count,
|
||||
@@ -8919,7 +9253,7 @@ fn render_doctor_help_json() -> serde_json::Value {
|
||||
"requires_provider_request": false,
|
||||
"requires_session_resume": false,
|
||||
"mutates_workspace": false,
|
||||
"output_fields": ["kind", "action", "status", "message", "report", "has_failures", "summary", "checks"],
|
||||
"output_fields": ["kind", "action", "status", "message", "report", "has_failures", "summary", "checks", "allowed_tools"],
|
||||
"check_names": ["auth", "config", "install source", "workspace", "boot preflight", "sandbox", "permissions", "system"],
|
||||
"status_values": ["ok", "warn", "fail"],
|
||||
"options": [
|
||||
@@ -12217,7 +12551,7 @@ impl ToolExecutor for CliToolExecutor {
|
||||
if self
|
||||
.allowed_tools
|
||||
.as_ref()
|
||||
.is_some_and(|allowed| !allowed.contains(tool_name))
|
||||
.is_some_and(|allowed| !allowed.contains(&canonical_allowed_tool_name(tool_name)))
|
||||
{
|
||||
return Err(ToolError::new(format!(
|
||||
"tool `{tool_name}` is not enabled by the current --allowedTools setting"
|
||||
@@ -12347,6 +12681,10 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
||||
" claw [--model MODEL] [--output-format text|json] TEXT"
|
||||
)?;
|
||||
writeln!(out, " Shorthand non-interactive prompt mode")?;
|
||||
writeln!(
|
||||
out,
|
||||
" Use `--` before TEXT when the prompt itself starts with '-' or '--'"
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
" claw --resume [SESSION.jsonl|session-id|latest] [/status] [/compact] [...]"
|
||||
@@ -12404,7 +12742,15 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
" --output-format FORMAT Non-interactive output format: text or json"
|
||||
" --output-format FORMAT Non-interactive output format: text or json (case-insensitive)"
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
" CLAW_OUTPUT_FORMAT sets the default; flags override env"
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
" Log env vars: CLAW_LOG or RUST_LOG"
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
@@ -12422,7 +12768,11 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
||||
out,
|
||||
" --dangerously-skip-permissions, --skip-permissions Skip all permission checks"
|
||||
)?;
|
||||
writeln!(out, " --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)")?;
|
||||
writeln!(
|
||||
out,
|
||||
" --allowedTools TOOLS Restrict enabled tools by canonical snake_case name or alias"
|
||||
)?;
|
||||
writeln!(out, " Examples: read, glob, web_fetch, WebFetch; status JSON exposes aliases")?;
|
||||
writeln!(
|
||||
out,
|
||||
" --version, -V Print version and build information locally"
|
||||
@@ -13106,6 +13456,67 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_dash_prefixed_prompt_text_434() {
|
||||
let _guard = env_lock();
|
||||
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
|
||||
|
||||
assert_eq!(
|
||||
parse_args(&["--".to_string(), "-prompt-with-dash".to_string()])
|
||||
.expect("-- should terminate flag parsing"),
|
||||
CliAction::Prompt {
|
||||
prompt: "-prompt-with-dash".to_string(),
|
||||
model: DEFAULT_MODEL.to_string(),
|
||||
output_format: CliOutputFormat::Text,
|
||||
allowed_tools: None,
|
||||
permission_mode: PermissionMode::WorkspaceWrite,
|
||||
compact: false,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
allow_broad_cwd: false,
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parse_args(&["-not-a-flag".to_string()])
|
||||
.expect("unknown dash-prefixed shorthand prompt should parse as prompt text"),
|
||||
CliAction::Prompt {
|
||||
prompt: "-not-a-flag".to_string(),
|
||||
model: DEFAULT_MODEL.to_string(),
|
||||
output_format: CliOutputFormat::Text,
|
||||
allowed_tools: None,
|
||||
permission_mode: PermissionMode::WorkspaceWrite,
|
||||
compact: false,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
allow_broad_cwd: false,
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parse_args(&["--bogus-flag-like".to_string(), "literal".to_string()])
|
||||
.expect("unknown double-dash text should stay eligible for prompt shorthand"),
|
||||
CliAction::Prompt {
|
||||
prompt: "--bogus-flag-like literal".to_string(),
|
||||
model: DEFAULT_MODEL.to_string(),
|
||||
output_format: CliOutputFormat::Text,
|
||||
allowed_tools: None,
|
||||
permission_mode: PermissionMode::WorkspaceWrite,
|
||||
compact: false,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
allow_broad_cwd: false,
|
||||
}
|
||||
);
|
||||
|
||||
assert!(parse_args(&["--".to_string()]).is_ok());
|
||||
|
||||
let error = parse_args(&["--resum".to_string()])
|
||||
.expect_err("nearby real flags should still be rejected as unknown options");
|
||||
assert!(error.contains("unknown option: --resum"));
|
||||
assert!(error.contains("Did you mean --resume?"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_compact_flag_for_prompt_mode() {
|
||||
// given a bare prompt invocation that includes the --compact flag
|
||||
@@ -13346,13 +13757,41 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_allowed_tools_followed_by_subcommand_or_flag_432() {
|
||||
let _env_guard = env_lock();
|
||||
let _cwd_guard = cwd_guard();
|
||||
for args in [
|
||||
vec!["--allowedTools".to_string(), "status".to_string()],
|
||||
vec![
|
||||
"--allowedTools".to_string(),
|
||||
"status".to_string(),
|
||||
"--output-format".to_string(),
|
||||
"json".to_string(),
|
||||
],
|
||||
vec!["--allowedTools".to_string(), "--output-format".to_string()],
|
||||
vec!["--allowedTools=".to_string()],
|
||||
] {
|
||||
let error = parse_args(&args).expect_err("allowedTools missing value should reject");
|
||||
assert!(
|
||||
error.starts_with("missing_argument: --allowedTools requires a tool list"),
|
||||
"unexpected error for {args:?}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_allowed_tools() {
|
||||
let _env_guard = env_lock();
|
||||
let _cwd_guard = cwd_guard();
|
||||
let error = parse_args(&["--allowedTools".to_string(), "teleport".to_string()])
|
||||
.expect_err("tool should be rejected");
|
||||
assert!(error.starts_with("invalid_tool_name:"));
|
||||
assert!(error.contains("unsupported tool in --allowedTools: teleport"));
|
||||
assert!(error.contains("Available: "));
|
||||
assert!(error.contains("web_fetch"));
|
||||
assert!(error.contains("Aliases: "));
|
||||
assert!(error.contains("WebFetch=web_fetch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -14048,6 +14487,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
json.get("status").and_then(|v| v.as_str()),
|
||||
@@ -14097,6 +14537,18 @@ mod tests {
|
||||
Some(false),
|
||||
"default status should expose unrestricted tool state: {json}"
|
||||
);
|
||||
assert_eq!(
|
||||
json.pointer("/allowed_tools/available/0")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("agent"),
|
||||
"status JSON should expose canonical snake_case available tools: {json}"
|
||||
);
|
||||
assert_eq!(
|
||||
json.pointer("/allowed_tools/aliases/WebFetch")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("web_fetch"),
|
||||
"status JSON should expose allowed-tool aliases: {json}"
|
||||
);
|
||||
|
||||
let allowed: super::AllowedToolSet = ["read_file", "grep_search"]
|
||||
.into_iter()
|
||||
@@ -14110,6 +14562,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
Some(&allowed),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
restricted_json
|
||||
@@ -14142,6 +14595,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
clean_json.get("status").and_then(|v| v.as_str()),
|
||||
@@ -14417,6 +14871,16 @@ mod tests {
|
||||
classify_error_kind("invalid_install_source: bogus"),
|
||||
"invalid_install_source"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_error_kind("invalid_tool_name: unsupported tool in --allowedTools: teleport"),
|
||||
"invalid_tool_name"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_error_kind(
|
||||
"invalid_output_format: unsupported value for --output-format: YAML"
|
||||
),
|
||||
"invalid_output_format"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_error_kind(
|
||||
"missing_flag_value: missing value for --model.\nUsage: --model <provider/model>"
|
||||
@@ -15946,6 +16410,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -17206,7 +17671,7 @@ UU conflicted.rs",
|
||||
.expect("mcp tools should be allow-listable")
|
||||
.expect("allow-list should exist");
|
||||
assert!(allowed.contains("mcp__alpha__echo"));
|
||||
assert!(allowed.contains("MCPTool"));
|
||||
assert!(allowed.contains("mcp_tool"));
|
||||
|
||||
let mut executor = CliToolExecutor::new(
|
||||
None,
|
||||
|
||||
@@ -1252,9 +1252,13 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
||||
assert!(summary["ok"].as_u64().is_some());
|
||||
assert!(summary["warnings"].as_u64().is_some());
|
||||
assert!(summary["failures"].as_u64().is_some());
|
||||
assert_eq!(doctor["allowed_tools"]["aliases"]["WebFetch"], "web_fetch");
|
||||
assert!(doctor["allowed_tools"]["available"]
|
||||
.as_array()
|
||||
.is_some_and(|available| available.iter().any(|name| name == "web_fetch")));
|
||||
|
||||
let checks = doctor["checks"].as_array().expect("doctor checks");
|
||||
assert_eq!(checks.len(), 7);
|
||||
assert_eq!(checks.len(), 8);
|
||||
let check_names = checks
|
||||
.iter()
|
||||
.map(|check| {
|
||||
@@ -1279,6 +1283,7 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
||||
"workspace",
|
||||
"boot preflight",
|
||||
"sandbox",
|
||||
"permissions",
|
||||
"system"
|
||||
]
|
||||
);
|
||||
@@ -2340,6 +2345,11 @@ fn assert_non_empty_action(parsed: &Value, args: &[&str]) {
|
||||
fn run_claw(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Output {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
|
||||
command.current_dir(current_dir).args(args);
|
||||
for key in ["CLAW_OUTPUT_FORMAT", "CLAW_LOG", "RUST_LOG"] {
|
||||
if !envs.iter().any(|(env_key, _)| *env_key == key) {
|
||||
command.env_remove(key);
|
||||
}
|
||||
}
|
||||
for (key, value) in envs {
|
||||
command.env(key, value);
|
||||
}
|
||||
@@ -2717,6 +2727,186 @@ fn flag_value_errors_have_error_kind_and_hint_756() {
|
||||
"missing --model hint must be non-empty (#756): {parsed2}"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn output_format_flags_and_env_have_typed_contract_433() {
|
||||
let root = unique_temp_dir("output-format-433");
|
||||
fs::create_dir_all(&root).expect("temp dir");
|
||||
|
||||
let repeated = run_claw(
|
||||
&root,
|
||||
&[
|
||||
"--output-format",
|
||||
"text",
|
||||
"--output-format",
|
||||
"JSON",
|
||||
"status",
|
||||
],
|
||||
&[],
|
||||
);
|
||||
assert!(repeated.status.success());
|
||||
let repeated_stderr = String::from_utf8_lossy(&repeated.stderr);
|
||||
assert!(
|
||||
repeated_stderr.contains("warning: --output-format specified multiple times"),
|
||||
"repeated output-format should warn on stderr: {repeated_stderr}"
|
||||
);
|
||||
let repeated_json = parse_json_stdout(&repeated, "repeated output-format status");
|
||||
assert_eq!(repeated_json["kind"], "status");
|
||||
assert_eq!(repeated_json["format_source"], "flag");
|
||||
assert_eq!(repeated_json["format_raw"], "JSON");
|
||||
assert_eq!(repeated_json["format_overridden"][0], "text");
|
||||
|
||||
let repeated_text = run_claw(
|
||||
&root,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"--output-format",
|
||||
"text",
|
||||
"status",
|
||||
],
|
||||
&[],
|
||||
);
|
||||
assert!(repeated_text.status.success());
|
||||
let repeated_text_stderr = String::from_utf8_lossy(&repeated_text.stderr);
|
||||
assert!(
|
||||
repeated_text_stderr.contains("using last value 'text'"),
|
||||
"json-to-text repeated output-format should warn: {repeated_text_stderr}"
|
||||
);
|
||||
let repeated_text_stdout = String::from_utf8_lossy(&repeated_text.stdout);
|
||||
assert!(
|
||||
repeated_text_stdout.contains("Status"),
|
||||
"last text output-format should produce text status: {repeated_text_stdout}"
|
||||
);
|
||||
|
||||
for value in ["json", "JSON", "Json"] {
|
||||
let parsed = assert_json_command(&root, &["--output-format", value, "status"]);
|
||||
assert_eq!(
|
||||
parsed["kind"], "status",
|
||||
"case {value} should parse as JSON"
|
||||
);
|
||||
assert_eq!(parsed["format_source"], "flag");
|
||||
assert_eq!(parsed["format_raw"], value);
|
||||
}
|
||||
|
||||
let from_env =
|
||||
assert_json_command_with_env(&root, &["status"], &[("CLAW_OUTPUT_FORMAT", "json")]);
|
||||
assert_eq!(from_env["kind"], "status");
|
||||
assert_eq!(from_env["format_source"], "env");
|
||||
assert_eq!(from_env["format_raw"], "json");
|
||||
|
||||
let flag_overrides_env = run_claw(
|
||||
&root,
|
||||
&["--output-format", "json", "status"],
|
||||
&[("CLAW_OUTPUT_FORMAT", "text")],
|
||||
);
|
||||
assert!(flag_overrides_env.status.success());
|
||||
let override_json = parse_json_stdout(&flag_overrides_env, "flag overrides env output-format");
|
||||
assert_eq!(override_json["kind"], "status");
|
||||
assert_eq!(override_json["format_source"], "flag");
|
||||
assert_eq!(override_json["format_raw"], "json");
|
||||
assert_eq!(
|
||||
override_json["format_overridden"].as_array().map(Vec::len),
|
||||
Some(0)
|
||||
);
|
||||
|
||||
let invalid = run_claw(&root, &["--output-format", "YAML", "status"], &[]);
|
||||
assert_eq!(invalid.status.code(), Some(1));
|
||||
assert!(
|
||||
invalid.stderr.is_empty(),
|
||||
"invalid output-format in JSON mode must keep stderr empty: {}",
|
||||
String::from_utf8_lossy(&invalid.stderr)
|
||||
);
|
||||
let invalid_json = parse_json_stdout(&invalid, "invalid output-format JSON error");
|
||||
assert_eq!(invalid_json["error_kind"], "invalid_output_format");
|
||||
assert_eq!(invalid_json["value"], "YAML");
|
||||
assert_eq!(
|
||||
invalid_json["expected"],
|
||||
serde_json::json!(["text", "json"])
|
||||
);
|
||||
assert!(invalid_json["hint"]
|
||||
.as_str()
|
||||
.is_some_and(|hint| hint.contains("--output-format json")));
|
||||
|
||||
let help = assert_json_command(&root, &["--output-format", "json", "help"]);
|
||||
let help_text = help["message"].as_str().expect("help message");
|
||||
assert!(
|
||||
help_text.contains("CLAW_OUTPUT_FORMAT"),
|
||||
"help should document CLAW_OUTPUT_FORMAT: {help_text}"
|
||||
);
|
||||
assert!(
|
||||
help_text.contains("CLAW_LOG"),
|
||||
"help should document CLAW_LOG: {help_text}"
|
||||
);
|
||||
assert!(
|
||||
help_text.contains("RUST_LOG"),
|
||||
"help should document RUST_LOG: {help_text}"
|
||||
);
|
||||
|
||||
let doctor = assert_json_command_with_env(
|
||||
&root,
|
||||
&["doctor"],
|
||||
&[
|
||||
("CLAW_OUTPUT_FORMAT", "json"),
|
||||
("CLAW_LOG", "debug"),
|
||||
("RUST_LOG", "claw=debug"),
|
||||
],
|
||||
);
|
||||
let system_check = doctor["checks"]
|
||||
.as_array()
|
||||
.expect("doctor checks")
|
||||
.iter()
|
||||
.find(|check| check["name"] == "system")
|
||||
.expect("system check");
|
||||
assert_eq!(system_check["claw_output_format"], "json");
|
||||
assert_eq!(system_check["claw_log"], "debug");
|
||||
assert_eq!(system_check["rust_log"], "claw=debug");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_tools_errors_have_typed_json_and_alias_map_432() {
|
||||
let root = unique_temp_dir("allowed-tools-432");
|
||||
fs::create_dir_all(&root).expect("temp dir");
|
||||
|
||||
let missing = run_claw(
|
||||
&root,
|
||||
&["--allowedTools", "status", "--output-format", "json"],
|
||||
&[],
|
||||
);
|
||||
assert_eq!(missing.status.code(), Some(1));
|
||||
assert!(
|
||||
missing.stderr.is_empty(),
|
||||
"JSON missing allowedTools value must keep stderr empty: {}",
|
||||
String::from_utf8_lossy(&missing.stderr)
|
||||
);
|
||||
let missing_json = parse_json_stdout(&missing, "allowedTools subcommand missing value");
|
||||
assert_eq!(missing_json["error_kind"], "missing_argument");
|
||||
assert_eq!(missing_json["argument"], "--allowedTools");
|
||||
assert!(missing_json["hint"]
|
||||
.as_str()
|
||||
.is_some_and(|hint| { hint.contains("--allowedTools") && hint.contains("read,glob") }));
|
||||
|
||||
let invalid = run_claw(
|
||||
&root,
|
||||
&["--output-format", "json", "--allowedTools", "teleport"],
|
||||
&[],
|
||||
);
|
||||
assert_eq!(invalid.status.code(), Some(1));
|
||||
assert!(
|
||||
invalid.stderr.is_empty(),
|
||||
"JSON invalid allowedTools value must keep stderr empty: {}",
|
||||
String::from_utf8_lossy(&invalid.stderr)
|
||||
);
|
||||
let invalid_json = parse_json_stdout(&invalid, "allowedTools invalid tool");
|
||||
assert_eq!(invalid_json["error_kind"], "invalid_tool_name");
|
||||
assert_eq!(invalid_json["tool_name"], "teleport");
|
||||
assert!(invalid_json["available"]
|
||||
.as_array()
|
||||
.is_some_and(|available| available.iter().any(|name| name == "web_fetch")));
|
||||
assert_eq!(invalid_json["tool_aliases"]["WebFetch"], "web_fetch");
|
||||
assert!(invalid_json["hint"]
|
||||
.as_str()
|
||||
.is_some_and(|hint| { hint.contains("canonical snake_case") && hint.contains("aliases") }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_p_flag_swallows_no_flags_755() {
|
||||
|
||||
@@ -201,30 +201,20 @@ impl GlobalToolRegistry {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let builtin_specs = mvp_tool_specs();
|
||||
let canonical_names = builtin_specs
|
||||
.iter()
|
||||
.map(|spec| spec.name.to_string())
|
||||
.chain(
|
||||
self.plugin_tools
|
||||
.iter()
|
||||
.map(|tool| tool.definition().name.clone()),
|
||||
)
|
||||
.chain(self.runtime_tools.iter().map(|tool| tool.name.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
let mut name_map = canonical_names
|
||||
.iter()
|
||||
.map(|name| (normalize_tool_name(name), name.clone()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let actual_names = self.actual_tool_names();
|
||||
let canonical_names = self.canonical_allowed_tool_names();
|
||||
let canonical_name_set = canonical_names.iter().cloned().collect::<BTreeSet<_>>();
|
||||
let mut name_map = BTreeMap::new();
|
||||
for actual in &actual_names {
|
||||
let canonical = canonical_allowed_tool_name(actual);
|
||||
name_map.insert(allowed_tool_lookup_key(actual), canonical.clone());
|
||||
name_map.insert(allowed_tool_lookup_key(&canonical), canonical);
|
||||
}
|
||||
|
||||
for (alias, canonical) in [
|
||||
("read", "read_file"),
|
||||
("write", "write_file"),
|
||||
("edit", "edit_file"),
|
||||
("glob", "glob_search"),
|
||||
("grep", "grep_search"),
|
||||
] {
|
||||
name_map.insert(alias.to_string(), canonical.to_string());
|
||||
for (alias, canonical) in self.allowed_tool_aliases() {
|
||||
if canonical_name_set.contains(&canonical) {
|
||||
name_map.insert(allowed_tool_lookup_key(&alias), canonical);
|
||||
}
|
||||
}
|
||||
|
||||
let mut allowed = BTreeSet::new();
|
||||
@@ -233,11 +223,11 @@ impl GlobalToolRegistry {
|
||||
.split(|ch: char| ch == ',' || ch.is_whitespace())
|
||||
.filter(|token| !token.is_empty())
|
||||
{
|
||||
let normalized = normalize_tool_name(token);
|
||||
let canonical = name_map.get(&normalized).ok_or_else(|| {
|
||||
let canonical = name_map.get(&allowed_tool_lookup_key(token)).ok_or_else(|| {
|
||||
format!(
|
||||
"unsupported tool in --allowedTools: {token} (expected one of: {})",
|
||||
canonical_names.join(", ")
|
||||
"invalid_tool_name: unsupported tool in --allowedTools: {token}\nAvailable: {}\nAliases: {}\nHint: Use canonical snake_case tool names from Available or aliases from Aliases.",
|
||||
canonical_names.join(", "),
|
||||
format_allowed_tool_aliases(&self.allowed_tool_aliases())
|
||||
)
|
||||
})?;
|
||||
allowed.insert(canonical.clone());
|
||||
@@ -258,7 +248,10 @@ impl GlobalToolRegistry {
|
||||
pub fn definitions(&self, allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolDefinition> {
|
||||
let builtin = mvp_tool_specs()
|
||||
.into_iter()
|
||||
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
|
||||
.filter(|spec| {
|
||||
allowed_tools
|
||||
.is_none_or(|allowed| allowed.contains(&canonical_allowed_tool_name(spec.name)))
|
||||
})
|
||||
.map(|spec| ToolDefinition {
|
||||
name: spec.name.to_string(),
|
||||
description: Some(spec.description.to_string()),
|
||||
@@ -267,7 +260,11 @@ impl GlobalToolRegistry {
|
||||
let runtime = self
|
||||
.runtime_tools
|
||||
.iter()
|
||||
.filter(|tool| allowed_tools.is_none_or(|allowed| allowed.contains(tool.name.as_str())))
|
||||
.filter(|tool| {
|
||||
allowed_tools.is_none_or(|allowed| {
|
||||
allowed.contains(&canonical_allowed_tool_name(&tool.name))
|
||||
})
|
||||
})
|
||||
.map(|tool| ToolDefinition {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
@@ -277,8 +274,11 @@ impl GlobalToolRegistry {
|
||||
.plugin_tools
|
||||
.iter()
|
||||
.filter(|tool| {
|
||||
allowed_tools
|
||||
.is_none_or(|allowed| allowed.contains(tool.definition().name.as_str()))
|
||||
allowed_tools.is_none_or(|allowed| {
|
||||
allowed.contains(&canonical_allowed_tool_name(
|
||||
tool.definition().name.as_str(),
|
||||
))
|
||||
})
|
||||
})
|
||||
.map(|tool| ToolDefinition {
|
||||
name: tool.definition().name.clone(),
|
||||
@@ -294,19 +294,29 @@ impl GlobalToolRegistry {
|
||||
) -> Result<Vec<(String, PermissionMode)>, String> {
|
||||
let builtin = mvp_tool_specs()
|
||||
.into_iter()
|
||||
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
|
||||
.filter(|spec| {
|
||||
allowed_tools
|
||||
.is_none_or(|allowed| allowed.contains(&canonical_allowed_tool_name(spec.name)))
|
||||
})
|
||||
.map(|spec| (spec.name.to_string(), spec.required_permission));
|
||||
let runtime = self
|
||||
.runtime_tools
|
||||
.iter()
|
||||
.filter(|tool| allowed_tools.is_none_or(|allowed| allowed.contains(tool.name.as_str())))
|
||||
.filter(|tool| {
|
||||
allowed_tools.is_none_or(|allowed| {
|
||||
allowed.contains(&canonical_allowed_tool_name(&tool.name))
|
||||
})
|
||||
})
|
||||
.map(|tool| (tool.name.clone(), tool.required_permission));
|
||||
let plugin = self
|
||||
.plugin_tools
|
||||
.iter()
|
||||
.filter(|tool| {
|
||||
allowed_tools
|
||||
.is_none_or(|allowed| allowed.contains(tool.definition().name.as_str()))
|
||||
allowed_tools.is_none_or(|allowed| {
|
||||
allowed.contains(&canonical_allowed_tool_name(
|
||||
tool.definition().name.as_str(),
|
||||
))
|
||||
})
|
||||
})
|
||||
.map(|tool| {
|
||||
permission_mode_from_plugin(tool.required_permission())
|
||||
@@ -316,6 +326,52 @@ impl GlobalToolRegistry {
|
||||
Ok(builtin.chain(runtime).chain(plugin).collect())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn actual_tool_names(&self) -> Vec<String> {
|
||||
mvp_tool_specs()
|
||||
.iter()
|
||||
.map(|spec| spec.name.to_string())
|
||||
.chain(
|
||||
self.plugin_tools
|
||||
.iter()
|
||||
.map(|tool| tool.definition().name.clone()),
|
||||
)
|
||||
.chain(self.runtime_tools.iter().map(|tool| tool.name.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn canonical_allowed_tool_names(&self) -> Vec<String> {
|
||||
self.actual_tool_names()
|
||||
.into_iter()
|
||||
.map(|name| canonical_allowed_tool_name(&name))
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn allowed_tool_aliases(&self) -> BTreeMap<String, String> {
|
||||
let mut aliases = BTreeMap::from([
|
||||
("read".to_string(), "read_file".to_string()),
|
||||
("Read".to_string(), "read_file".to_string()),
|
||||
("write".to_string(), "write_file".to_string()),
|
||||
("Write".to_string(), "write_file".to_string()),
|
||||
("edit".to_string(), "edit_file".to_string()),
|
||||
("Edit".to_string(), "edit_file".to_string()),
|
||||
("glob".to_string(), "glob_search".to_string()),
|
||||
("Glob".to_string(), "glob_search".to_string()),
|
||||
("grep".to_string(), "grep_search".to_string()),
|
||||
("Grep".to_string(), "grep_search".to_string()),
|
||||
]);
|
||||
for actual in self.actual_tool_names() {
|
||||
let canonical = canonical_allowed_tool_name(&actual);
|
||||
if actual != canonical {
|
||||
aliases.insert(actual, canonical);
|
||||
}
|
||||
}
|
||||
aliases
|
||||
}
|
||||
#[must_use]
|
||||
pub fn has_runtime_tool(&self, name: &str) -> bool {
|
||||
self.runtime_tools.iter().any(|tool| tool.name == name)
|
||||
@@ -378,8 +434,40 @@ impl GlobalToolRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_tool_name(value: &str) -> String {
|
||||
value.trim().replace('-', "_").to_ascii_lowercase()
|
||||
pub fn canonical_allowed_tool_name(value: &str) -> String {
|
||||
let trimmed = value.trim().replace('-', "_");
|
||||
let mut output = String::new();
|
||||
let chars = trimmed.chars().collect::<Vec<_>>();
|
||||
for (index, ch) in chars.iter().copied().enumerate() {
|
||||
if ch == '_' || ch.is_whitespace() {
|
||||
output.push('_');
|
||||
continue;
|
||||
}
|
||||
let previous = index.checked_sub(1).and_then(|i| chars.get(i)).copied();
|
||||
let next = chars.get(index + 1).copied();
|
||||
if ch.is_ascii_uppercase()
|
||||
&& index > 0
|
||||
&& !output.ends_with('_')
|
||||
&& (previous.is_some_and(|p| p.is_ascii_lowercase() || p.is_ascii_digit())
|
||||
|| next.is_some_and(|n| n.is_ascii_lowercase()))
|
||||
{
|
||||
output.push('_');
|
||||
}
|
||||
output.push(ch.to_ascii_lowercase());
|
||||
}
|
||||
output.trim_matches('_').to_string()
|
||||
}
|
||||
|
||||
fn allowed_tool_lookup_key(value: &str) -> String {
|
||||
canonical_allowed_tool_name(value).replace('_', "")
|
||||
}
|
||||
|
||||
fn format_allowed_tool_aliases(aliases: &BTreeMap<String, String>) -> String {
|
||||
aliases
|
||||
.iter()
|
||||
.map(|(alias, canonical)| format!("{alias}={canonical}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
fn permission_mode_from_plugin(value: &str) -> Result<PermissionMode, String> {
|
||||
@@ -4210,7 +4298,7 @@ fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet<String> {
|
||||
"PowerShell",
|
||||
],
|
||||
};
|
||||
tools.into_iter().map(str::to_string).collect()
|
||||
tools.into_iter().map(canonical_allowed_tool_name).collect()
|
||||
}
|
||||
|
||||
fn agent_permission_policy() -> PermissionPolicy {
|
||||
@@ -5238,7 +5326,10 @@ impl SubagentToolExecutor {
|
||||
|
||||
impl ToolExecutor for SubagentToolExecutor {
|
||||
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
|
||||
if !self.allowed_tools.contains(tool_name) {
|
||||
if !self
|
||||
.allowed_tools
|
||||
.contains(&canonical_allowed_tool_name(tool_name))
|
||||
{
|
||||
return Err(ToolError::new(format!(
|
||||
"tool `{tool_name}` is not enabled for this sub-agent"
|
||||
)));
|
||||
@@ -5253,7 +5344,10 @@ impl ToolExecutor for SubagentToolExecutor {
|
||||
fn tool_specs_for_allowed_tools(allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolSpec> {
|
||||
mvp_tool_specs()
|
||||
.into_iter()
|
||||
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
|
||||
.filter(|spec| {
|
||||
allowed_tools
|
||||
.is_none_or(|allowed| allowed.contains(&canonical_allowed_tool_name(spec.name)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -7603,6 +7697,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_tools_normalize_to_canonical_snake_case_and_aliases_432() {
|
||||
let registry = GlobalToolRegistry::builtin();
|
||||
let allowed = registry
|
||||
.normalize_allowed_tools(&["Read,WebFetch,MCP".to_string()])
|
||||
.expect("aliases and legacy names should normalize")
|
||||
.expect("allow-list should be populated");
|
||||
assert!(allowed.contains("read_file"));
|
||||
assert!(allowed.contains("web_fetch"));
|
||||
assert!(allowed.contains("mcp"));
|
||||
assert!(!allowed.contains("Read"));
|
||||
assert!(!allowed.contains("WebFetch"));
|
||||
|
||||
let canonical = registry.canonical_allowed_tool_names();
|
||||
assert!(canonical.contains(&"web_fetch".to_string()));
|
||||
assert!(canonical.contains(&"todo_write".to_string()));
|
||||
assert!(!canonical.contains(&"WebFetch".to_string()));
|
||||
assert_eq!(
|
||||
registry.allowed_tool_aliases().get("WebFetch"),
|
||||
Some(&"web_fetch".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_tools_extend_registry_definitions_permissions_and_search() {
|
||||
let registry = GlobalToolRegistry::builtin()
|
||||
@@ -8584,7 +8701,7 @@ mod tests {
|
||||
.expect("spawn job should be captured");
|
||||
assert_eq!(captured_job.prompt, "Check tests and outstanding work.");
|
||||
assert!(captured_job.allowed_tools.contains("read_file"));
|
||||
assert!(!captured_job.allowed_tools.contains("Agent"));
|
||||
assert!(!captured_job.allowed_tools.contains("agent"));
|
||||
|
||||
let normalized = execute_tool(
|
||||
"Agent",
|
||||
@@ -9184,7 +9301,7 @@ mod tests {
|
||||
let general = allowed_tools_for_subagent("general-purpose");
|
||||
assert!(general.contains("bash"));
|
||||
assert!(general.contains("write_file"));
|
||||
assert!(!general.contains("Agent"));
|
||||
assert!(!general.contains("agent"));
|
||||
|
||||
let explore = allowed_tools_for_subagent("Explore");
|
||||
assert!(explore.contains("read_file"));
|
||||
@@ -9192,13 +9309,13 @@ mod tests {
|
||||
assert!(!explore.contains("bash"));
|
||||
|
||||
let plan = allowed_tools_for_subagent("Plan");
|
||||
assert!(plan.contains("TodoWrite"));
|
||||
assert!(plan.contains("StructuredOutput"));
|
||||
assert!(!plan.contains("Agent"));
|
||||
assert!(plan.contains("todo_write"));
|
||||
assert!(plan.contains("structured_output"));
|
||||
assert!(!plan.contains("agent"));
|
||||
|
||||
let verification = allowed_tools_for_subagent("Verification");
|
||||
assert!(verification.contains("bash"));
|
||||
assert!(verification.contains("PowerShell"));
|
||||
assert!(verification.contains("power_shell"));
|
||||
assert!(!verification.contains("write_file"));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user