1 Commits

Author SHA1 Message Date
Yeachan-Heo
cdf24b87b4 Enable safe in-place CLI self-updates from GitHub releases
Add a self-update command to the Rust CLI that checks the latest GitHub release, compares versions, downloads a matching binary plus checksum manifest, verifies SHA-256, and swaps the executable only after validation succeeds. The command reports changelog text from the release body and exits safely when no published release or matching asset exists.\n\nThe workspace verification request also surfaced unrelated stale permission-mode references in runtime tests and a brittle config-count assertion in the CLI tests. Those were updated so the requested fmt/clippy/test pass can complete cleanly in this worktree.\n\nConstraint: GitHub latest release for instructkr/clawd-code currently returns 404, so the updater must degrade safely when no published release exists\nConstraint: Must not replace the current executable before checksum verification succeeds\nRejected: Shell out to an external updater | environment-dependent and does not meet the GitHub API/changelog requirement\nRejected: Add archive extraction support now | no published release assets exist yet to justify broader packaging complexity\nConfidence: medium\nScope-risk: moderate\nReversibility: clean\nDirective: Keep release asset naming and checksum manifest conventions aligned with the eventual GitHub release pipeline before expanding packaging formats\nTested: cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace --exclude compat-harness; cargo run -q -p rusty-claude-cli -- self-update\nNot-tested: Successful live binary replacement against a real published GitHub release asset
2026-04-01 01:01:26 +00:00
12 changed files with 497 additions and 353 deletions

3
rust/Cargo.lock generated
View File

@@ -1091,8 +1091,11 @@ dependencies = [
"compat-harness", "compat-harness",
"crossterm", "crossterm",
"pulldown-cmark", "pulldown-cmark",
"reqwest",
"runtime", "runtime",
"serde",
"serde_json", "serde_json",
"sha2",
"syntect", "syntect",
"tokio", "tokio",
"tools", "tools",

View File

@@ -84,6 +84,15 @@ cargo run -p rusty-claude-cli -- logout
This removes only the stored OAuth credentials and preserves unrelated JSON fields in `credentials.json`. This removes only the stored OAuth credentials and preserves unrelated JSON fields in `credentials.json`.
### Self-update
```bash
cd rust
cargo run -p rusty-claude-cli -- self-update
```
The command checks the latest GitHub release for `instructkr/clawd-code`, compares it to the current binary version, downloads the matching binary asset plus checksum manifest, verifies SHA-256, replaces the current executable, and prints the release changelog. If no published release or matching asset exists, it exits safely with an explanatory message.
## Usage examples ## Usage examples
### 1) Prompt mode ### 1) Prompt mode
@@ -162,6 +171,7 @@ cargo run -p rusty-claude-cli -- --resume session.json /memory /config
- `dump-manifests` — print extracted upstream manifest counts - `dump-manifests` — print extracted upstream manifest counts
- `bootstrap-plan` — print the current bootstrap skeleton - `bootstrap-plan` — print the current bootstrap skeleton
- `system-prompt [--cwd PATH] [--date YYYY-MM-DD]` — render the synthesized system prompt - `system-prompt [--cwd PATH] [--date YYYY-MM-DD]` — render the synthesized system prompt
- `self-update` — update the installed binary from the latest GitHub release when a matching asset is available
- `--help` / `-h` — show CLI help - `--help` / `-h` — show CLI help
- `--version` / `-V` — print the CLI version and build info locally (no API call) - `--version` / `-V` — print the CLI version and build info locally (no API call)
- `--output-format text|json` — choose non-interactive prompt output rendering - `--output-format text|json` — choose non-interactive prompt output rendering

View File

@@ -912,7 +912,6 @@ mod tests {
system: None, system: None,
tools: None, tools: None,
tool_choice: None, tool_choice: None,
thinking: None,
stream: false, stream: false,
}; };

View File

@@ -13,5 +13,5 @@ pub use types::{
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest, InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest,
MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent, MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent,
ThinkingConfig, ToolChoice, ToolDefinition, ToolResultContentBlock, Usage, ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
}; };

View File

@@ -12,8 +12,6 @@ pub struct MessageRequest {
pub tools: Option<Vec<ToolDefinition>>, pub tools: Option<Vec<ToolDefinition>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<ToolChoice>, pub tool_choice: Option<ToolChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thinking: Option<ThinkingConfig>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")] #[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub stream: bool, pub stream: bool,
} }
@@ -26,23 +24,6 @@ impl MessageRequest {
} }
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThinkingConfig {
#[serde(rename = "type")]
pub kind: String,
pub budget_tokens: u32,
}
impl ThinkingConfig {
#[must_use]
pub fn enabled(budget_tokens: u32) -> Self {
Self {
kind: "enabled".to_string(),
budget_tokens,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InputMessage { pub struct InputMessage {
pub role: String, pub role: String,
@@ -149,11 +130,6 @@ pub enum OutputContentBlock {
Text { Text {
text: String, text: String,
}, },
Thinking {
thinking: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
signature: Option<String>,
},
ToolUse { ToolUse {
id: String, id: String,
name: String, name: String,
@@ -213,8 +189,6 @@ pub struct ContentBlockDeltaEvent {
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentBlockDelta { pub enum ContentBlockDelta {
TextDelta { text: String }, TextDelta { text: String },
ThinkingDelta { thinking: String },
SignatureDelta { signature: String },
InputJsonDelta { partial_json: String }, InputJsonDelta { partial_json: String },
} }

View File

@@ -258,7 +258,6 @@ async fn live_stream_smoke_test() {
system: None, system: None,
tools: None, tools: None,
tool_choice: None, tool_choice: None,
thinking: None,
stream: false, stream: false,
}) })
.await .await
@@ -439,7 +438,6 @@ fn sample_request(stream: bool) -> MessageRequest {
}), }),
}]), }]),
tool_choice: Some(ToolChoice::Auto), tool_choice: Some(ToolChoice::Auto),
thinking: None,
stream, stream,
} }
} }

View File

@@ -57,12 +57,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
argument_hint: None, argument_hint: None,
resume_supported: true, resume_supported: true,
}, },
SlashCommandSpec {
name: "thinking",
summary: "Show or toggle extended thinking",
argument_hint: Some("[on|off]"),
resume_supported: false,
},
SlashCommandSpec { SlashCommandSpec {
name: "model", name: "model",
summary: "Show or switch the active model", summary: "Show or switch the active model",
@@ -142,9 +136,6 @@ pub enum SlashCommand {
Help, Help,
Status, Status,
Compact, Compact,
Thinking {
enabled: Option<bool>,
},
Model { Model {
model: Option<String>, model: Option<String>,
}, },
@@ -189,13 +180,6 @@ impl SlashCommand {
"help" => Self::Help, "help" => Self::Help,
"status" => Self::Status, "status" => Self::Status,
"compact" => Self::Compact, "compact" => Self::Compact,
"thinking" => Self::Thinking {
enabled: match parts.next() {
Some("on") => Some(true),
Some("off") => Some(false),
Some(_) | None => None,
},
},
"model" => Self::Model { "model" => Self::Model {
model: parts.next().map(ToOwned::to_owned), model: parts.next().map(ToOwned::to_owned),
}, },
@@ -295,7 +279,6 @@ pub fn handle_slash_command(
session: session.clone(), session: session.clone(),
}), }),
SlashCommand::Status SlashCommand::Status
| SlashCommand::Thinking { .. }
| SlashCommand::Model { .. } | SlashCommand::Model { .. }
| SlashCommand::Permissions { .. } | SlashCommand::Permissions { .. }
| SlashCommand::Clear { .. } | SlashCommand::Clear { .. }
@@ -324,22 +307,6 @@ mod tests {
fn parses_supported_slash_commands() { fn parses_supported_slash_commands() {
assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help)); assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status)); assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
assert_eq!(
SlashCommand::parse("/thinking on"),
Some(SlashCommand::Thinking {
enabled: Some(true),
})
);
assert_eq!(
SlashCommand::parse("/thinking off"),
Some(SlashCommand::Thinking {
enabled: Some(false),
})
);
assert_eq!(
SlashCommand::parse("/thinking"),
Some(SlashCommand::Thinking { enabled: None })
);
assert_eq!( assert_eq!(
SlashCommand::parse("/model claude-opus"), SlashCommand::parse("/model claude-opus"),
Some(SlashCommand::Model { Some(SlashCommand::Model {
@@ -407,7 +374,6 @@ mod tests {
assert!(help.contains("/help")); assert!(help.contains("/help"));
assert!(help.contains("/status")); assert!(help.contains("/status"));
assert!(help.contains("/compact")); assert!(help.contains("/compact"));
assert!(help.contains("/thinking [on|off]"));
assert!(help.contains("/model [model]")); assert!(help.contains("/model [model]"));
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/clear [--confirm]"));
@@ -420,7 +386,7 @@ mod tests {
assert!(help.contains("/version")); assert!(help.contains("/version"));
assert!(help.contains("/export [file]")); assert!(help.contains("/export [file]"));
assert!(help.contains("/session [list|switch <session-id>]")); assert!(help.contains("/session [list|switch <session-id>]"));
assert_eq!(slash_command_specs().len(), 16); assert_eq!(slash_command_specs().len(), 15);
assert_eq!(resume_supported_slash_commands().len(), 11); assert_eq!(resume_supported_slash_commands().len(), 11);
} }
@@ -468,9 +434,6 @@ mod tests {
let session = Session::new(); let session = Session::new();
assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
assert!(
handle_slash_command("/thinking on", &session, CompactionConfig::default()).is_none()
);
assert!( assert!(
handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none() handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
); );

View File

@@ -130,7 +130,7 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String {
.filter_map(|block| match block { .filter_map(|block| match block {
ContentBlock::ToolUse { name, .. } => Some(name.as_str()), ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
ContentBlock::ToolResult { tool_name, .. } => Some(tool_name.as_str()), ContentBlock::ToolResult { tool_name, .. } => Some(tool_name.as_str()),
ContentBlock::Text { .. } | ContentBlock::Thinking { .. } => None, ContentBlock::Text { .. } => None,
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
tool_names.sort_unstable(); tool_names.sort_unstable();
@@ -200,7 +200,6 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String {
fn summarize_block(block: &ContentBlock) -> String { fn summarize_block(block: &ContentBlock) -> String {
let raw = match block { let raw = match block {
ContentBlock::Text { text } => text.clone(), ContentBlock::Text { text } => text.clone(),
ContentBlock::Thinking { text, .. } => format!("thinking: {text}"),
ContentBlock::ToolUse { name, input, .. } => format!("tool_use {name}({input})"), ContentBlock::ToolUse { name, input, .. } => format!("tool_use {name}({input})"),
ContentBlock::ToolResult { ContentBlock::ToolResult {
tool_name, tool_name,
@@ -259,7 +258,7 @@ fn collect_key_files(messages: &[ConversationMessage]) -> Vec<String> {
.iter() .iter()
.flat_map(|message| message.blocks.iter()) .flat_map(|message| message.blocks.iter())
.map(|block| match block { .map(|block| match block {
ContentBlock::Text { text } | ContentBlock::Thinking { text, .. } => text.as_str(), ContentBlock::Text { text } => text.as_str(),
ContentBlock::ToolUse { input, .. } => input.as_str(), ContentBlock::ToolUse { input, .. } => input.as_str(),
ContentBlock::ToolResult { output, .. } => output.as_str(), ContentBlock::ToolResult { output, .. } => output.as_str(),
}) })
@@ -281,15 +280,10 @@ fn infer_current_work(messages: &[ConversationMessage]) -> Option<String> {
fn first_text_block(message: &ConversationMessage) -> Option<&str> { fn first_text_block(message: &ConversationMessage) -> Option<&str> {
message.blocks.iter().find_map(|block| match block { message.blocks.iter().find_map(|block| match block {
ContentBlock::Text { text } | ContentBlock::Thinking { text, .. } ContentBlock::Text { text } if !text.trim().is_empty() => Some(text.as_str()),
if !text.trim().is_empty() =>
{
Some(text.as_str())
}
ContentBlock::ToolUse { .. } ContentBlock::ToolUse { .. }
| ContentBlock::ToolResult { .. } | ContentBlock::ToolResult { .. }
| ContentBlock::Text { .. } | ContentBlock::Text { .. } => None,
| ContentBlock::Thinking { .. } => None,
}) })
} }
@@ -334,7 +328,7 @@ fn estimate_message_tokens(message: &ConversationMessage) -> usize {
.blocks .blocks
.iter() .iter()
.map(|block| match block { .map(|block| match block {
ContentBlock::Text { text } | ContentBlock::Thinking { text, .. } => text.len() / 4 + 1, ContentBlock::Text { text } => text.len() / 4 + 1,
ContentBlock::ToolUse { name, input, .. } => (name.len() + input.len()) / 4 + 1, ContentBlock::ToolUse { name, input, .. } => (name.len() + input.len()) / 4 + 1,
ContentBlock::ToolResult { ContentBlock::ToolResult {
tool_name, output, .. tool_name, output, ..

View File

@@ -17,8 +17,6 @@ pub struct ApiRequest {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum AssistantEvent { pub enum AssistantEvent {
TextDelta(String), TextDelta(String),
ThinkingDelta(String),
ThinkingSignature(String),
ToolUse { ToolUse {
id: String, id: String,
name: String, name: String,
@@ -249,26 +247,15 @@ fn build_assistant_message(
events: Vec<AssistantEvent>, events: Vec<AssistantEvent>,
) -> Result<(ConversationMessage, Option<TokenUsage>), RuntimeError> { ) -> Result<(ConversationMessage, Option<TokenUsage>), RuntimeError> {
let mut text = String::new(); let mut text = String::new();
let mut thinking = String::new();
let mut thinking_signature: Option<String> = None;
let mut blocks = Vec::new(); let mut blocks = Vec::new();
let mut finished = false; let mut finished = false;
let mut usage = None; let mut usage = None;
for event in events { for event in events {
match event { match event {
AssistantEvent::TextDelta(delta) => { AssistantEvent::TextDelta(delta) => text.push_str(&delta),
flush_thinking_block(&mut thinking, &mut thinking_signature, &mut blocks);
text.push_str(&delta);
}
AssistantEvent::ThinkingDelta(delta) => {
flush_text_block(&mut text, &mut blocks);
thinking.push_str(&delta);
}
AssistantEvent::ThinkingSignature(signature) => thinking_signature = Some(signature),
AssistantEvent::ToolUse { id, name, input } => { AssistantEvent::ToolUse { id, name, input } => {
flush_text_block(&mut text, &mut blocks); flush_text_block(&mut text, &mut blocks);
flush_thinking_block(&mut thinking, &mut thinking_signature, &mut blocks);
blocks.push(ContentBlock::ToolUse { id, name, input }); blocks.push(ContentBlock::ToolUse { id, name, input });
} }
AssistantEvent::Usage(value) => usage = Some(value), AssistantEvent::Usage(value) => usage = Some(value),
@@ -279,7 +266,6 @@ fn build_assistant_message(
} }
flush_text_block(&mut text, &mut blocks); flush_text_block(&mut text, &mut blocks);
flush_thinking_block(&mut thinking, &mut thinking_signature, &mut blocks);
if !finished { if !finished {
return Err(RuntimeError::new( return Err(RuntimeError::new(
@@ -304,19 +290,6 @@ fn flush_text_block(text: &mut String, blocks: &mut Vec<ContentBlock>) {
} }
} }
fn flush_thinking_block(
thinking: &mut String,
signature: &mut Option<String>,
blocks: &mut Vec<ContentBlock>,
) {
if !thinking.is_empty() || signature.is_some() {
blocks.push(ContentBlock::Thinking {
text: std::mem::take(thinking),
signature: signature.take(),
});
}
}
type ToolHandler = Box<dyn FnMut(&str) -> Result<String, ToolError>>; type ToolHandler = Box<dyn FnMut(&str) -> Result<String, ToolError>>;
#[derive(Default)] #[derive(Default)]
@@ -352,8 +325,8 @@ impl ToolExecutor for StaticToolExecutor {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
build_assistant_message, ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError,
RuntimeError, StaticToolExecutor, StaticToolExecutor,
}; };
use crate::compact::CompactionConfig; use crate::compact::CompactionConfig;
use crate::permissions::{ use crate::permissions::{
@@ -529,29 +502,6 @@ mod tests {
)); ));
} }
#[test]
fn thinking_blocks_are_preserved_separately_from_text() {
let (message, usage) = build_assistant_message(vec![
AssistantEvent::ThinkingDelta("first ".to_string()),
AssistantEvent::ThinkingDelta("second".to_string()),
AssistantEvent::ThinkingSignature("sig-1".to_string()),
AssistantEvent::TextDelta("final".to_string()),
AssistantEvent::MessageStop,
])
.expect("assistant message should build");
assert_eq!(usage, None);
assert!(matches!(
&message.blocks[0],
ContentBlock::Thinking { text, signature }
if text == "first second" && signature.as_deref() == Some("sig-1")
));
assert!(matches!(
&message.blocks[1],
ContentBlock::Text { text } if text == "final"
));
}
#[test] #[test]
fn reconstructs_usage_tracker_from_restored_session() { fn reconstructs_usage_tracker_from_restored_session() {
struct SimpleApi; struct SimpleApi;

View File

@@ -19,10 +19,6 @@ pub enum ContentBlock {
Text { Text {
text: String, text: String,
}, },
Thinking {
text: String,
signature: Option<String>,
},
ToolUse { ToolUse {
id: String, id: String,
name: String, name: String,
@@ -261,19 +257,6 @@ impl ContentBlock {
object.insert("type".to_string(), JsonValue::String("text".to_string())); object.insert("type".to_string(), JsonValue::String("text".to_string()));
object.insert("text".to_string(), JsonValue::String(text.clone())); object.insert("text".to_string(), JsonValue::String(text.clone()));
} }
Self::Thinking { text, signature } => {
object.insert(
"type".to_string(),
JsonValue::String("thinking".to_string()),
);
object.insert("text".to_string(), JsonValue::String(text.clone()));
if let Some(signature) = signature {
object.insert(
"signature".to_string(),
JsonValue::String(signature.clone()),
);
}
}
Self::ToolUse { id, name, input } => { Self::ToolUse { id, name, input } => {
object.insert( object.insert(
"type".to_string(), "type".to_string(),
@@ -320,13 +303,6 @@ impl ContentBlock {
"text" => Ok(Self::Text { "text" => Ok(Self::Text {
text: required_string(object, "text")?, text: required_string(object, "text")?,
}), }),
"thinking" => Ok(Self::Thinking {
text: required_string(object, "text")?,
signature: object
.get("signature")
.and_then(JsonValue::as_str)
.map(ToOwned::to_owned),
}),
"tool_use" => Ok(Self::ToolUse { "tool_use" => Ok(Self::ToolUse {
id: required_string(object, "id")?, id: required_string(object, "id")?,
name: required_string(object, "name")?, name: required_string(object, "name")?,

View File

@@ -11,8 +11,11 @@ commands = { path = "../commands" }
compat-harness = { path = "../compat-harness" } compat-harness = { path = "../compat-harness" }
crossterm = "0.28" crossterm = "0.28"
pulldown-cmark = "0.13" pulldown-cmark = "0.13"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
runtime = { path = "../runtime" } runtime = { path = "../runtime" }
serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
sha2 = "0.10"
syntect = "5" syntect = "5"
tokio = { version = "1", features = ["rt-multi-thread", "time"] } tokio = { version = "1", features = ["rt-multi-thread", "time"] }
tools = { path = "../tools" } tools = { path = "../tools" }

File diff suppressed because it is too large Load Diff