feat: RFC 0052 blue install command - implemented
Adds unified installation management for Claude Code integration: blue install - Install hooks, skills, MCP server blue uninstall - Remove Blue from Claude Code blue doctor - Check installation health Components managed: - Hooks: session-start.sh (PATH), guard-write.sh (guard) - Skills: Symlinks to ~/.claude/skills/ - MCP Server: Configuration in ~/.claude.json Features: - --hooks-only, --skills-only, --mcp-only flags - --force to overwrite existing files - Managed files tagged with "# Managed by: blue install" - Idempotent - safe to run repeatedly Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2216fc41d9
commit
f5d3621495
6 changed files with 519 additions and 22 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# RFC 0052: Blue Install Command
|
||||
|
||||
**Status**: Draft
|
||||
**Status**: Implemented
|
||||
**Created**: 2026-02-01
|
||||
**Updated**: 2026-02-01
|
||||
**Author**: Claude Opus 4.5
|
||||
|
|
@ -1,14 +1,11 @@
|
|||
#!/bin/bash
|
||||
# PreToolUse hook for Write/Edit/MultiEdit - enforces RFC 0038 worktree protection
|
||||
# Managed by: blue install
|
||||
# Blue PreToolUse hook - enforces RFC 0038 worktree protection
|
||||
|
||||
# Extract file_path directly with jq (recommended pattern - avoids cat hanging)
|
||||
FILE_PATH=$(jq -r '.tool_input.file_path // empty')
|
||||
|
||||
# If no file_path, allow (shouldn't happen for Write/Edit)
|
||||
if [ -z "$FILE_PATH" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Call blue guard with the extracted path
|
||||
# RFC 0051: PATH is set by SessionStart hook
|
||||
blue guard --path="$FILE_PATH"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#!/bin/bash
|
||||
# SessionStart hook - sets up PATH for blue CLI
|
||||
# RFC 0051: Portable hook binary resolution
|
||||
# Managed by: blue install
|
||||
# Blue SessionStart hook - sets up PATH for Claude Code
|
||||
|
||||
if [ -n "$CLAUDE_ENV_FILE" ] && [ -n "$CLAUDE_PROJECT_DIR" ]; then
|
||||
echo "export PATH=\"\$CLAUDE_PROJECT_DIR/target/release:\$PATH\"" >> "$CLAUDE_ENV_FILE"
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"command": ".claude/hooks/guard-write.sh",
|
||||
"type": "command"
|
||||
}
|
||||
],
|
||||
"matcher": "Write|Edit|MultiEdit"
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": ".claude/hooks/session-start.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": ".claude/hooks/guard-write.sh"
|
||||
"command": ".claude/hooks/session-start.sh",
|
||||
"type": "command"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -25,3 +25,5 @@ serde.workspace = true
|
|||
dirs.workspace = true
|
||||
tempfile.workspace = true
|
||||
serde_yaml.workspace = true
|
||||
serde_json.workspace = true
|
||||
which = "7.0"
|
||||
|
|
|
|||
|
|
@ -315,6 +315,31 @@ enum Commands {
|
|||
/// Session end (silent, used by hooks)
|
||||
#[command(name = "session-end")]
|
||||
SessionEnd,
|
||||
|
||||
/// Install Blue for Claude Code (RFC 0052)
|
||||
Install {
|
||||
/// Only install hooks
|
||||
#[arg(long)]
|
||||
hooks_only: bool,
|
||||
|
||||
/// Only install skills
|
||||
#[arg(long)]
|
||||
skills_only: bool,
|
||||
|
||||
/// Only configure MCP server
|
||||
#[arg(long)]
|
||||
mcp_only: bool,
|
||||
|
||||
/// Overwrite existing files
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
|
||||
/// Remove Blue from Claude Code (RFC 0052)
|
||||
Uninstall,
|
||||
|
||||
/// Check Blue installation health (RFC 0052)
|
||||
Doctor,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
|
|
@ -750,6 +775,15 @@ async fn tokio_main() -> Result<()> {
|
|||
let _ = std::fs::remove_file(&session_file);
|
||||
}
|
||||
}
|
||||
Some(Commands::Install { hooks_only, skills_only, mcp_only, force }) => {
|
||||
handle_install_command(hooks_only, skills_only, mcp_only, force).await?;
|
||||
}
|
||||
Some(Commands::Uninstall) => {
|
||||
handle_uninstall_command().await?;
|
||||
}
|
||||
Some(Commands::Doctor) => {
|
||||
handle_doctor_command().await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
@ -2690,3 +2724,467 @@ fn log_guard_bypass(path: &str, tool: Option<&str>, reason: &str) {
|
|||
let _ = file.write_all(entry.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RFC 0052: Blue Install Command
|
||||
// ============================================================================
|
||||
|
||||
const SESSION_START_HOOK: &str = r#"#!/bin/bash
|
||||
# Managed by: blue install
|
||||
# Blue SessionStart hook - sets up PATH for Claude Code
|
||||
|
||||
if [ -n "$CLAUDE_ENV_FILE" ] && [ -n "$CLAUDE_PROJECT_DIR" ]; then
|
||||
echo "export PATH=\"\$CLAUDE_PROJECT_DIR/target/release:\$PATH\"" >> "$CLAUDE_ENV_FILE"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
"#;
|
||||
|
||||
const GUARD_WRITE_HOOK: &str = r#"#!/bin/bash
|
||||
# Managed by: blue install
|
||||
# Blue PreToolUse hook - enforces RFC 0038 worktree protection
|
||||
|
||||
FILE_PATH=$(jq -r '.tool_input.file_path // empty')
|
||||
|
||||
if [ -z "$FILE_PATH" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
blue guard --path="$FILE_PATH"
|
||||
"#;
|
||||
|
||||
async fn handle_install_command(hooks_only: bool, skills_only: bool, mcp_only: bool, force: bool) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let cwd = std::env::current_dir()?;
|
||||
let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?;
|
||||
|
||||
println!("Installing Blue for Claude Code...\n");
|
||||
|
||||
let install_all = !hooks_only && !skills_only && !mcp_only;
|
||||
|
||||
// Install hooks
|
||||
if install_all || hooks_only {
|
||||
println!("Hooks:");
|
||||
install_hooks(&cwd, force)?;
|
||||
}
|
||||
|
||||
// Install skills
|
||||
if install_all || skills_only {
|
||||
println!("\nSkills:");
|
||||
install_skills(&cwd, &home)?;
|
||||
}
|
||||
|
||||
// Install MCP server
|
||||
if install_all || mcp_only {
|
||||
println!("\nMCP Server:");
|
||||
install_mcp_server(&cwd, &home)?;
|
||||
}
|
||||
|
||||
println!("\nBlue installed. Restart Claude Code to activate.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_hooks(project_dir: &std::path::Path, force: bool) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let hooks_dir = project_dir.join(".claude").join("hooks");
|
||||
std::fs::create_dir_all(&hooks_dir)?;
|
||||
|
||||
// Write session-start.sh
|
||||
let session_start_path = hooks_dir.join("session-start.sh");
|
||||
if !session_start_path.exists() || force {
|
||||
std::fs::write(&session_start_path, SESSION_START_HOOK)?;
|
||||
std::fs::set_permissions(&session_start_path, std::fs::Permissions::from_mode(0o755))?;
|
||||
println!(" ✓ .claude/hooks/session-start.sh");
|
||||
} else {
|
||||
println!(" - .claude/hooks/session-start.sh (exists, use --force to overwrite)");
|
||||
}
|
||||
|
||||
// Write guard-write.sh
|
||||
let guard_write_path = hooks_dir.join("guard-write.sh");
|
||||
if !guard_write_path.exists() || force {
|
||||
std::fs::write(&guard_write_path, GUARD_WRITE_HOOK)?;
|
||||
std::fs::set_permissions(&guard_write_path, std::fs::Permissions::from_mode(0o755))?;
|
||||
println!(" ✓ .claude/hooks/guard-write.sh");
|
||||
} else {
|
||||
println!(" - .claude/hooks/guard-write.sh (exists, use --force to overwrite)");
|
||||
}
|
||||
|
||||
// Update settings.json
|
||||
let settings_path = project_dir.join(".claude").join("settings.json");
|
||||
let settings = merge_hook_settings(&settings_path)?;
|
||||
std::fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
|
||||
println!(" ✓ .claude/settings.json (merged)");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn merge_hook_settings(settings_path: &std::path::Path) -> Result<serde_json::Value> {
|
||||
use serde_json::json;
|
||||
|
||||
let mut settings: serde_json::Value = if settings_path.exists() {
|
||||
let content = std::fs::read_to_string(settings_path)?;
|
||||
serde_json::from_str(&content).unwrap_or_else(|_| json!({}))
|
||||
} else {
|
||||
json!({})
|
||||
};
|
||||
|
||||
// Ensure hooks object exists
|
||||
if settings.get("hooks").is_none() {
|
||||
settings["hooks"] = json!({});
|
||||
}
|
||||
|
||||
// Add SessionStart hook
|
||||
settings["hooks"]["SessionStart"] = json!([
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": ".claude/hooks/session-start.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]);
|
||||
|
||||
// Add PreToolUse hook
|
||||
settings["hooks"]["PreToolUse"] = json!([
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": ".claude/hooks/guard-write.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]);
|
||||
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
fn install_skills(project_dir: &std::path::Path, home: &std::path::Path) -> Result<()> {
|
||||
let skills_dir = project_dir.join("skills");
|
||||
let target_dir = home.join(".claude").join("skills");
|
||||
|
||||
std::fs::create_dir_all(&target_dir)?;
|
||||
|
||||
if !skills_dir.exists() {
|
||||
println!(" - No skills directory found");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for entry in std::fs::read_dir(&skills_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
let skill_name = entry.file_name();
|
||||
let link_path = target_dir.join(&skill_name);
|
||||
|
||||
// Remove existing symlink if present
|
||||
if link_path.exists() || link_path.symlink_metadata().is_ok() {
|
||||
std::fs::remove_file(&link_path).ok();
|
||||
}
|
||||
|
||||
// Create symlink
|
||||
std::os::unix::fs::symlink(&path, &link_path)?;
|
||||
println!(" ✓ ~/.claude/skills/{} -> {}", skill_name.to_string_lossy(), path.display());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_mcp_server(project_dir: &std::path::Path, home: &std::path::Path) -> Result<()> {
|
||||
use serde_json::json;
|
||||
|
||||
let config_path = home.join(".claude.json");
|
||||
|
||||
let mut config: serde_json::Value = if config_path.exists() {
|
||||
let content = std::fs::read_to_string(&config_path)?;
|
||||
serde_json::from_str(&content).unwrap_or_else(|_| json!({}))
|
||||
} else {
|
||||
json!({})
|
||||
};
|
||||
|
||||
// Ensure mcpServers object exists
|
||||
if config.get("mcpServers").is_none() {
|
||||
config["mcpServers"] = json!({});
|
||||
}
|
||||
|
||||
// Add/update blue MCP server
|
||||
let binary_path = project_dir.join("target").join("release").join("blue");
|
||||
config["mcpServers"]["blue"] = json!({
|
||||
"command": binary_path.to_string_lossy(),
|
||||
"args": ["mcp"]
|
||||
});
|
||||
|
||||
std::fs::write(&config_path, serde_json::to_string_pretty(&config)?)?;
|
||||
println!(" ✓ ~/.claude.json (blue server configured)");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_uninstall_command() -> Result<()> {
|
||||
let cwd = std::env::current_dir()?;
|
||||
let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?;
|
||||
|
||||
println!("Removing Blue from Claude Code...\n");
|
||||
|
||||
// Remove hooks
|
||||
println!("Hooks:");
|
||||
uninstall_hooks(&cwd)?;
|
||||
|
||||
// Remove skills
|
||||
println!("\nSkills:");
|
||||
uninstall_skills(&cwd, &home)?;
|
||||
|
||||
// Remove MCP server
|
||||
println!("\nMCP Server:");
|
||||
uninstall_mcp_server(&home)?;
|
||||
|
||||
println!("\nBlue uninstalled.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn uninstall_hooks(project_dir: &std::path::Path) -> Result<()> {
|
||||
let hooks_dir = project_dir.join(".claude").join("hooks");
|
||||
|
||||
// Remove hook scripts
|
||||
let session_start = hooks_dir.join("session-start.sh");
|
||||
if session_start.exists() {
|
||||
// Check if managed by blue
|
||||
if let Ok(content) = std::fs::read_to_string(&session_start) {
|
||||
if content.contains("Managed by: blue install") {
|
||||
std::fs::remove_file(&session_start)?;
|
||||
println!(" ✓ Removed .claude/hooks/session-start.sh");
|
||||
} else {
|
||||
println!(" - .claude/hooks/session-start.sh (not managed by blue, skipped)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let guard_write = hooks_dir.join("guard-write.sh");
|
||||
if guard_write.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&guard_write) {
|
||||
if content.contains("Managed by: blue install") {
|
||||
std::fs::remove_file(&guard_write)?;
|
||||
println!(" ✓ Removed .claude/hooks/guard-write.sh");
|
||||
} else {
|
||||
println!(" - .claude/hooks/guard-write.sh (not managed by blue, skipped)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean settings.json
|
||||
let settings_path = project_dir.join(".claude").join("settings.json");
|
||||
if settings_path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&settings_path) {
|
||||
if let Ok(mut settings) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
if let Some(hooks) = settings.get_mut("hooks") {
|
||||
if let Some(obj) = hooks.as_object_mut() {
|
||||
obj.remove("SessionStart");
|
||||
obj.remove("PreToolUse");
|
||||
}
|
||||
}
|
||||
std::fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
|
||||
println!(" ✓ Cleaned .claude/settings.json");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn uninstall_skills(project_dir: &std::path::Path, home: &std::path::Path) -> Result<()> {
|
||||
let skills_dir = project_dir.join("skills");
|
||||
let target_dir = home.join(".claude").join("skills");
|
||||
|
||||
if !skills_dir.exists() {
|
||||
println!(" - No skills to remove");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for entry in std::fs::read_dir(&skills_dir)? {
|
||||
let entry = entry?;
|
||||
if entry.path().is_dir() {
|
||||
let skill_name = entry.file_name();
|
||||
let link_path = target_dir.join(&skill_name);
|
||||
|
||||
if link_path.symlink_metadata().is_ok() {
|
||||
std::fs::remove_file(&link_path)?;
|
||||
println!(" ✓ Removed ~/.claude/skills/{}", skill_name.to_string_lossy());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn uninstall_mcp_server(home: &std::path::Path) -> Result<()> {
|
||||
let config_path = home.join(".claude.json");
|
||||
|
||||
if config_path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&config_path) {
|
||||
if let Ok(mut config) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
if let Some(servers) = config.get_mut("mcpServers") {
|
||||
if let Some(obj) = servers.as_object_mut() {
|
||||
obj.remove("blue");
|
||||
}
|
||||
}
|
||||
std::fs::write(&config_path, serde_json::to_string_pretty(&config)?)?;
|
||||
println!(" ✓ Removed blue from ~/.claude.json");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_doctor_command() -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let cwd = std::env::current_dir()?;
|
||||
let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?;
|
||||
|
||||
println!("Blue Installation Health Check\n");
|
||||
|
||||
let mut issues = 0;
|
||||
|
||||
// Check binary
|
||||
println!("Binary:");
|
||||
if let Ok(path) = which::which("blue") {
|
||||
println!(" ✓ blue found at {}", path.display());
|
||||
} else {
|
||||
println!(" ✗ blue not found in PATH");
|
||||
issues += 1;
|
||||
}
|
||||
|
||||
// Check hooks
|
||||
println!("\nHooks:");
|
||||
let hooks_dir = cwd.join(".claude").join("hooks");
|
||||
|
||||
let session_start = hooks_dir.join("session-start.sh");
|
||||
if session_start.exists() {
|
||||
let is_executable = std::fs::metadata(&session_start)
|
||||
.map(|m| m.permissions().mode() & 0o111 != 0)
|
||||
.unwrap_or(false);
|
||||
if is_executable {
|
||||
println!(" ✓ session-start.sh (installed, executable)");
|
||||
} else {
|
||||
println!(" ✗ session-start.sh (not executable)");
|
||||
issues += 1;
|
||||
}
|
||||
} else {
|
||||
println!(" ✗ session-start.sh missing");
|
||||
issues += 1;
|
||||
}
|
||||
|
||||
let guard_write = hooks_dir.join("guard-write.sh");
|
||||
if guard_write.exists() {
|
||||
let is_executable = std::fs::metadata(&guard_write)
|
||||
.map(|m| m.permissions().mode() & 0o111 != 0)
|
||||
.unwrap_or(false);
|
||||
if is_executable {
|
||||
println!(" ✓ guard-write.sh (installed, executable)");
|
||||
} else {
|
||||
println!(" ✗ guard-write.sh (not executable)");
|
||||
issues += 1;
|
||||
}
|
||||
} else {
|
||||
println!(" ✗ guard-write.sh missing");
|
||||
issues += 1;
|
||||
}
|
||||
|
||||
let settings_path = cwd.join(".claude").join("settings.json");
|
||||
if settings_path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&settings_path) {
|
||||
if content.contains("SessionStart") && content.contains("PreToolUse") {
|
||||
println!(" ✓ settings.json configured");
|
||||
} else {
|
||||
println!(" ✗ settings.json missing hook configuration");
|
||||
issues += 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!(" ✗ settings.json missing");
|
||||
issues += 1;
|
||||
}
|
||||
|
||||
// Check skills
|
||||
println!("\nSkills:");
|
||||
let skills_dir = cwd.join("skills");
|
||||
let target_dir = home.join(".claude").join("skills");
|
||||
|
||||
if skills_dir.exists() {
|
||||
for entry in std::fs::read_dir(&skills_dir)? {
|
||||
let entry = entry?;
|
||||
if entry.path().is_dir() {
|
||||
let skill_name = entry.file_name();
|
||||
let link_path = target_dir.join(&skill_name);
|
||||
|
||||
if link_path.symlink_metadata().is_ok() {
|
||||
// Check if symlink points to correct target
|
||||
if let Ok(target) = std::fs::read_link(&link_path) {
|
||||
if target == entry.path() {
|
||||
println!(" ✓ {} (symlink valid)", skill_name.to_string_lossy());
|
||||
} else {
|
||||
println!(" ✗ {} (symlink points to wrong target)", skill_name.to_string_lossy());
|
||||
issues += 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!(" ✗ {} (symlink missing)", skill_name.to_string_lossy());
|
||||
issues += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!(" - No skills directory");
|
||||
}
|
||||
|
||||
// Check MCP server
|
||||
println!("\nMCP Server:");
|
||||
let config_path = home.join(".claude.json");
|
||||
if config_path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&config_path) {
|
||||
if let Ok(config) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
if let Some(servers) = config.get("mcpServers") {
|
||||
if servers.get("blue").is_some() {
|
||||
println!(" ✓ blue configured in ~/.claude.json");
|
||||
|
||||
// Check if binary path is correct
|
||||
if let Some(cmd) = servers["blue"].get("command").and_then(|c| c.as_str()) {
|
||||
if std::path::Path::new(cmd).exists() {
|
||||
println!(" ✓ Binary path valid");
|
||||
} else {
|
||||
println!(" ✗ Binary path invalid: {}", cmd);
|
||||
issues += 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!(" ✗ blue not configured in ~/.claude.json");
|
||||
issues += 1;
|
||||
}
|
||||
} else {
|
||||
println!(" ✗ No mcpServers in ~/.claude.json");
|
||||
issues += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!(" ✗ ~/.claude.json not found");
|
||||
issues += 1;
|
||||
}
|
||||
|
||||
// Summary
|
||||
println!();
|
||||
if issues == 0 {
|
||||
println!("All checks passed.");
|
||||
} else {
|
||||
println!("{} issue(s) found. Run `blue install` to fix.", issues);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue