Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7706f9c
feat(sync): registry + engine/dispatcher skeletons (Phase 1)
mswdev May 2, 2026
d4dab31
feat(sync): backup primitives (Phase 2)
mswdev May 2, 2026
b2f538d
feat(sync): structured strategies — mcp/settings/prefs (Phase 3)
mswdev May 2, 2026
c3e3223
feat(sync): files-based strategies (Phase 4)
mswdev May 2, 2026
2b346ad
feat(sync): special strategies — statusline + hooks (Phase 5)
mswdev May 2, 2026
54aa19d
feat(sync): engine core (Phase 6)
mswdev May 2, 2026
086d52d
feat(sync): preview UX — summary table + drill-down picker (Phase 7)
mswdev May 2, 2026
4d46b5a
feat(sync): interactive wizard — gum pickers (Phase 8)
mswdev May 2, 2026
ea61ee6
feat(sync): wire dispatcher end-to-end + remove old monolith (Phase 9)
mswdev May 2, 2026
765d824
refactor(account): rename sync-hooks → redeploy-hooks (Phase 10)
mswdev May 2, 2026
5c641ed
feat(setup): offer initial sync after adding 2nd-or-later account (Ph…
mswdev May 2, 2026
f1c5b09
docs(sync): docs sweep + tab completion bump (Phase 12)
mswdev May 2, 2026
7df8a43
fix(sync): code-review feedback (PR #37)
mswdev May 2, 2026
0c3724f
fix(sync): second-round code-review feedback (PR #37)
mswdev May 3, 2026
2c296d1
fix(sync): third-round code-review feedback (PR #37)
mswdev May 3, 2026
56f2837
fix(sync): fourth-round code-review bug fixes (PR #37)
mswdev May 3, 2026
8adf336
refactor(sync): /simplify pass — extract _shared.zsh, dedup helpers (…
mswdev May 3, 2026
ea2427c
fix(sync): fifth-round code-review bug fixes (PR #37)
mswdev May 3, 2026
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
17 changes: 13 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased] — CLI + onboarding overhaul

### Sync system overhaul

- **New:** `ckipper account sync` is fully interactive by default. Run with no args to pick source, targets, and types via gum pickers; pass positional args to skip the relevant pickers.
- **New:** 10 syncable types covering every shareable Claude Code config category: MCP servers, settings (top-level + nested keys), CLAUDE.md, agents, commands, output-styles, skills, statusline (with internal/external script detection), user-written hooks (filtered against the install allowlist), and account preferences.
- **New:** Named bundles `all`, `customizations`, `claude-config`, `preferences` for `--include` / `--exclude`.
- **New:** Multi-destination support — sync from one account to many in a single invocation.
- **New:** Summary table preview with `git status`-style status badges (`[+]` / `[~]`) and on-demand drill-down for any per-item diff.
- **New:** Timestamped backups before any destructive write — `<dst>/.ckipper-sync-backups/<ts>-from-<source>/` — with manifest-driven restore via `ckipper account sync undo <account> [--pick | --list]`.
- **New:** Hard refusal when any Claude CLI process is running (override with `--force`). macOS does not expose other processes' `CLAUDE_CONFIG_DIR` env var to non-privileged callers, so the refusal cannot be filtered down to "Claude on this destination dir specifically" — it is conservative by design.
- **New:** Setup wizard offers initial sync after adding a 2nd-or-later account.
- **Renamed:** `ckipper account sync-hooks` → `ckipper account redeploy-hooks`. The new name reflects that it deploys the ckipper-managed safety hooks from the install dir to every account; it is NOT peer-to-peer sync.
- **Removed:** Old flag surface (`--mcp [names]`, `--settings <keys>`, `--all`). The new `--include` / `--exclude` model with bundles supersedes these.

### Added
- `ckipper setup` — interactive wizard for configuring Ckipper. Re-runnable.
- `ckipper config get / set / unset / list / edit` — view and modify settings.
Expand All @@ -27,10 +40,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Removed
- `ckipper account repair-plugins` (folded into `ckipper doctor --fix`).
- `ckipper account sync-hooks` from public help (still callable; auto-runs after install/setup).

### Notes
- `lib/account/sync.zsh` is intentionally untouched — full sync overhaul ships in a separate future PR.

## [0.2.0] — 2026-04-30 — Breaking changes: merge `w` into `ckipper`

Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ lint-merge-guards:
@! grep -rE '\b_ckipper_setup_' lib/account/ lib/worktree/ lib/config/ lib/run/ lib/core/ 2>/dev/null || (echo "lint-merge-guards: setup-namespace reference outside lib/setup/" >&2 && exit 1)
@! grep -rE '\b_ckipper_run_' lib/account/ lib/worktree/ lib/setup/ lib/config/ lib/core/ 2>/dev/null || (echo "lint-merge-guards: run-namespace reference outside lib/run/" >&2 && exit 1)
@! grep -rE '\b_ckipper_launcher_' lib/account/ lib/worktree/ lib/setup/ lib/config/ lib/run/ lib/core/ 2>/dev/null || (echo "lint-merge-guards: launcher-namespace reference outside lib/launcher/" >&2 && exit 1)
@! grep -rE '^_core_[a-z_]+\(\)' lib/account/ lib/worktree/ lib/config/ lib/setup/ lib/run/ lib/launcher/ --include='*.zsh' 2>/dev/null || (echo "lint-merge-guards: _core_* function defined outside lib/core/ (see .claude/rules/shell-conventions.md — _core_* is reserved for lib/core/)" >&2 && exit 1)

install:
./install.sh
73 changes: 72 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,77 @@ Two terminals running the **same** account simultaneously will hit a known OAuth

If you want concurrent runs of the *same* account, register it twice under two names (`personal-a`, `personal-b`) — though this means re-`/login` for each.

## Sync state between accounts

`ckipper account sync` copies state between registered accounts — MCP servers, settings, agents, commands, skills, user hooks, etc. — interactively by default, with one source and one or more destinations.

### Syncable types

| Type | What it covers |
|---|---|
| `mcp` | `.claude.json` `.mcpServers` (per server) |
| `settings` | `settings.json` top-level + nested keys (excludes `.hooks`) |
| `claude-md` | `CLAUDE.md` (user memory) |
| `agents` | `agents/*.md` |
| `commands` | `commands/*.md` |
| `output-styles` | `output_styles/*.md` |
| `skills` | `skills/<name>/` (per-directory; symlinks preserved) |
| `statusline` | `settings.json` `.statusLine` + referenced script (if internal to the account dir) |
| `hooks` | User-written hooks under `<account>/hooks/` (filtered against the install allowlist) + paired `settings.json` `.hooks` entries |
| `prefs` | Account preferences in `accounts.json` (`always_docker`, `always_firewall`, `ssh_forward`) |

Plugins are not a separate type — sync `enabledPlugins` + `extraKnownMarketplaces` (both in the `settings` type) and Claude Code re-fetches the plugins on the destination's next launch.

### Common commands

```sh
# Full interactive wizard — picks source, targets, and types
ckipper account sync

# One-shot single type
ckipper account sync personal work --include mcp

# Bundle: every user-customization (no prefs)
ckipper account sync personal work --include customizations

# Multi-destination
ckipper account sync personal work client1 client2 --include all

# Dry run (summary only, no writes)
ckipper account sync personal work --include all --dry-run

# Apply without confirm prompt (for scripting)
ckipper account sync personal work --include all --yes
```

### Named bundles

| Bundle | Resolves to |
|---|---|
| `all` | every type |
| `customizations` | mcp, settings, claude-md, agents, commands, output-styles, skills, statusline, hooks |
| `claude-config` | mcp, settings, hooks |
| `preferences` | prefs |

### Safeguards & undo

Every destructive write is preceded by a copy to `<dst>/.ckipper-sync-backups/<UTC-ISO-ts>-from-<source>/`. The summary table prints the backup directory path before applying. To restore:

```sh
ckipper account sync undo work # restore most recent backup
ckipper account sync undo work --pick # gum-pick from backup ledger
ckipper account sync undo work --list # print backup directory paths
```

Sync refuses to write when Claude is running with the destination's config dir (override with `--force` if you understand the risk).

### Sync vs. redeploy-hooks

These two commands sound similar but do different things:

- **`ckipper account sync ... --include hooks`** — peer-to-peer copy of *user-written* hooks (any hook file in `<account>/hooks/` whose filename does NOT match a ckipper-managed install hook). Includes the paired `settings.json` `.hooks` entry.
- **`ckipper account redeploy-hooks`** — pushes the ckipper safety hooks (`bash-guardrails`, `protect-claude-config`, `docker-context`, `notify-bell`) from `~/.ckipper/hooks/` to every registered account. Run after editing a script in the install dir.

## Security

### Docker isolation
Expand Down Expand Up @@ -301,7 +372,7 @@ This is usually a feature — your `personal` and `work` accounts working in the

### MCP servers are per-account (user-scoped only)

`mcpServers` lives in each account's `.claude.json`. When you `ckipper account add <new>`, the new account starts with **zero** user-scoped MCP servers. Use `ckipper account sync` to copy MCP/settings/plugins between accounts.
`mcpServers` lives in each account's `.claude.json`. When you `ckipper account add <new>`, the new account starts with **zero** user-scoped MCP servers. Use `ckipper account sync <from> <new> --include mcp` (or run the wizard with no args) to copy them — see [Sync state between accounts](#sync-state-between-accounts).

### Plugins and marketplaces are per-account

Expand Down
42 changes: 37 additions & 5 deletions ckipper.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,20 @@ source "$CKIPPER_REPO_DIR/lib/core/prompt.zsh"
source "$CKIPPER_REPO_DIR/lib/account/account-management.zsh"
source "$CKIPPER_REPO_DIR/lib/account/cleanup.zsh"
source "$CKIPPER_REPO_DIR/lib/account/aliases.zsh"
source "$CKIPPER_REPO_DIR/lib/account/sync.zsh"
# Sync subsystem — registry, backup, shared helpers, engine, preview,
# interactive, dispatcher, then the strategy modules.
source "$CKIPPER_REPO_DIR/lib/account/sync/registry.zsh"
source "$CKIPPER_REPO_DIR/lib/account/sync/backup.zsh"
source "$CKIPPER_REPO_DIR/lib/account/sync/_shared.zsh"
source "$CKIPPER_REPO_DIR/lib/account/sync/engine.zsh"
source "$CKIPPER_REPO_DIR/lib/account/sync/preview.zsh"
source "$CKIPPER_REPO_DIR/lib/account/sync/interactive.zsh"
source "$CKIPPER_REPO_DIR/lib/account/sync/dispatcher.zsh"
source "$CKIPPER_REPO_DIR/lib/account/sync/strategies/structured.zsh"
source "$CKIPPER_REPO_DIR/lib/account/sync/strategies/files_flat.zsh"
source "$CKIPPER_REPO_DIR/lib/account/sync/strategies/files_dir.zsh"
source "$CKIPPER_REPO_DIR/lib/account/sync/strategies/statusline.zsh"
source "$CKIPPER_REPO_DIR/lib/account/sync/strategies/hooks.zsh"
source "$CKIPPER_REPO_DIR/lib/account/doctor.zsh"
source "$CKIPPER_REPO_DIR/lib/account/dispatcher.zsh"

Expand Down Expand Up @@ -91,7 +104,7 @@ typeset -gA _CKIPPER_LEGACY_COMMANDS=(
[remove]='account remove'
[rename]='account rename'
[sync]='account sync'
[sync-hooks]='account sync-hooks'
[sync-hooks]='account redeploy-hooks'
[repair-plugins]='doctor --fix'
[migrate]=''
)
Expand Down Expand Up @@ -209,7 +222,7 @@ fpath=(~/.zsh/completions $fpath)
# Bump this when the heredoc body below changes so existing installs
# regenerate the cached completion file. The version is embedded as a literal
# comment in the generated file and matched here.
CKIPPER_COMPLETION_VERSION=6
CKIPPER_COMPLETION_VERSION=7
if [[ ! -f ~/.zsh/completions/_ckipper ]] \
|| ! grep -q "# ckipper-completion-version=$CKIPPER_COMPLETION_VERSION" ~/.zsh/completions/_ckipper 2>/dev/null; then
# Note: `_ckipper()` below is a zsh tab-completion definition embedded in
Expand All @@ -219,7 +232,7 @@ if [[ ! -f ~/.zsh/completions/_ckipper ]] \
# a completion file, not maintained shell logic).
cat > ~/.zsh/completions/_ckipper << 'COMPEOF'
#compdef ckipper ck
# ckipper-completion-version=6
# ckipper-completion-version=7

_ckipper() {
local projects_dir="${CKIPPER_PROJECTS_DIR:-$HOME/Developer}"
Expand All @@ -243,7 +256,8 @@ _ckipper() {
'default:Set the default account'
'remove:Unregister an account'
'rename:Rename an account in place'
'sync:Copy state between accounts'
'sync:Copy state between accounts (interactive or via --include)'
'redeploy-hooks:Redeploy ckipper safety hooks from install to all accounts'
'help:Show account-namespace help'
)
worktree_subs=(
Expand Down Expand Up @@ -350,6 +364,13 @@ _ckipper() {
_message 'new worktree branch name'
fi
;;
account/sync|acct/sync)
local -a accounts
if [[ -f "${CKIPPER_REGISTRY:-$HOME/.ckipper/accounts.json}" ]]; then
accounts=( $(jq -r '.accounts | keys[]' "${CKIPPER_REGISTRY:-$HOME/.ckipper/accounts.json}" 2>/dev/null) )
fi
_describe -t accounts 'additional sync target' accounts && return 0
;;
esac
;;
args)
Expand All @@ -368,6 +389,17 @@ _ckipper() {
_describe -t flags 'flag' flags
_command_names -e
;;
account/sync|acct/sync)
local -a sync_flags
sync_flags=(
'--include:Comma-separated types or named bundle (all/customizations/claude-config/preferences)'
'--exclude:Subtract from --include'
'--dry-run:Print summary; no writes'
'--yes:Skip the confirm prompt; apply directly'
'--force:Bypass the destination-Claude-running refusal'
)
_describe -t flags 'sync flag' sync_flags
;;
esac
case "${words[2]}" in
run)
Expand Down
6 changes: 3 additions & 3 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ chmod +x "$CKIPPER_DIR/docker/entrypoint.sh"
chmod +x "$CKIPPER_DIR/docker/init-firewall.sh"
chmod +x "$CKIPPER_DIR/docker/cleanup-projects.py"

# 3. Copy hooks (canonical source for ckipper account sync-hooks)
# 3. Copy hooks (canonical source for ckipper account redeploy-hooks)
echo "Copying hooks to $CKIPPER_DIR/hooks/..."
mkdir -p "$CKIPPER_DIR/hooks"
cp "$REPO_DIR/hooks/protect-claude-config.sh" "$CKIPPER_DIR/hooks/"
Expand Down Expand Up @@ -118,10 +118,10 @@ fi
[[ -f "$CKIPPER_DIR/accounts.json" ]] && echo " accounts.json already exists (not overwritten — managed by ckipper)"
[[ -f "$CKIPPER_DIR/aliases.zsh" ]] && echo " aliases.zsh already exists (not overwritten — auto-generated)"

# 6. Deploy settings-template.json (consumed by ckipper account add / sync-hooks per-account)
# 6. Deploy settings-template.json (consumed by ckipper account add / redeploy-hooks per-account)
echo "Copying settings-template.json to $CKIPPER_DIR/..."
cp "$REPO_DIR/templates/settings-template.json" "$CKIPPER_DIR/settings-template.json"
echo " Settings template deployed. ckipper account sync-hooks applies it per-account."
echo " Settings template deployed. ckipper account redeploy-hooks applies it per-account."

# 7. Add or update source line in .zshrc
# Pre-merge installs sourced w-function.zsh from ~/.claude/docker/ or
Expand Down
6 changes: 3 additions & 3 deletions lib/account/account-management.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ _ckipper_account_add_fresh_flow() {
if [[ -f "$CKIPPER_DIR/settings-template.json" ]]; then
cp "$CKIPPER_DIR/settings-template.json" "$dir/settings.json"
fi
_ckipper_account_sync_hooks_for "$name" "$dir"
_ckipper_account_redeploy_hooks_for "$name" "$dir"
local before_snapshot
before_snapshot=$(_core_keychain_snapshot) || return 1
_ckipper_account_add_launch_claude "$name" "$dir" || return 1
Expand Down Expand Up @@ -256,7 +256,7 @@ _ckipper_account_finalize_registration() {
_ckipper_account_finalize_announce() {
local name="$1" mode="$2"
_ckipper_account_regenerate_aliases
_ckipper_account_sync_hooks_for "$name"
_ckipper_account_redeploy_hooks_for "$name"
echo "Registered '$name' (mode: $mode)."
if _ckipper_account_bare_alias_safe "$name"; then
echo "Use it via: claude-$name (or just: $name)"
Expand Down Expand Up @@ -538,7 +538,7 @@ _ckipper_account_rename() {
unset -f "claude-$old" 2>/dev/null
unset -f "$old" 2>/dev/null
_ckipper_account_regenerate_aliases
_ckipper_account_sync_hooks_for "$new" # rewrite per-account settings.json hook paths to new dir
_ckipper_account_redeploy_hooks_for "$new" # rewrite per-account settings.json hook paths to new dir
echo "Renamed '$old' → '$new'."
echo "Directory: $old_dir → $new_dir"
if _ckipper_account_bare_alias_safe "$new"; then
Expand Down
27 changes: 18 additions & 9 deletions lib/account/aliases.zsh
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env zsh
# Alias generation and hook sync subcommands: regenerate_aliases, sync_hooks_for, sync_hooks.
# Alias generation and install-hook redeploy subcommands:
# regenerate_aliases, redeploy_hooks_for, redeploy_hooks.

readonly ALIASES_FILE_PERMS=644

Expand Down Expand Up @@ -110,17 +111,20 @@ _ckipper_account_rewrite_settings_json_hooks() {
' "$dir/settings.json" > "$settings_tmpfile" && mv "$settings_tmpfile" "$dir/settings.json"
}

# Copy hook scripts into an account directory and rewrite settings.json hook paths.
# Allows callers (e.g. _ckipper_account_add) to pass the dir directly before the account
# is registered in the registry.
# Redeploy ckipper-managed safety hooks from $CKIPPER_DIR/hooks/ into one
# account dir and rewrite that account's settings.json hook paths to absolute
# destination paths. This is install→one redeploy, not peer-to-peer sync.
#
# Allows callers (e.g. _ckipper_account_add) to pass the dir directly before
# the account is registered in the registry.
#
# Args:
# $1 — account name
# $2 — (optional) account config directory; looked up in registry if omitted
#
# Returns:
# 0 on success; 1 if directory cannot be resolved.
_ckipper_account_sync_hooks_for() {
_ckipper_account_redeploy_hooks_for() {
local name="$1" dir="${2:-}"
if [[ -z "$dir" ]]; then
_core_registry_check_version || return 1
Expand All @@ -132,19 +136,24 @@ _ckipper_account_sync_hooks_for() {
_ckipper_account_rewrite_settings_json_hooks "$dir"
}

# Copy hooks into all registered accounts.
# Redeploy ckipper-managed safety hooks into every registered account dir.
#
# This is install→all redeploy (NOT peer-to-peer sync). Run after editing
# anything under $CKIPPER_DIR/hooks/ so the change propagates everywhere.
# For peer-to-peer sync of user-written hooks, see `ckipper account sync
# <from> <to> --include hooks`.
#
# Returns:
# 0 on success; 1 if registry version check fails.
_ckipper_account_sync_hooks() {
_ckipper_account_redeploy_hooks() {
if [[ ! -f "$CKIPPER_REGISTRY" ]]; then
echo "No accounts registered."
return 0
fi
_core_registry_check_version || return 1
local names; names=$(jq -r '.accounts | keys[]' "$CKIPPER_REGISTRY")
while IFS= read -r name; do
echo "Syncing hooks → $name"
_ckipper_account_sync_hooks_for "$name"
echo "Redeploying install hooks → $name"
_ckipper_account_redeploy_hooks_for "$name"
done <<< "$names"
}
Loading
Loading