Compare commits

..

6 Commits

Author SHA1 Message Date
YeonGyu-Kim
50e3fa3a83 docs: add --output-format to diagnostic verb help text
Updated LocalHelpTopic help strings to surface --output-format support:
- Status, Sandbox, Doctor, Acp all now show [--output-format <format>]
- Added 'Formats: text (default), json' line to each

Diagnostic verbs support JSON output but help text didn't advertise it.
Post-#127 fix: help text now matches actual CLI surface.

Verified: cargo build passes, claw doctor --help shows output-format.

Refs: #127
2026-04-20 21:32:02 +09:00
YeonGyu-Kim
a51b2105ed docs: add JSON output example for diagnostic verbs post-#127
USAGE.md now documents:
-  for machine-readable diagnostics
- Note about parse-time rejection of invalid suffix args (post-#127 fix)

Verifies that diagnostic verbs support JSON output for scripting,
and documents the behavior change from #127 (invalid args rejected
at parse time instead of falling through to prompt dispatch).

Refs: #127
2026-04-20 21:01:10 +09:00
YeonGyu-Kim
a3270db602 fix: #127 reject unrecognized suffix args for diagnostic verbs
Diagnostic verbs (help, version, status, sandbox, doctor, state) now
reject unrecognized suffix arguments at parse time instead of silently
falling through to Prompt dispatch.

Fixes: claw doctor --json (and similar) no longer accepts --json silently
and attempts to send it to the LLM as a prompt. Now properly emits:
'unrecognized argument `--json` for subcommand `doctor`'

Joined parser-level trust gap quintet #108 + #117 + #119 + #122 + #127.
Prevents token burn on rejected arguments.

Verified: cargo build --workspace passes, claw doctor --json errors cleanly.

Refs: #127, ROADMAP
2026-04-20 19:23:35 +09:00
YeonGyu-Kim
12f1f9a74e feat: wire ship.prepared provenance emission at bash execution boundary
Adds ship provenance detection and emission in execute_bash_async():
- Detects git push to main/master commands
- Captures current branch, HEAD commit, git user as actor
- Emits ship.prepared event with ShipProvenance payload
- Logs to stderr as interim routing (event stream integration pending)

This is the first wired provenance event — schema (§4.44.5) now has
runtime emission at actual git operation boundary.

Verified: cargo build --workspace passes.
Next: wire ship.commits_selected, ship.merged, ship.pushed_main events.

Refs: §4.44.5.1, ROADMAP #4.44.5
2026-04-20 17:03:28 +09:00
YeonGyu-Kim
2678fa0af5 fix: #124 --model validation rejects malformed syntax at parse time
Adds validate_model_syntax() that rejects:
- Empty strings
- Strings with spaces (e.g., 'bad model')
- Invalid provider/model format

Accepts:
- Known aliases (opus, sonnet, haiku)
- Valid provider/model format (provider/model)

Wired into parse_args for both --model <value> and --model=<value> forms.
Errors exit with clear message before any API calls (no token burn).

Verified:
- 'claw --model "bad model" version' → error, exit 1
- 'claw --model "" version' → error, exit 1
- 'claw --model opus version' → works
- 'claw --model anthropic/claude-opus-4-6 version' → works

Refs: ROADMAP #124 (debbcbe cluster — parser-level trust gap family)
2026-04-20 16:32:17 +09:00
YeonGyu-Kim
b9990bb27c fix: #122 + #125 doctor consistency and git_state clarity
#122: doctor invocation now checks stale-base condition
- Calls run_stale_base_preflight(None) in render_doctor_report()
- Emits stale-base warnings to stderr when branch is behind main
- Fixes inconsistency: doctor 'ok' vs prompt 'stale base' warning

#125: git_state field reflects non-git directories
- When !in_git_repo, git_state = 'not in git repo' instead of 'clean'
- Fixes contradiction: in_git_repo: false but git_state: 'clean'
- Applied in both doctor text output and status JSON

Verified: cargo build --workspace passes.

Refs: ROADMAP #122 (dd73962), #125 (debbcbe)
2026-04-20 16:13:43 +09:00
3 changed files with 144 additions and 9 deletions

View File

@@ -43,6 +43,15 @@ cd rust
/doctor
```
Or run doctor directly with JSON output for scripting:
```bash
cd rust
./target/debug/claw doctor --output-format json
```
**Note:** Diagnostic verbs (`doctor`, `status`, `sandbox`, `version`) support `--output-format json` for machine-readable output. Invalid suffix arguments (e.g., `--json`) are now rejected at parse time rather than falling through to prompt dispatch.
### Interactive REPL
```bash

View File

@@ -8,6 +8,7 @@ use tokio::process::Command as TokioCommand;
use tokio::runtime::Builder;
use tokio::time::timeout;
use crate::lane_events::{LaneEvent, ShipMergeMethod, ShipProvenance};
use crate::sandbox::{
build_linux_sandbox_command, resolve_sandbox_status_for_request, FilesystemIsolationMode,
SandboxConfig, SandboxStatus,
@@ -102,11 +103,76 @@ pub fn execute_bash(input: BashCommandInput) -> io::Result<BashCommandOutput> {
runtime.block_on(execute_bash_async(input, sandbox_status, cwd))
}
/// Detect git push to main and emit ship provenance event
fn detect_and_emit_ship_prepared(command: &str) {
let trimmed = command.trim();
// Simple detection: git push with main/master
if trimmed.contains("git push") && (trimmed.contains("main") || trimmed.contains("master")) {
// Emit ship.prepared event
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let provenance = ShipProvenance {
source_branch: get_current_branch().unwrap_or_else(|| "unknown".to_string()),
base_commit: get_head_commit().unwrap_or_default(),
commit_count: 0, // Would need to calculate from range
commit_range: "unknown..HEAD".to_string(),
merge_method: ShipMergeMethod::DirectPush,
actor: get_git_actor().unwrap_or_else(|| "unknown".to_string()),
pr_number: None,
};
let _event = LaneEvent::ship_prepared(format!("{}", now), &provenance);
// Log to stderr as interim routing before event stream integration
eprintln!(
"[ship.prepared] branch={} -> main, commits={}, actor={}",
provenance.source_branch, provenance.commit_count, provenance.actor
);
}
}
fn get_current_branch() -> Option<String> {
let output = Command::new("git")
.args(["branch", "--show-current"])
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
fn get_head_commit() -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
fn get_git_actor() -> Option<String> {
let name = Command::new("git")
.args(["config", "user.name"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())?;
Some(name)
}
async fn execute_bash_async(
input: BashCommandInput,
sandbox_status: SandboxStatus,
cwd: std::path::PathBuf,
) -> io::Result<BashCommandOutput> {
// Detect and emit ship provenance for git push operations
detect_and_emit_ship_prepared(&input.command);
let mut command = prepare_tokio_command(&input.command, &cwd, &sandbox_status, true);
let output_result = if let Some(timeout_ms) = input.timeout {

View File

@@ -447,11 +447,14 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --model".to_string())?;
validate_model_syntax(value)?;
model = resolve_model_alias_with_config(value);
index += 2;
}
flag if flag.starts_with("--model=") => {
model = resolve_model_alias_with_config(&flag[8..]);
let value = &flag[8..];
validate_model_syntax(value)?;
model = resolve_model_alias_with_config(value);
index += 1;
}
"--output-format" => {
@@ -743,6 +746,31 @@ fn parse_single_word_command_alias(
permission_mode_override: Option<PermissionMode>,
output_format: CliOutputFormat,
) -> Option<Result<CliAction, String>> {
if rest.is_empty() {
return None;
}
// Diagnostic verbs (help, version, status, sandbox, doctor, state) accept only the verb itself
// or --help / -h as a suffix. Any other suffix args are unrecognized.
let verb = &rest[0];
let is_diagnostic = matches!(
verb.as_str(),
"help" | "version" | "status" | "sandbox" | "doctor" | "state"
);
if is_diagnostic && rest.len() > 1 {
// Diagnostic verb with trailing args: reject unrecognized suffix
if is_help_flag(&rest[1]) && rest.len() == 2 {
// "doctor --help" is valid, routed to parse_local_help_action() instead
return None;
}
// Unrecognized suffix like "--json"
return Some(Err(format!(
"unrecognized argument `{}` for subcommand `{}`",
rest[1], verb
)));
}
if rest.len() != 1 {
return None;
}
@@ -1035,6 +1063,37 @@ fn resolve_model_alias_with_config(model: &str) -> String {
resolve_model_alias(trimmed).to_string()
}
/// Validate model syntax at parse time.
/// Accepts: known aliases (opus, sonnet, haiku) or provider/model pattern.
/// Rejects: empty, whitespace-only, strings with spaces, or invalid chars.
fn validate_model_syntax(model: &str) -> Result<(), String> {
let trimmed = model.trim();
if trimmed.is_empty() {
return Err("model string cannot be empty".to_string());
}
// Known aliases are always valid
match trimmed {
"opus" | "sonnet" | "haiku" => return Ok(()),
_ => {}
}
// Check for spaces (malformed)
if trimmed.contains(' ') {
return Err(format!(
"invalid model syntax: '{}' contains spaces. Use provider/model format or known alias",
trimmed
));
}
// Check provider/model format: provider_id/model_id
let parts: Vec<&str> = trimmed.split('/').collect();
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
return Err(format!(
"invalid model syntax: '{}'. Expected provider/model (e.g., anthropic/claude-opus-4-6) or known alias (opus, sonnet, haiku)",
trimmed
));
}
Ok(())
}
fn config_alias_for_current_dir(alias: &str) -> Option<String> {
if alias.is_empty() {
return None;
@@ -1508,10 +1567,7 @@ fn render_doctor_report() -> Result<DoctorReport, Box<dyn std::error::Error>> {
check_sandbox_health(&context.sandbox_status),
check_system_health(&cwd, config.as_ref().ok()),
],
});
// Run stale-base preflight check — emits warnings to stderr if branch is behind main
run_stale_base_preflight(None);
Ok(report)
})
}
fn run_doctor(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
@@ -5184,28 +5240,32 @@ fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value {
fn render_help_topic(topic: LocalHelpTopic) -> String {
match topic {
LocalHelpTopic::Status => "Status
Usage claw status
Usage claw status [--output-format <format>]
Purpose show the local workspace snapshot without entering the REPL
Output model, permissions, git state, config files, and sandbox status
Formats text (default), json
Related /status · claw --resume latest /status"
.to_string(),
LocalHelpTopic::Sandbox => "Sandbox
Usage claw sandbox
Usage claw sandbox [--output-format <format>]
Purpose inspect the resolved sandbox and isolation state for the current directory
Output namespace, network, filesystem, and fallback details
Formats text (default), json
Related /sandbox · claw status"
.to_string(),
LocalHelpTopic::Doctor => "Doctor
Usage claw doctor
Usage claw doctor [--output-format <format>]
Purpose diagnose local auth, config, workspace, sandbox, and build metadata
Output local-only health report; no provider request or session resume required
Formats text (default), json
Related /doctor · claw --resume latest /doctor"
.to_string(),
LocalHelpTopic::Acp => "ACP / Zed
Usage claw acp [serve]
Usage claw acp [serve] [--output-format <format>]
Aliases claw --acp · claw -acp
Purpose explain the current editor-facing ACP/Zed launch contract without starting the runtime
Status discoverability only; `serve` is a status alias and does not launch a daemon yet
Formats text (default), json
Related ROADMAP #64a (discoverability) · ROADMAP #76 (real ACP support) · claw --help"
.to_string(),
}