minor README tweaks + rust progress
This commit is contained in:
parent
c99507ca1e
commit
45f7ac9071
9 changed files with 628 additions and 25 deletions
37
README.md
37
README.md
|
|
@ -7,7 +7,7 @@ The process was explicitly two-phase:
|
|||
|
||||
Specification [`spec/`](https://github.com/kuberwastaken/claude-code/tree/main/spec) - An AI agent analyzed the source and produced exhaustive behavioral specifications and improvements, deviated from the original: architecture, data flows, tool contracts, system designs. No source code was carried forward.
|
||||
|
||||
Implementation [`src/`](https://github.com/kuberwastaken/claude-code/tree/main/src-rust)- A separate AI agent implemented from the spec alone, never referencing the original TypeScript. The output is idiomatic Rust that reproduces the behavior, not the expression.
|
||||
Implementation [`src-rust/`](https://github.com/kuberwastaken/claude-code/tree/main/src-rust)- A separate AI agent implemented from the spec alone, never referencing the original TypeScript. The output is idiomatic Rust that reproduces the behavior, not the expression.
|
||||
|
||||
This mirrors the legal precedent established by Phoenix Technologies v. IBM (1984) — clean-room engineering of the BIOS — and the principle from Baker v. Selden (1879) that copyright protects expression, not ideas or behavior.
|
||||
|
||||
|
|
@ -353,6 +353,14 @@ Claude Code's tool system lives in [`tools/`](https://github.com/kuberwastaken/c
|
|||
| **WorkflowTool** | Execute workflow scripts |
|
||||
| **ConfigTool** | Modify settings (**internal only**) |
|
||||
| **TungstenTool** | Advanced features (**internal only**) |
|
||||
| **MCPTool** | Generic MCP tool execution |
|
||||
| **McpAuthTool** | MCP server authentication |
|
||||
| **SyntheticOutputTool** | Structured output via dynamic JSON schemas |
|
||||
| **SuggestBackgroundPRTool** | Suggest background PRs (**internal only**) |
|
||||
| **VerifyPlanExecutionTool** | Verify plan execution (gated by `CLAUDE_CODE_VERIFY_PLAN`) |
|
||||
| **CtxInspectTool** | Context window inspection (gated by `CONTEXT_COLLAPSE`) |
|
||||
| **TerminalCaptureTool** | Terminal panel capture (gated by `TERMINAL_PANEL`) |
|
||||
| **CronCreateTool** / **CronDeleteTool** / **CronListTool** | Granular cron job management (under `ScheduleCronTool/`) |
|
||||
| **SendUserFile** / **PushNotification** / **SubscribePR** | KAIROS-exclusive tools |
|
||||
|
||||
Tools are registered via `getAllBaseTools()` and filtered by feature gates, user type, environment flags, and permission deny rules. There's a **tool schema cache** ([`toolSchemaCache.ts`](https://github.com/kuberwastaken/claude-code/blob/main/src-rust/crates/tools/src/lib.rs)) that caches JSON schemas for prompt efficiency.
|
||||
|
|
@ -401,6 +409,33 @@ The [`constants/betas.ts`](https://github.com/kuberwastaken/claude-code/blob/mai
|
|||
|
||||
---
|
||||
|
||||
## Upcoming Models - Capybara, Opus 4.7, and Sonnet 4.8
|
||||
|
||||
The codebase contains references to unreleased Anthropic models that haven't been publicly announced:
|
||||
|
||||
- **Claude "Capybara"** - A new model family already in version 2, with a variant called `capybara-v2-fast` being prepared with a **1M context window**
|
||||
- **Capybara comes in "fast" and regular thinking** tiers
|
||||
- **Opus 4.7** and **Sonnet 4.8** are already referenced within the code
|
||||
|
||||
### Production Engineering Around Capybara
|
||||
|
||||
The code reveals that Anthropic observed a **real production failure mode**: Capybara can prematurely stop generating when the prompt shape resembles a turn boundary after tool results. Rather than waiting for a model fix, they mitigated it with **prompt-shape surgery**:
|
||||
|
||||
1. **Force a safe boundary marker** (`Tool loaded.`) to prevent ambiguous turn boundaries
|
||||
2. **Relocate risky sibling blocks** that could trigger premature stops
|
||||
3. **Smoosh reminder text into tool results** to maintain generation flow
|
||||
4. **Add non-empty markers for empty tool outputs** to avoid confusing the model
|
||||
|
||||
All of this is wrapped with **kill-switchable gates** (`tengu_*` prefixed flags) so rollout can be staged and reverted quickly.
|
||||
|
||||
- Comments include **concrete A/B test evidence** (not hand-wavy), which typically means this area was **launch-critical** and closely monitored
|
||||
- Comments like *"un-gate once validated on external via A/B"* confirm that **ant/internal users are canary lanes** before broader rollout
|
||||
- The strongest interpretation: Anthropic is working toward a **Capybara model family** with a fast-tier variant (`capybara-v2-fast`), supporting up to **1M context**
|
||||
|
||||
Nothing confirms a launch date or official SKU naming, but the implementation signatures fit a model family that is actively being prepared for release ;)
|
||||
|
||||
---
|
||||
|
||||
## Feature Gating - Internal vs. External Builds
|
||||
|
||||
This is one of the most architecturally interesting parts of the codebase.
|
||||
|
|
|
|||
|
|
@ -151,10 +151,6 @@ struct Cli {
|
|||
#[arg(long = "dangerously-skip-permissions", action = ArgAction::SetTrue)]
|
||||
dangerously_skip_permissions: bool,
|
||||
|
||||
/// Show version and exit
|
||||
#[arg(long = "version", short = 'V', action = ArgAction::SetTrue)]
|
||||
version_flag: bool,
|
||||
|
||||
/// Dump the system prompt to stdout and exit
|
||||
#[arg(long = "dump-system-prompt", action = ArgAction::SetTrue, hide = true)]
|
||||
dump_system_prompt: bool,
|
||||
|
|
@ -449,6 +445,8 @@ async fn main() -> anyhow::Result<()> {
|
|||
max_turns: cli.max_turns,
|
||||
system_prompt: Some(system_prompt),
|
||||
append_system_prompt: None,
|
||||
output_style: config.effective_output_style(),
|
||||
working_directory: Some(cwd.display().to_string()),
|
||||
thinking_budget: None,
|
||||
temperature: None,
|
||||
};
|
||||
|
|
@ -805,6 +803,11 @@ async fn run_interactive(
|
|||
app.status_message =
|
||||
Some("Configuration updated.".to_string());
|
||||
}
|
||||
Some(CommandResult::ConfigChangeMessage(new_cfg, msg)) => {
|
||||
cmd_ctx.config = new_cfg.clone();
|
||||
app.config = new_cfg;
|
||||
app.status_message = Some(msg);
|
||||
}
|
||||
Some(CommandResult::UserMessage(msg)) => {
|
||||
// Inject as user turn
|
||||
messages.push(cc_core::types::Message::user(msg.clone()));
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@
|
|||
// Each command is a struct implementing the `SlashCommand` trait.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use cc_core::config::Config;
|
||||
use cc_core::config::{Config, Settings, Theme};
|
||||
use cc_core::cost::CostTracker;
|
||||
use cc_core::types::Message;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
#[allow(unused_imports)]
|
||||
use std::path::PathBuf;
|
||||
|
|
@ -34,6 +35,8 @@ pub enum CommandResult {
|
|||
UserMessage(String),
|
||||
/// Modify the configuration.
|
||||
ConfigChange(Config),
|
||||
/// Modify the configuration and show a specific status message.
|
||||
ConfigChangeMessage(Config, String),
|
||||
/// Clear the conversation.
|
||||
ClearConversation,
|
||||
/// Replace the conversation with a specific message list (used by /rewind).
|
||||
|
|
@ -111,6 +114,141 @@ pub struct RenameCommand;
|
|||
pub struct EffortCommand;
|
||||
pub struct SummaryCommand;
|
||||
pub struct CommitCommand;
|
||||
pub struct ThemeCommand;
|
||||
pub struct OutputStyleCommand;
|
||||
pub struct KeybindingsCommand;
|
||||
pub struct PrivacySettingsCommand;
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct KeybindingTemplateFile {
|
||||
#[serde(rename = "$schema")]
|
||||
schema: &'static str,
|
||||
#[serde(rename = "$docs")]
|
||||
docs: &'static str,
|
||||
bindings: Vec<KeybindingTemplateBlock>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct KeybindingTemplateBlock {
|
||||
context: String,
|
||||
bindings: BTreeMap<String, Option<String>>,
|
||||
}
|
||||
|
||||
fn save_settings_mutation<F>(mutate: F) -> anyhow::Result<()>
|
||||
where
|
||||
F: FnOnce(&mut Settings),
|
||||
{
|
||||
let mut settings = Settings::load_sync()?;
|
||||
mutate(&mut settings);
|
||||
settings.save_sync()
|
||||
}
|
||||
|
||||
fn open_with_system(target: &str) -> std::io::Result<()> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let ps_cmd = format!("Start-Process '{}'", target.replace('\'', "''"));
|
||||
std::process::Command::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
std::process::Command::new("open")
|
||||
.arg(target)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(target)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn format_keystroke(keystroke: &cc_core::keybindings::ParsedKeystroke) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if keystroke.ctrl {
|
||||
parts.push("ctrl".to_string());
|
||||
}
|
||||
if keystroke.alt {
|
||||
parts.push("alt".to_string());
|
||||
}
|
||||
if keystroke.shift {
|
||||
parts.push("shift".to_string());
|
||||
}
|
||||
if keystroke.meta {
|
||||
parts.push("meta".to_string());
|
||||
}
|
||||
parts.push(match keystroke.key.as_str() {
|
||||
"space" => "space".to_string(),
|
||||
other => other.to_string(),
|
||||
});
|
||||
parts.join("+")
|
||||
}
|
||||
|
||||
fn format_chord(chord: &[cc_core::keybindings::ParsedKeystroke]) -> String {
|
||||
chord
|
||||
.iter()
|
||||
.map(format_keystroke)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn generate_keybindings_template() -> anyhow::Result<String> {
|
||||
let mut grouped: BTreeMap<String, BTreeMap<String, Option<String>>> = BTreeMap::new();
|
||||
for binding in cc_core::keybindings::default_bindings() {
|
||||
let chord = format_chord(&binding.chord);
|
||||
if cc_core::keybindings::NON_REBINDABLE.contains(&chord.as_str()) {
|
||||
continue;
|
||||
}
|
||||
grouped
|
||||
.entry(format!("{:?}", binding.context))
|
||||
.or_default()
|
||||
.insert(chord, binding.action.clone());
|
||||
}
|
||||
|
||||
let template = KeybindingTemplateFile {
|
||||
schema: "https://www.schemastore.org/claude-code-keybindings.json",
|
||||
docs: "https://code.claude.com/docs/en/keybindings",
|
||||
bindings: grouped
|
||||
.into_iter()
|
||||
.map(|(context, bindings)| KeybindingTemplateBlock { context, bindings })
|
||||
.collect(),
|
||||
};
|
||||
|
||||
Ok(format!(
|
||||
"{}\n",
|
||||
serde_json::to_string_pretty(&template)?
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_theme(name: &str) -> Option<Theme> {
|
||||
match name.trim().to_lowercase().as_str() {
|
||||
"default" | "system" => Some(Theme::Default),
|
||||
"dark" => Some(Theme::Dark),
|
||||
"light" => Some(Theme::Light),
|
||||
custom if !custom.is_empty() => Some(Theme::Custom(custom.to_string())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_output_style_name(config: &Config) -> &str {
|
||||
config.output_style.as_deref().unwrap_or("default")
|
||||
}
|
||||
|
||||
// ---- /help ---------------------------------------------------------------
|
||||
|
||||
|
|
@ -262,9 +400,311 @@ impl SlashCommand for ConfigCommand {
|
|||
fn name(&self) -> &str { "config" }
|
||||
fn description(&self) -> &str { "Show or modify configuration settings" }
|
||||
|
||||
async fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult {
|
||||
let args = args.trim();
|
||||
if args.is_empty() || matches!(args, "show" | "get") {
|
||||
let json = serde_json::to_string_pretty(&ctx.config).unwrap_or_default();
|
||||
return CommandResult::Message(format!(
|
||||
"Current configuration:\n{}\n\nUsage:\n /config\n /config set theme <default|dark|light>\n /config set output-style <default|concise|explanatory|learning|formal|casual>\n /config set model <model>\n /config set permission-mode <default|accept-edits|bypass-permissions|plan>\n /config unset <model|output-style>",
|
||||
json
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(key) = args.strip_prefix("get ").map(str::trim) {
|
||||
return match key {
|
||||
"theme" => CommandResult::Message(format!("theme = {:?}", ctx.config.theme)),
|
||||
"output-style" | "output_style" => CommandResult::Message(format!(
|
||||
"output-style = {}",
|
||||
current_output_style_name(&ctx.config)
|
||||
)),
|
||||
"model" => CommandResult::Message(format!(
|
||||
"model = {}",
|
||||
ctx.config.effective_model()
|
||||
)),
|
||||
"permission-mode" | "permission_mode" => CommandResult::Message(format!(
|
||||
"permission-mode = {:?}",
|
||||
ctx.config.permission_mode
|
||||
)),
|
||||
other => CommandResult::Error(format!("Unknown config key '{}'", other)),
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(key) = args.strip_prefix("unset ").map(str::trim) {
|
||||
return match key {
|
||||
"model" => {
|
||||
let mut new_config = ctx.config.clone();
|
||||
new_config.model = None;
|
||||
if let Err(err) = save_settings_mutation(|settings| settings.config.model = None)
|
||||
{
|
||||
return CommandResult::Error(format!(
|
||||
"Failed to save configuration: {}",
|
||||
err
|
||||
));
|
||||
}
|
||||
CommandResult::ConfigChangeMessage(
|
||||
new_config,
|
||||
"Model reset to the default for new sessions.".to_string(),
|
||||
)
|
||||
}
|
||||
"output-style" | "output_style" => {
|
||||
let mut new_config = ctx.config.clone();
|
||||
new_config.output_style = None;
|
||||
if let Err(err) =
|
||||
save_settings_mutation(|settings| settings.config.output_style = None)
|
||||
{
|
||||
return CommandResult::Error(format!(
|
||||
"Failed to save configuration: {}",
|
||||
err
|
||||
));
|
||||
}
|
||||
CommandResult::ConfigChangeMessage(
|
||||
new_config,
|
||||
"Output style reset to default.".to_string(),
|
||||
)
|
||||
}
|
||||
other => CommandResult::Error(format!("Unknown config key '{}'", other)),
|
||||
};
|
||||
}
|
||||
|
||||
let mut parts = args.splitn(3, ' ');
|
||||
let command = parts.next().unwrap_or_default();
|
||||
let key = parts.next().unwrap_or_default().trim();
|
||||
let value = parts.next().unwrap_or_default().trim();
|
||||
if command != "set" || key.is_empty() || value.is_empty() {
|
||||
return CommandResult::Error("Usage: /config set <key> <value>".to_string());
|
||||
}
|
||||
|
||||
match key {
|
||||
"theme" => {
|
||||
let Some(theme) = parse_theme(value) else {
|
||||
return CommandResult::Error(
|
||||
"Theme must be one of: default, dark, light".to_string(),
|
||||
);
|
||||
};
|
||||
let mut new_config = ctx.config.clone();
|
||||
new_config.theme = theme.clone();
|
||||
if let Err(err) =
|
||||
save_settings_mutation(|settings| settings.config.theme = theme.clone())
|
||||
{
|
||||
return CommandResult::Error(format!("Failed to save configuration: {}", err));
|
||||
}
|
||||
CommandResult::ConfigChangeMessage(
|
||||
new_config,
|
||||
format!("Theme set to {}.", value.trim().to_lowercase()),
|
||||
)
|
||||
}
|
||||
"output-style" | "output_style" => {
|
||||
let normalized = value.trim().to_lowercase();
|
||||
let valid = ["default", "concise", "explanatory", "learning", "formal", "casual"];
|
||||
if !valid.contains(&normalized.as_str()) {
|
||||
return CommandResult::Error(format!(
|
||||
"Unsupported output style '{}'. Use one of: {}",
|
||||
value,
|
||||
valid.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
let mut new_config = ctx.config.clone();
|
||||
new_config.output_style =
|
||||
(normalized != "default").then(|| normalized.clone());
|
||||
if let Err(err) = save_settings_mutation(|settings| {
|
||||
settings.config.output_style =
|
||||
(normalized != "default").then(|| normalized.clone());
|
||||
}) {
|
||||
return CommandResult::Error(format!("Failed to save configuration: {}", err));
|
||||
}
|
||||
CommandResult::ConfigChangeMessage(
|
||||
new_config,
|
||||
format!(
|
||||
"Output style set to {}. Changes take effect on the next request.",
|
||||
normalized
|
||||
),
|
||||
)
|
||||
}
|
||||
"model" => {
|
||||
let mut new_config = ctx.config.clone();
|
||||
new_config.model = Some(value.to_string());
|
||||
if let Err(err) = save_settings_mutation(|settings| {
|
||||
settings.config.model = Some(value.to_string());
|
||||
}) {
|
||||
return CommandResult::Error(format!("Failed to save configuration: {}", err));
|
||||
}
|
||||
CommandResult::ConfigChangeMessage(
|
||||
new_config,
|
||||
format!("Model set to {}.", value),
|
||||
)
|
||||
}
|
||||
"permission-mode" | "permission_mode" => {
|
||||
let mode = match value.trim().to_lowercase().as_str() {
|
||||
"default" => cc_core::config::PermissionMode::Default,
|
||||
"accept-edits" | "accept_edits" => {
|
||||
cc_core::config::PermissionMode::AcceptEdits
|
||||
}
|
||||
"bypass-permissions" | "bypass_permissions" => {
|
||||
cc_core::config::PermissionMode::BypassPermissions
|
||||
}
|
||||
"plan" => cc_core::config::PermissionMode::Plan,
|
||||
_ => {
|
||||
return CommandResult::Error(
|
||||
"Permission mode must be one of: default, accept-edits, bypass-permissions, plan"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let mut new_config = ctx.config.clone();
|
||||
new_config.permission_mode = mode.clone();
|
||||
if let Err(err) = save_settings_mutation(|settings| {
|
||||
settings.config.permission_mode = mode.clone();
|
||||
}) {
|
||||
return CommandResult::Error(format!("Failed to save configuration: {}", err));
|
||||
}
|
||||
CommandResult::ConfigChangeMessage(
|
||||
new_config,
|
||||
format!("Permission mode set to {}.", value.trim().to_lowercase()),
|
||||
)
|
||||
}
|
||||
other => CommandResult::Error(format!("Unknown config key '{}'", other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- /theme --------------------------------------------------------------
|
||||
|
||||
#[async_trait]
|
||||
impl SlashCommand for ThemeCommand {
|
||||
fn name(&self) -> &str { "theme" }
|
||||
fn aliases(&self) -> Vec<&str> { vec!["color"] }
|
||||
fn description(&self) -> &str { "Show or change the current theme" }
|
||||
fn help(&self) -> &str {
|
||||
"Usage: /theme [default|dark|light]\n\
|
||||
Without arguments, shows the active theme. With an argument, updates the theme for this and future sessions."
|
||||
}
|
||||
|
||||
async fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult {
|
||||
let args = args.trim();
|
||||
if args.is_empty() {
|
||||
return CommandResult::Message(format!(
|
||||
"Current theme: {:?}\nUse /theme <default|dark|light> to change it.",
|
||||
ctx.config.theme
|
||||
));
|
||||
}
|
||||
|
||||
let Some(theme) = parse_theme(args) else {
|
||||
return CommandResult::Error(
|
||||
"Theme must be one of: default, dark, light".to_string(),
|
||||
);
|
||||
};
|
||||
|
||||
let mut new_config = ctx.config.clone();
|
||||
new_config.theme = theme.clone();
|
||||
if let Err(err) = save_settings_mutation(|settings| settings.config.theme = theme.clone())
|
||||
{
|
||||
return CommandResult::Error(format!("Failed to save theme: {}", err));
|
||||
}
|
||||
|
||||
CommandResult::ConfigChangeMessage(
|
||||
new_config,
|
||||
format!("Theme set to {}.", args.to_lowercase()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- /output-style -------------------------------------------------------
|
||||
|
||||
#[async_trait]
|
||||
impl SlashCommand for OutputStyleCommand {
|
||||
fn name(&self) -> &str { "output-style" }
|
||||
fn description(&self) -> &str { "Show the output-style migration guidance" }
|
||||
|
||||
async fn execute(&self, _args: &str, ctx: &mut CommandContext) -> CommandResult {
|
||||
let json = serde_json::to_string_pretty(&ctx.config).unwrap_or_default();
|
||||
CommandResult::Message(format!("Current configuration:\n{}", json))
|
||||
CommandResult::Message(format!(
|
||||
"/output-style has been deprecated. Use /config to change your output style, or set it in your settings file. Changes take effect on the next session.\nCurrent output style: {}",
|
||||
current_output_style_name(&ctx.config)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- /keybindings --------------------------------------------------------
|
||||
|
||||
#[async_trait]
|
||||
impl SlashCommand for KeybindingsCommand {
|
||||
fn name(&self) -> &str { "keybindings" }
|
||||
fn description(&self) -> &str { "Create or open ~/.claude/keybindings.json" }
|
||||
|
||||
async fn execute(&self, _args: &str, _ctx: &mut CommandContext) -> CommandResult {
|
||||
let config_dir = Settings::config_dir();
|
||||
let path = config_dir.join("keybindings.json");
|
||||
let existed = path.exists();
|
||||
|
||||
if !existed {
|
||||
if let Err(err) = std::fs::create_dir_all(&config_dir) {
|
||||
return CommandResult::Error(format!(
|
||||
"Failed to create {}: {}",
|
||||
config_dir.display(),
|
||||
err
|
||||
));
|
||||
}
|
||||
|
||||
let template = match generate_keybindings_template() {
|
||||
Ok(template) => template,
|
||||
Err(err) => {
|
||||
return CommandResult::Error(format!(
|
||||
"Failed to generate keybindings template: {}",
|
||||
err
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = std::fs::write(&path, template) {
|
||||
return CommandResult::Error(format!(
|
||||
"Failed to write {}: {}",
|
||||
path.display(),
|
||||
err
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
match open_with_system(&path.display().to_string()) {
|
||||
Ok(_) => CommandResult::Message(if existed {
|
||||
format!("Opened {} in your editor.", path.display())
|
||||
} else {
|
||||
format!(
|
||||
"Created {} with a template and opened it in your editor.",
|
||||
path.display()
|
||||
)
|
||||
}),
|
||||
Err(err) => CommandResult::Message(if existed {
|
||||
format!(
|
||||
"Opened {}. Could not launch an editor automatically: {}",
|
||||
path.display(),
|
||||
err
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Created {} with a template. Could not launch an editor automatically: {}",
|
||||
path.display(),
|
||||
err
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- /privacy-settings ---------------------------------------------------
|
||||
|
||||
#[async_trait]
|
||||
impl SlashCommand for PrivacySettingsCommand {
|
||||
fn name(&self) -> &str { "privacy-settings" }
|
||||
fn description(&self) -> &str { "Open Claude privacy settings" }
|
||||
|
||||
async fn execute(&self, _args: &str, _ctx: &mut CommandContext) -> CommandResult {
|
||||
let url = "https://claude.ai/settings/data-privacy-controls";
|
||||
let fallback = format!("Review and manage your privacy settings at {}", url);
|
||||
match open_with_system(url) {
|
||||
Ok(_) => CommandResult::Message(format!("Opened privacy settings: {}", url)),
|
||||
Err(_) => CommandResult::Message(fallback),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1086,6 +1526,10 @@ pub fn all_commands() -> Vec<Box<dyn SlashCommand>> {
|
|||
Box::new(TasksCommand),
|
||||
Box::new(SessionCommand),
|
||||
Box::new(ThinkingCommand),
|
||||
Box::new(ThemeCommand),
|
||||
Box::new(OutputStyleCommand),
|
||||
Box::new(KeybindingsCommand),
|
||||
Box::new(PrivacySettingsCommand),
|
||||
// New commands
|
||||
Box::new(ExportCommand),
|
||||
Box::new(SkillsCommand),
|
||||
|
|
|
|||
|
|
@ -128,10 +128,28 @@ impl NamedCommand for AddDirCommand {
|
|||
Err(e) => return CommandResult::Error(format!("Cannot resolve path: {e}")),
|
||||
};
|
||||
|
||||
// TODO: persist to settings.json `workspacePaths` array
|
||||
let mut settings = match cc_core::config::Settings::load_sync() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return CommandResult::Error(format!(
|
||||
"Failed to load settings before updating workspace paths: {e}"
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
if !settings.config.workspace_paths.iter().any(|p| p == &abs_path) {
|
||||
settings.config.workspace_paths.push(abs_path.clone());
|
||||
if let Err(e) = settings.save_sync() {
|
||||
return CommandResult::Error(format!(
|
||||
"Added {} for this session, but failed to save settings: {}",
|
||||
abs_path.display(),
|
||||
e
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
CommandResult::Message(format!(
|
||||
"Added {} to allowed workspace paths.\n\
|
||||
Note: restart Claude Code for the change to take effect.",
|
||||
"Added {} to allowed workspace paths.",
|
||||
abs_path.display()
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,9 @@ pub fn parse_keystroke(s: &str) -> Option<ParsedKeystroke> {
|
|||
|
||||
for part in s.split('+') {
|
||||
let part = part.trim();
|
||||
if part.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match part {
|
||||
"ctrl" | "control" => ctrl = true,
|
||||
"alt" | "opt" | "option" => alt = true,
|
||||
|
|
@ -164,6 +167,18 @@ pub struct UserKeybindings {
|
|||
pub bindings: Vec<UserBinding>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct JsonKeybindingConfig {
|
||||
#[serde(default)]
|
||||
bindings: Vec<JsonKeybindingBlock>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct JsonKeybindingBlock {
|
||||
context: String,
|
||||
bindings: HashMap<String, Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UserBinding {
|
||||
pub chord: String, // e.g. "ctrl+k ctrl+d"
|
||||
|
|
@ -172,10 +187,16 @@ pub struct UserBinding {
|
|||
}
|
||||
|
||||
impl UserKeybindings {
|
||||
pub fn from_json_str(content: &str) -> Self {
|
||||
serde_json::from_str(content)
|
||||
.or_else(|_| Self::from_block_config(content))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn load(config_dir: &Path) -> Self {
|
||||
let path = config_dir.join("keybindings.json");
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
serde_json::from_str(&content).unwrap_or_default()
|
||||
Self::from_json_str(&content)
|
||||
} else {
|
||||
Self::default()
|
||||
}
|
||||
|
|
@ -187,6 +208,23 @@ impl UserKeybindings {
|
|||
std::fs::write(path, json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn from_block_config(content: &str) -> Result<Self, serde_json::Error> {
|
||||
let config: JsonKeybindingConfig = serde_json::from_str(content)?;
|
||||
let bindings = config
|
||||
.bindings
|
||||
.into_iter()
|
||||
.flat_map(|block| {
|
||||
let context = block.context;
|
||||
block.bindings.into_iter().map(move |(chord, action)| UserBinding {
|
||||
chord,
|
||||
action,
|
||||
context: Some(context.clone()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(Self { bindings })
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved keybindings (defaults merged with user overrides)
|
||||
|
|
@ -420,4 +458,28 @@ mod tests {
|
|||
let user = UserKeybindings::default();
|
||||
assert!(user.bindings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_keybindings_supports_ts_block_format() {
|
||||
let user = UserKeybindings::from_json_str(
|
||||
r#"{
|
||||
"bindings": [
|
||||
{
|
||||
"context": "Chat",
|
||||
"bindings": {
|
||||
"ctrl+g": "chat:externalEditor",
|
||||
"space": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
);
|
||||
|
||||
assert_eq!(user.bindings.len(), 2);
|
||||
assert_eq!(user.bindings[0].context.as_deref(), Some("Chat"));
|
||||
assert_eq!(user.bindings[0].chord, "ctrl+g");
|
||||
assert_eq!(user.bindings[0].action.as_deref(), Some("chat:externalEditor"));
|
||||
assert_eq!(user.bindings[1].chord, "space");
|
||||
assert_eq!(user.bindings[1].action, None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -411,6 +411,8 @@ pub mod config {
|
|||
pub max_tokens: Option<u32>,
|
||||
pub permission_mode: PermissionMode,
|
||||
pub theme: Theme,
|
||||
#[serde(default)]
|
||||
pub output_style: Option<String>,
|
||||
pub auto_compact: bool,
|
||||
pub compact_threshold: f32,
|
||||
pub verbose: bool,
|
||||
|
|
@ -424,6 +426,8 @@ pub mod config {
|
|||
pub append_system_prompt: Option<String>,
|
||||
pub disable_claude_mds: bool,
|
||||
pub project_dir: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
pub workspace_paths: Vec<PathBuf>,
|
||||
/// Event hooks: map of event → list of hook commands.
|
||||
#[serde(default)]
|
||||
pub hooks: HashMap<HookEvent, Vec<HookEntry>>,
|
||||
|
|
@ -518,6 +522,14 @@ pub mod config {
|
|||
}
|
||||
}
|
||||
|
||||
/// Resolve the effective output style for system-prompt assembly.
|
||||
pub fn effective_output_style(&self) -> crate::system_prompt::OutputStyle {
|
||||
self.output_style
|
||||
.as_deref()
|
||||
.map(crate::system_prompt::OutputStyle::from_str)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Resolve the API key from the config, then from `ANTHROPIC_API_KEY`.
|
||||
pub fn resolve_api_key(&self) -> Option<String> {
|
||||
self.api_key
|
||||
|
|
@ -633,6 +645,28 @@ pub mod config {
|
|||
tokio::fs::write(&path, content).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Synchronous variant used by pre-session commands.
|
||||
pub fn load_sync() -> anyhow::Result<Self> {
|
||||
let path = Self::global_settings_path();
|
||||
if path.exists() {
|
||||
let content = std::fs::read_to_string(&path)?;
|
||||
Ok(serde_json::from_str(&content).unwrap_or_default())
|
||||
} else {
|
||||
Ok(Self::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronous variant used by pre-session commands.
|
||||
pub fn save_sync(&self) -> anyhow::Result<()> {
|
||||
let path = Self::global_settings_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let content = serde_json::to_string_pretty(self)?;
|
||||
std::fs::write(&path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ pub fn format_memory_manifest(memories: &[MemoryFileMeta]) -> String {
|
|||
|
||||
match &m.description {
|
||||
Some(desc) => format!("- {}{} ({}): {}", tag, m.filename, ts, desc),
|
||||
None => format!("- {}{} ({})", tag, m.filename, ts),
|
||||
None => format!("- {}{}", tag, m.filename),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
|
|
@ -361,8 +361,7 @@ pub fn auto_memory_path(project_root: &Path) -> PathBuf {
|
|||
/// Sanitize an arbitrary string into a directory-name-safe component.
|
||||
/// Matches `sanitizePath` used inside `getAutoMemPath` in `paths.ts`.
|
||||
pub fn sanitize_path_component(s: &str) -> String {
|
||||
let sanitized: String = s
|
||||
.chars()
|
||||
s.chars()
|
||||
.map(|c| {
|
||||
if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
|
||||
c
|
||||
|
|
@ -370,8 +369,7 @@ pub fn sanitize_path_component(s: &str) -> String {
|
|||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
sanitized.trim_matches('_').to_string()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether the auto-memory system is enabled for this session.
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ pub struct QueryConfig {
|
|||
pub max_turns: u32,
|
||||
pub system_prompt: Option<String>,
|
||||
pub append_system_prompt: Option<String>,
|
||||
pub output_style: cc_core::system_prompt::OutputStyle,
|
||||
pub working_directory: Option<String>,
|
||||
pub thinking_budget: Option<u32>,
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
|
@ -71,6 +73,8 @@ impl Default for QueryConfig {
|
|||
max_turns: cc_core::constants::MAX_TURNS_DEFAULT,
|
||||
system_prompt: None,
|
||||
append_system_prompt: None,
|
||||
output_style: cc_core::system_prompt::OutputStyle::Default,
|
||||
working_directory: None,
|
||||
thinking_budget: None,
|
||||
temperature: None,
|
||||
}
|
||||
|
|
@ -82,6 +86,11 @@ impl QueryConfig {
|
|||
Self {
|
||||
model: cfg.effective_model().to_string(),
|
||||
max_tokens: cfg.effective_max_tokens(),
|
||||
output_style: cfg.effective_output_style(),
|
||||
working_directory: cfg
|
||||
.project_dir
|
||||
.as_ref()
|
||||
.map(|p| p.display().to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
|
@ -440,13 +449,12 @@ fn build_system_prompt(config: &QueryConfig) -> SystemPrompt {
|
|||
custom_system_prompt: config.system_prompt.clone(),
|
||||
append_system_prompt: config.append_system_prompt.clone(),
|
||||
// All other fields use sensible defaults:
|
||||
// - prefix: auto-detect from env
|
||||
// - output_style: Default (no suffix)
|
||||
// - working_directory: None (callers inject via append if needed)
|
||||
// - memory_content: empty (callers inject via append if needed)
|
||||
// - prefix: auto-detect from env
|
||||
// - memory_content: empty (callers inject via append if needed)
|
||||
// - replace_system_prompt: false (additive mode)
|
||||
// - coordinator_mode: false
|
||||
output_style: OutputStyle::Default,
|
||||
// - coordinator_mode: false
|
||||
output_style: config.output_style,
|
||||
working_directory: config.working_directory.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -549,8 +549,9 @@ mod tests {
|
|||
fn expand_prompt_suffix_empty() {
|
||||
let skill = find_bundled_skill("stuck").unwrap();
|
||||
let expanded = expand_prompt(skill, "");
|
||||
// $ARGUMENTS_SUFFIX should be "" (not ": ")
|
||||
assert!(!expanded.contains(": "));
|
||||
// $ARGUMENTS_SUFFIX should expand to "" so "stuck" is not followed by ": "
|
||||
assert!(!expanded.contains("stuck: "));
|
||||
assert!(!expanded.contains("$ARGUMENTS_SUFFIX"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue