Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ target/

# Task files
# tasks.json
# tasks/
# tasks/

# Local config overrides (user-specific, not committed)
.ccsync.local

# Devenv
.devenv*
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,36 @@ Press **q** or **Ctrl+C** to cancel anytime.
- **Skills** in `~/.claude/skills/` ↔ `./.claude/skills/`
- **Commands** in `~/.claude/commands/` ↔ `./.claude/commands/`

## Configuration Files

Create a `.ccsync` file in your project to customize sync behavior:

```toml
# Ignore certain files (gitignore-style patterns)
ignore = ["**/test-*.md", "**/*.backup"]

# Only sync specific patterns
include = ["agents/**", "skills/**"]

# Set default conflict strategy
conflict_strategy = "newer"
```

**Config file locations** (in order of precedence):
1. `--config <path>` - Custom config file via flag
2. `.ccsync.local` - Project-local (gitignored, for personal settings)
3. `.ccsync` - Project config (committed to repo)
4. `~/.config/ccsync/config.toml` - Global config

**CLI flags always override config files.**

### Skip config files

```bash
# Ignore all config files, use only CLI flags
ccsync to-local --no-config
```

## Examples

### Check Before Syncing
Expand Down
4 changes: 2 additions & 2 deletions crates/ccsync-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ pub struct Cli {
pub local_path: Option<PathBuf>,

/// Use specific config file
#[arg(long, global = true, value_name = "PATH")]
#[arg(long, global = true, value_name = "PATH", conflicts_with = "no_config")]
pub config: Option<PathBuf>,

/// Ignore all config files
#[arg(long, global = true)]
#[arg(long, global = true, conflicts_with = "config")]
pub no_config: bool,

/// Preserve symlinks instead of following them
Expand Down
70 changes: 70 additions & 0 deletions crates/ccsync-cli/src/commands/common.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//! Common types and utilities for command execution

use ccsync::config::{Config, ConfigManager};

/// Execution options for sync commands
#[allow(clippy::struct_excessive_bools)]
pub struct SyncOptions<'a> {
/// Enable verbose output
pub verbose: bool,
/// Preview changes without applying (dry-run)
pub dry_run: bool,
/// Auto-approve all operations without prompting
pub yes_all: bool,
/// Path to custom config file
pub config_path: Option<&'a std::path::Path>,
/// Skip loading all config files
pub no_config: bool,
}

impl<'a> SyncOptions<'a> {
/// Create new sync options
#[must_use]
#[allow(clippy::fn_params_excessive_bools)]
pub const fn new(
verbose: bool,
dry_run: bool,
yes_all: bool,
config_path: Option<&'a std::path::Path>,
no_config: bool,
) -> Self {
Self {
verbose,
dry_run,
yes_all,
config_path,
no_config,
}
}

/// Load configuration from files or use defaults
///
/// # Errors
///
/// Returns an error if config file is explicitly specified but cannot be loaded.
pub fn load_config(&self) -> anyhow::Result<Config> {
if self.no_config {
if self.verbose {
println!("Skipping config file loading (--no-config)");
}
return Ok(Config::default());
}

match ConfigManager::load(self.config_path) {
Ok(config) => Ok(config),
Err(e) => {
// If user explicitly specified a config file, fail hard
if self.config_path.is_some() {
anyhow::bail!("Failed to load config file: {e}");
}

// Otherwise, warn and use defaults
if self.verbose {
eprintln!("Warning: Failed to load config files: {e}");
eprintln!("Using default configuration");
}
Ok(Config::default())
}
}
}
}
2 changes: 2 additions & 0 deletions crates/ccsync-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
pub mod common;
pub mod config;
pub mod diff;
pub mod status;
pub mod to_global;
pub mod to_local;

pub use common::SyncOptions;
pub use config::Config;
pub use diff::Diff;
pub use status::Status;
Expand Down
39 changes: 20 additions & 19 deletions crates/ccsync-cli/src/commands/to_global.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use ccsync::config::{Config, SyncDirection};
use ccsync::sync::{SyncEngine, SyncReporter};

use crate::cli::{ConfigType, ConflictMode};
use crate::commands::SyncOptions;
use crate::interactive::InteractivePrompter;

pub struct ToGlobal;
Expand All @@ -14,35 +15,36 @@ impl ToGlobal {
pub fn execute(
types: &[ConfigType],
conflict: &ConflictMode,
verbose: bool,
dry_run: bool,
yes_all: bool,
options: &SyncOptions,
) -> anyhow::Result<()> {
if verbose {
if options.verbose {
println!("Executing to-global command");
println!("Types: {types:?}");
println!("Conflict mode: {conflict:?}");
println!("Dry run: {dry_run}");
println!("Dry run: {}", options.dry_run);
}

// Determine paths
let global_path = Self::get_global_path()?;
let local_path = Self::get_local_path()?;

if verbose {
if options.verbose {
println!("Local path: {}", local_path.display());
println!("Global path: {}", global_path.display());
}

// Build configuration
let config = Self::build_config(types, conflict, dry_run, verbose);
// Load configuration from files
let mut config = options.load_config()?;

// Merge CLI flags into loaded config (CLI takes precedence)
Self::merge_cli_flags(&mut config, types, conflict, options.dry_run);

// Initialize sync engine
let engine = SyncEngine::new(config, SyncDirection::ToGlobal)
.context("Failed to initialize sync engine")?;

// Execute sync with optional interactive approval (source is local, destination is global)
let result = if yes_all || dry_run {
let result = if options.yes_all || options.dry_run {
// Non-interactive: auto-approve all or just preview
engine
.sync(&local_path, &global_path)
Expand Down Expand Up @@ -88,28 +90,27 @@ impl ToGlobal {
Ok(current_dir.join(".claude"))
}

fn build_config(
fn merge_cli_flags(
config: &mut Config,
types: &[ConfigType],
conflict: &ConflictMode,
dry_run: bool,
_verbose: bool,
) -> Config {
let mut config = Config::default();
) {
// CLI flags override config file settings

// Set dry run flag
// Set dry run flag (override config)
if dry_run {
config.dry_run = Some(true);
}

// Set conflict strategy
// Set conflict strategy (override config)
config.conflict_strategy = Some(Self::convert_conflict_mode(conflict));

// Handle type filters
// Handle type filters - ADD to config patterns (additive, not replace)
if !types.is_empty() {
config.include = Self::build_type_patterns(types);
let cli_patterns = Self::build_type_patterns(types);
config.include.extend(cli_patterns);
}

config
}

const fn convert_conflict_mode(mode: &ConflictMode) -> ConflictStrategy {
Expand Down
39 changes: 20 additions & 19 deletions crates/ccsync-cli/src/commands/to_local.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use ccsync::config::{Config, SyncDirection};
use ccsync::sync::{SyncEngine, SyncReporter};

use crate::cli::{ConfigType, ConflictMode};
use crate::commands::SyncOptions;
use crate::interactive::InteractivePrompter;

pub struct ToLocal;
Expand All @@ -14,35 +15,36 @@ impl ToLocal {
pub fn execute(
types: &[ConfigType],
conflict: &ConflictMode,
verbose: bool,
dry_run: bool,
yes_all: bool,
options: &SyncOptions,
) -> anyhow::Result<()> {
if verbose {
if options.verbose {
println!("Executing to-local command");
println!("Types: {types:?}");
println!("Conflict mode: {conflict:?}");
println!("Dry run: {dry_run}");
println!("Dry run: {}", options.dry_run);
}

// Determine paths
let global_path = Self::get_global_path()?;
let local_path = Self::get_local_path()?;

if verbose {
if options.verbose {
println!("Global path: {}", global_path.display());
println!("Local path: {}", local_path.display());
}

// Build configuration
let config = Self::build_config(types, conflict, dry_run, verbose);
// Load configuration from files
let mut config = options.load_config()?;

// Merge CLI flags into loaded config (CLI takes precedence)
Self::merge_cli_flags(&mut config, types, conflict, options.dry_run);

// Initialize sync engine
let engine = SyncEngine::new(config, SyncDirection::ToLocal)
.context("Failed to initialize sync engine")?;

// Execute sync with optional interactive approval
let result = if yes_all || dry_run {
let result = if options.yes_all || options.dry_run {
// Non-interactive: auto-approve all or just preview
engine
.sync(&global_path, &local_path)
Expand Down Expand Up @@ -88,28 +90,27 @@ impl ToLocal {
Ok(current_dir.join(".claude"))
}

fn build_config(
fn merge_cli_flags(
config: &mut Config,
types: &[ConfigType],
conflict: &ConflictMode,
dry_run: bool,
_verbose: bool,
) -> Config {
let mut config = Config::default();
) {
// CLI flags override config file settings

// Set dry run flag
// Set dry run flag (override config)
if dry_run {
config.dry_run = Some(true);
}

// Set conflict strategy
// Set conflict strategy (override config)
config.conflict_strategy = Some(Self::convert_conflict_mode(conflict));

// Handle type filters
// Handle type filters - ADD to config patterns (additive, not replace)
if !types.is_empty() {
config.include = Self::build_type_patterns(types);
let cli_patterns = Self::build_type_patterns(types);
config.include.extend(cli_patterns);
}

config
}

const fn convert_conflict_mode(mode: &ConflictMode) -> ConflictStrategy {
Expand Down
14 changes: 12 additions & 2 deletions crates/ccsync-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod interactive;
use anyhow::Context;
use clap::Parser;
use cli::{Cli, Commands};
use commands::SyncOptions;

fn main() -> anyhow::Result<()> {
// Set up Ctrl+C handler for graceful interruption
Expand All @@ -22,13 +23,22 @@ fn main() -> anyhow::Result<()> {
println!("Yes all: {}", cli.yes_all);
}

// Create sync options from CLI flags
let options = SyncOptions::new(
cli.verbose,
cli.dry_run,
cli.yes_all,
cli.config.as_deref(),
cli.no_config,
);

match &cli.command {
Commands::ToLocal { types, conflict } => {
commands::ToLocal::execute(types, conflict, cli.verbose, cli.dry_run, cli.yes_all)
commands::ToLocal::execute(types, conflict, &options)
.context("Failed to execute to-local command")?;
}
Commands::ToGlobal { types, conflict } => {
commands::ToGlobal::execute(types, conflict, cli.verbose, cli.dry_run, cli.yes_all)
commands::ToGlobal::execute(types, conflict, &options)
.context("Failed to execute to-global command")?;
}
Commands::Status { types } => {
Expand Down
Loading