Skip to content

Commit 1368c50

Browse files
Merge remote-tracking branch 'upstream/main' into dev
2 parents a15863e + a51b210 commit 1368c50

3 files changed

Lines changed: 135 additions & 1 deletion

File tree

USAGE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,15 @@ cd rust
4343
/doctor
4444
```
4545

46+
Or run doctor directly with JSON output for scripting:
47+
48+
```bash
49+
cd rust
50+
./target/debug/claw doctor --output-format json
51+
```
52+
53+
**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.
54+
4655
### Interactive REPL
4756

4857
```bash

rust/crates/runtime/src/bash.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use tokio::process::Command as TokioCommand;
88
use tokio::runtime::Builder;
99
use tokio::time::timeout;
1010

11+
use crate::lane_events::{LaneEvent, ShipMergeMethod, ShipProvenance};
1112
use crate::sandbox::{
1213
build_linux_sandbox_command, resolve_sandbox_status_for_request, FilesystemIsolationMode,
1314
SandboxConfig, SandboxStatus,
@@ -102,11 +103,76 @@ pub fn execute_bash(input: BashCommandInput) -> io::Result<BashCommandOutput> {
102103
runtime.block_on(execute_bash_async(input, sandbox_status, cwd))
103104
}
104105

106+
/// Detect git push to main and emit ship provenance event
107+
fn detect_and_emit_ship_prepared(command: &str) {
108+
let trimmed = command.trim();
109+
// Simple detection: git push with main/master
110+
if trimmed.contains("git push") && (trimmed.contains("main") || trimmed.contains("master")) {
111+
// Emit ship.prepared event
112+
let now = std::time::SystemTime::now()
113+
.duration_since(std::time::UNIX_EPOCH)
114+
.unwrap_or_default()
115+
.as_millis();
116+
let provenance = ShipProvenance {
117+
source_branch: get_current_branch().unwrap_or_else(|| "unknown".to_string()),
118+
base_commit: get_head_commit().unwrap_or_default(),
119+
commit_count: 0, // Would need to calculate from range
120+
commit_range: "unknown..HEAD".to_string(),
121+
merge_method: ShipMergeMethod::DirectPush,
122+
actor: get_git_actor().unwrap_or_else(|| "unknown".to_string()),
123+
pr_number: None,
124+
};
125+
let _event = LaneEvent::ship_prepared(format!("{}", now), &provenance);
126+
// Log to stderr as interim routing before event stream integration
127+
eprintln!(
128+
"[ship.prepared] branch={} -> main, commits={}, actor={}",
129+
provenance.source_branch, provenance.commit_count, provenance.actor
130+
);
131+
}
132+
}
133+
134+
fn get_current_branch() -> Option<String> {
135+
let output = Command::new("git")
136+
.args(["branch", "--show-current"])
137+
.output()
138+
.ok()?;
139+
if output.status.success() {
140+
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
141+
} else {
142+
None
143+
}
144+
}
145+
146+
fn get_head_commit() -> Option<String> {
147+
let output = Command::new("git")
148+
.args(["rev-parse", "--short", "HEAD"])
149+
.output()
150+
.ok()?;
151+
if output.status.success() {
152+
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
153+
} else {
154+
None
155+
}
156+
}
157+
158+
fn get_git_actor() -> Option<String> {
159+
let name = Command::new("git")
160+
.args(["config", "user.name"])
161+
.output()
162+
.ok()
163+
.filter(|o| o.status.success())
164+
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())?;
165+
Some(name)
166+
}
167+
105168
async fn execute_bash_async(
106169
input: BashCommandInput,
107170
sandbox_status: SandboxStatus,
108171
cwd: std::path::PathBuf,
109172
) -> io::Result<BashCommandOutput> {
173+
// Detect and emit ship provenance for git push operations
174+
detect_and_emit_ship_prepared(&input.command);
175+
110176
let mut command = prepare_tokio_command(&input.command, &cwd, &sandbox_status, true);
111177

112178
// The model often passes timeout values thinking they're seconds (e.g. 60)

rust/crates/rusty-claude-cli/src/main.rs

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -484,11 +484,14 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
484484
let value = args
485485
.get(index + 1)
486486
.ok_or_else(|| "missing value for --model".to_string())?;
487+
validate_model_syntax(value)?;
487488
model = resolve_model_alias_with_config(value);
488489
index += 2;
489490
}
490491
flag if flag.starts_with("--model=") => {
491-
model = resolve_model_alias_with_config(&flag[8..]);
492+
let value = &flag[8..];
493+
validate_model_syntax(value)?;
494+
model = resolve_model_alias_with_config(value);
492495
index += 1;
493496
}
494497
"--output-format" => {
@@ -780,6 +783,31 @@ fn parse_single_word_command_alias(
780783
permission_mode_override: Option<PermissionMode>,
781784
output_format: CliOutputFormat,
782785
) -> Option<Result<CliAction, String>> {
786+
if rest.is_empty() {
787+
return None;
788+
}
789+
790+
// Diagnostic verbs (help, version, status, sandbox, doctor, state) accept only the verb itself
791+
// or --help / -h as a suffix. Any other suffix args are unrecognized.
792+
let verb = &rest[0];
793+
let is_diagnostic = matches!(
794+
verb.as_str(),
795+
"help" | "version" | "status" | "sandbox" | "doctor" | "state"
796+
);
797+
798+
if is_diagnostic && rest.len() > 1 {
799+
// Diagnostic verb with trailing args: reject unrecognized suffix
800+
if is_help_flag(&rest[1]) && rest.len() == 2 {
801+
// "doctor --help" is valid, routed to parse_local_help_action() instead
802+
return None;
803+
}
804+
// Unrecognized suffix like "--json"
805+
return Some(Err(format!(
806+
"unrecognized argument `{}` for subcommand `{}`",
807+
rest[1], verb
808+
)));
809+
}
810+
783811
if rest.len() != 1 {
784812
return None;
785813
}
@@ -1072,6 +1100,37 @@ fn resolve_model_alias_with_config(model: &str) -> String {
10721100
resolve_model_alias(trimmed).to_string()
10731101
}
10741102

1103+
/// Validate model syntax at parse time.
1104+
/// Accepts: known aliases (opus, sonnet, haiku) or provider/model pattern.
1105+
/// Rejects: empty, whitespace-only, strings with spaces, or invalid chars.
1106+
fn validate_model_syntax(model: &str) -> Result<(), String> {
1107+
let trimmed = model.trim();
1108+
if trimmed.is_empty() {
1109+
return Err("model string cannot be empty".to_string());
1110+
}
1111+
// Known aliases are always valid
1112+
match trimmed {
1113+
"opus" | "sonnet" | "haiku" => return Ok(()),
1114+
_ => {}
1115+
}
1116+
// Check for spaces (malformed)
1117+
if trimmed.contains(' ') {
1118+
return Err(format!(
1119+
"invalid model syntax: '{}' contains spaces. Use provider/model format or known alias",
1120+
trimmed
1121+
));
1122+
}
1123+
// Check provider/model format: provider_id/model_id
1124+
let parts: Vec<&str> = trimmed.split('/').collect();
1125+
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
1126+
return Err(format!(
1127+
"invalid model syntax: '{}'. Expected provider/model (e.g., anthropic/claude-opus-4-6) or known alias (opus, sonnet, haiku)",
1128+
trimmed
1129+
));
1130+
}
1131+
Ok(())
1132+
}
1133+
10751134
fn config_alias_for_current_dir(alias: &str) -> Option<String> {
10761135
if alias.is_empty() {
10771136
return None;

0 commit comments

Comments
 (0)