cctidy

package module
v0.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Feb 16, 2026 License: MIT Imports: 12 Imported by: 0

README

cctidy

A CLI tool that formats Claude Code configuration files.

Motivation

Claude Code generates and updates several JSON config files during use. Over time, these files accumulate stale entries (removed projects, dead permission paths) and inconsistent formatting. cctidy keeps them tidy by removing dead references and normalizing structure, so diffs stay clean and configs stay readable.

Features

cctidy handles three categories of config files, each with different operations:

~/.claude.json

Removes project paths and GitHub repo paths that no longer exist on the filesystem. Repo keys with no remaining paths are deleted entirely. Pretty-prints with sorted keys.

[!NOTE] Arrays are not sorted because Claude Code manages their order internally.

Global settings (~/.claude/settings*.json)

Removes allow and ask permission entries that reference non-existent paths. deny entries are never swept to preserve safety. Pretty-prints with sorted keys and sorted homogeneous arrays for deterministic diffs.

Project settings (.claude/settings*.json)

Same operations as global settings, with the addition of project-relative path resolution for permission sweeping.

[!NOTE] Bash tool sweeping is opt-in via --unsafe flag or [permission.bash] enabled = true in config. Bash entries use path extraction heuristics that may produce false positives, so sweeping is disabled by default.

Supported Tools
Tool Default Detection
Read enabled Path existence
Edit enabled Path existence
Bash disabled All extracted paths
Task enabled Agent existence
Skill enabled Skill/command existence
MCP enabled Server registration

Entries for tools not listed above (e.g. Write, Grep, WebFetch) are kept unchanged. Write is excluded because it creates new files, so the target path not existing is expected.

Installation

Homebrew
brew install 708u/tap/cctidy
Install script

Linux / macOS:

curl -sSfL https://raw.githubusercontent.com/708u/cctidy/main/scripts/install.sh | sh

Windows (PowerShell):

irm https://raw.githubusercontent.com/708u/cctidy/main/scripts/install.ps1 | iex
Go
go install github.com/708u/cctidy/cmd/cctidy@latest

Or download from Releases.

Quick Start

# Format all target files
cctidy

# Preview changes without writing
cctidy --dry-run -v

# Exit with 1 if any file needs formatting.
# Useful for CI to enforce consistent config formatting.
cctidy --check

# Format a specific file with backup
cctidy -t ~/.claude.json --backup

# Include unsafe sweepers (e.g. Bash)
cctidy --unsafe

CLI Options

Flag Short Description
--target -t Format a specific file only
--backup Create backup before writing
--dry-run Show changes without writing
--check Exit with 1 if any file is dirty
--unsafe Enable unsafe sweepers (e.g. Bash)
--config Path to config file
--verbose -v Show formatting details
--version Print version

Details: docs/reference/cli.md

Exit Codes

Code Meaning
0 Success
1 --check: dirty files detected
2 Invalid flags or runtime error

--check cannot be combined with --backup or --dry-run. Using them together exits with code 2.

Configuration

cctidy supports layered TOML configuration. Settings are merged in the following order (later wins):

  1. Global: ~/.config/cctidy/config.toml
  2. Project shared: .claude/cctidy.toml
  3. Project local: .claude/cctidy.local.toml
  4. CLI flags (--unsafe)

Project config files are searched from the nearest .claude/ directory, walking up from the current working directory.

Example
# ~/.config/cctidy/config.toml or .claude/cctidy.toml
[permission.bash]
enabled = true
exclude_entries = ["mkdir -p /opt/logs"]
exclude_commands = ["mkdir", "touch"]
exclude_paths = ["vendor/"]
Merge Strategy
  • Scalars (enabled): last-set-wins. Unset values do not override lower layers.
  • Arrays (exclude_*): union with deduplication. Each layer adds entries additively.
  • Relative paths in project config exclude_paths are resolved against the project root.

Details: docs/reference/cli.md

Target Files

File Operations
~/.claude.json Path cleaning, formatting
~/.claude/settings.json Sweeping, sorting
~/.claude/settings.local.json Sweeping, sorting
.claude/settings.json Sweeping, sorting
.claude/settings.local.json Sweeping, sorting

Details: docs/reference/formatting.md, docs/reference/permission-sweeping.md

File Safety

  • Atomic write: Uses temp file + rename to prevent partial writes on crash or interrupt.
  • Symlink: Resolves symlinks before writing so the actual target file is updated.

Claude Code Plugin

A Claude Code plugin is available that automatically formats config files on session start.

/plugin marketplace add 708u/cctidy
/plugin install cctidy@708u-cctidy

License

MIT

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func LoadAgentNames added in v0.4.0

func LoadAgentNames(dir string) set.Value[string]

LoadAgentNames scans the agents directory and returns a set of agent names extracted from frontmatter. The frontmatter name field is the sole agent identifier; filenames are not used. Files without a valid name field are skipped. Returns an empty set if the directory does not exist.

func LoadProjectConfig added in v0.4.0

func LoadProjectConfig(projectRoot string) (rawConfig, error)

LoadProjectConfig reads project-level config files from <projectRoot>/.claude/cctidy.toml (shared) and <projectRoot>/.claude/cctidy.local.toml (local). Local overrides shared. Returns zero value when both files are absent.

func LoadSkillNames added in v0.4.0

func LoadSkillNames(claudeDir string) set.Value[string]

LoadSkillNames scans the skills and commands directories under claudeDir and returns a set of skill names.

Skills are identified by subdirectories in <claudeDir>/skills/ that contain a SKILL.md file. If SKILL.md has a frontmatter name field, that name is used; otherwise the directory name is used.

Commands are identified by .md files in <claudeDir>/commands/. If a file has a frontmatter name field, that name is used; otherwise the filename without extension is used.

Returns an empty set if claudeDir is empty or unreadable.

Types

type BashExcluder added in v0.2.0

type BashExcluder struct {
	// contains filtered or unexported fields
}

BashExcluder decides whether a Bash permission specifier should be excluded from sweeping (i.e. always kept).

func NewBashExcluder added in v0.2.0

func NewBashExcluder(cfg BashPermissionConfig) *BashExcluder

NewBashExcluder builds a BashExcluder from a BashPermissionConfig.

func (*BashExcluder) IsExcluded added in v0.2.0

func (e *BashExcluder) IsExcluded(specifier string, absPaths []string) bool

IsExcluded reports whether the specifier matches any exclusion rule. Checks are applied in order: entries (exact), commands (first token), paths (prefix match on pre-extracted absolute paths).

type BashPermissionConfig added in v0.4.0

type BashPermissionConfig struct {
	// Enabled turns on Bash sweep when true.
	Enabled bool `toml:"enabled"`

	// ExcludeEntries lists specifiers to exclude by exact match.
	ExcludeEntries []string `toml:"exclude_entries"`

	// ExcludeCommands lists command names (first token) to exclude.
	ExcludeCommands []string `toml:"exclude_commands"`

	// ExcludePaths lists path prefixes to exclude.
	// Trailing / is recommended to ensure directory boundary matching.
	ExcludePaths []string `toml:"exclude_paths"`
}

BashPermissionConfig controls Bash permission entry sweeping.

type BashToolSweeper

type BashToolSweeper struct {
	// contains filtered or unexported fields
}

BashToolSweeper sweeps Bash permission entries where all resolvable paths in the specifier are non-existent. Entries with no resolvable paths or at least one existing path are kept.

func NewBashToolSweeper added in v0.4.0

func NewBashToolSweeper(checker PathChecker, homeDir, baseDir string, excluder *BashExcluder, active bool) *BashToolSweeper

NewBashToolSweeper creates a BashToolSweeper. active controls whether sweeping is performed at all; when false, ShouldSweep always returns a zero result.

func (*BashToolSweeper) ShouldSweep

func (b *BashToolSweeper) ShouldSweep(ctx context.Context, entry StandardEntry) ToolSweepResult

type ClaudeJSONFormatter

type ClaudeJSONFormatter struct {
	PathChecker PathChecker
}

ClaudeJSONFormatter formats ~/.claude.json with path cleaning (removing non-existent projects and GitHub repo paths) and pretty-printing with 2-space indent.

func NewClaudeJSONFormatter

func NewClaudeJSONFormatter(checker PathChecker) *ClaudeJSONFormatter

func (*ClaudeJSONFormatter) Format

func (f *ClaudeJSONFormatter) Format(ctx context.Context, data []byte) (*FormatResult, error)

type ClaudeJSONFormatterStats

type ClaudeJSONFormatterStats struct {
	ProjectsBefore int
	ProjectsAfter  int
	RepoBefore     int
	RepoAfter      int
	RemovedRepos   int
	SizeBefore     int
	SizeAfter      int
}

ClaudeJSONFormatterStats holds statistics for ~/.claude.json formatting.

func (*ClaudeJSONFormatterStats) Summary

func (s *ClaudeJSONFormatterStats) Summary() string

type Config added in v0.2.0

type Config struct {
	Permission PermissionConfig `toml:"permission"`
}

Config holds the cctidy configuration loaded from TOML.

func LoadConfig added in v0.2.0

func LoadConfig(path string) (*Config, error)

LoadConfig reads a TOML configuration file. If path is empty, the default path (~/.config/cctidy/config.toml) is used. Returns a zero-value Config without error when the file does not exist.

func MergeConfig added in v0.4.0

func MergeConfig(base *Config, project rawConfig, projectRoot string) *Config

MergeConfig merges a project rawConfig on top of a global Config. Relative paths in the project config's ExcludePaths are resolved against projectRoot before merging.

type FormatResult

type FormatResult struct {
	Data  []byte
	Stats Summarizer
}

FormatResult holds the formatted output and statistics.

type MCPEntry added in v0.4.0

type MCPEntry struct {
	ServerName string
	RawEntry   string
}

MCPEntry is a parsed mcp__server__tool permission entry.

func (MCPEntry) Name added in v0.4.0

func (e MCPEntry) Name() ToolName

type MCPServerSets added in v0.4.0

type MCPServerSets struct {
	// contains filtered or unexported fields
}

MCPServerSets holds MCP server names separated by source. Use ForUserScope or ForProjectScope to get the appropriate set for a given settings file scope.

func LoadMCPServers added in v0.4.0

func LoadMCPServers(mcpJSONPath, claudeJSONPath string) (*MCPServerSets, error)

LoadMCPServers collects known MCP server names from .mcp.json and ~/.claude.json. Missing files are silently ignored. JSON parse errors are returned.

func (*MCPServerSets) ForProjectScope added in v0.4.0

func (s *MCPServerSets) ForProjectScope() set.Value[string]

ForProjectScope returns servers available in project scope (.claude/settings.json, .claude/settings.local.json). This includes both .mcp.json and ~/.claude.json servers.

func (*MCPServerSets) ForUserScope added in v0.4.0

func (s *MCPServerSets) ForUserScope() set.Value[string]

ForUserScope returns servers available in user scope (~/.claude/settings.json, ~/.claude/settings.local.json). This includes only ~/.claude.json servers, not .mcp.json.

type MCPToolSweeper added in v0.4.0

type MCPToolSweeper struct {
	// contains filtered or unexported fields
}

MCPToolSweeper sweeps MCP tool permission entries whose server is no longer present in the known server set.

func NewMCPToolSweeper added in v0.4.0

func NewMCPToolSweeper(servers set.Value[string]) *MCPToolSweeper

NewMCPToolSweeper creates an MCPToolSweeper.

func (*MCPToolSweeper) ShouldSweep added in v0.4.0

func (m *MCPToolSweeper) ShouldSweep(_ context.Context, entry MCPEntry) ToolSweepResult

ShouldSweep evaluates an MCPEntry. Returns Sweep=true when the server is not in the known set.

type PathChecker

type PathChecker interface {
	Exists(ctx context.Context, path string) bool
}

PathChecker checks whether a filesystem path exists.

type PermissionConfig added in v0.4.0

type PermissionConfig struct {
	// Bash configures sweeping for Bash permission entries.
	// "bash" corresponds to the Bash tool name in Claude Code permissions.
	Bash BashPermissionConfig `toml:"bash"`
}

PermissionConfig groups per-tool permission sweep settings.

type PermissionSweeper

type PermissionSweeper struct {
	// contains filtered or unexported fields
}

PermissionSweeper sweeps stale permission entries from settings objects. It dispatches to tool-specific ToolSweeper implementations based on the tool name extracted from each entry. Entries for unregistered tools are kept unchanged.

Ref: https://code.claude.com/docs/en/permissions#permission-rule-syntax

func NewPermissionSweeper

func NewPermissionSweeper(checker PathChecker, homeDir string, servers set.Value[string], opts ...SweepOption) *PermissionSweeper

NewPermissionSweeper creates a PermissionSweeper. homeDir is required for resolving ~/path specifiers. servers is the set of known MCP server names for MCP sweep.

func (*PermissionSweeper) Sweep

func (p *PermissionSweeper) Sweep(ctx context.Context, obj map[string]any) *SweepResult

Sweep removes stale allow/ask permission entries from obj.

type ReadEditToolSweeper

type ReadEditToolSweeper struct {
	// contains filtered or unexported fields
}

ReadEditToolSweeper sweeps Read/Edit permission entries that reference non-existent paths.

Specifier resolution rules:

  • glob (*, ?, [) → skip (kept unchanged)
  • //path → /path (absolute; always resolvable)
  • ~/path → homeDir/path (requires homeDir)
  • /path → project root relative (requires baseDir)
  • ./path, ../path, bare path → cwd relative (requires baseDir)

func (*ReadEditToolSweeper) ShouldSweep

type SettingsJSONFormatter

type SettingsJSONFormatter struct {
	Sweeper *PermissionSweeper
}

SettingsJSONFormatter formats settings.json / settings.local.json by sorting keys recursively and sorting homogeneous arrays. When Sweeper is provided, dead permission paths are swept.

func NewSettingsJSONFormatter

func NewSettingsJSONFormatter(sweeper *PermissionSweeper) *SettingsJSONFormatter

func (*SettingsJSONFormatter) Format

func (s *SettingsJSONFormatter) Format(ctx context.Context, data []byte) (*FormatResult, error)

type SettingsJSONFormatterStats

type SettingsJSONFormatterStats struct {
	SizeBefore int
	SizeAfter  int
	SweptAllow int
	SweptAsk   int
	Warns      []string
}

SettingsJSONFormatterStats holds statistics for settings.json formatting.

func (*SettingsJSONFormatterStats) Summary

func (s *SettingsJSONFormatterStats) Summary() string

type SkillToolSweeper added in v0.4.0

type SkillToolSweeper struct {
	// contains filtered or unexported fields
}

SkillToolSweeper sweeps Skill permission entries where the referenced skill or command no longer exists. Plugin skills (containing ":") are always kept.

func NewSkillToolSweeper added in v0.4.0

func NewSkillToolSweeper(skills set.Value[string]) *SkillToolSweeper

NewSkillToolSweeper creates a SkillToolSweeper.

func (*SkillToolSweeper) ShouldSweep added in v0.4.0

type StandardEntry added in v0.4.0

type StandardEntry struct {
	Tool      ToolName
	Specifier string
}

StandardEntry is a parsed Tool(specifier) permission entry.

func (StandardEntry) Name added in v0.4.0

func (e StandardEntry) Name() ToolName

type Summarizer

type Summarizer interface {
	Summary() string
}

Summarizer produces a human-readable summary of formatting results.

type SweepOption

type SweepOption func(*sweepConfig)

SweepOption configures a PermissionSweeper.

func WithBaseDir

func WithBaseDir(dir string) SweepOption

WithBaseDir sets the base directory for resolving relative path specifiers.

func WithBashConfig added in v0.4.0

func WithBashConfig(cfg *BashPermissionConfig) SweepOption

WithBashConfig sets the BashPermissionConfig for Bash sweeping. Exclude patterns filter entries from sweeping. When cfg.Enabled is true, Bash sweep runs without --unsafe.

func WithUnsafe added in v0.4.0

func WithUnsafe() SweepOption

WithUnsafe enables unsafe-tier sweepers.

type SweepResult

type SweepResult struct {
	SweptAllow int
	SweptAsk   int
	Warns      []string
}

SweepResult holds statistics from permission sweeping. Deny entries are intentionally excluded from sweeping because they represent explicit user prohibitions; removing stale deny rules costs nothing but could silently re-enable a previously blocked action.

type TaskToolSweeper added in v0.4.0

type TaskToolSweeper struct {
	// contains filtered or unexported fields
}

TaskToolSweeper sweeps Task permission entries where the referenced agent no longer exists. Built-in agents, plugin agents (containing ":"), and agents whose name appears in the agent name set are always kept.

func NewTaskToolSweeper added in v0.4.0

func NewTaskToolSweeper(agents set.Value[string]) *TaskToolSweeper

NewTaskToolSweeper creates a TaskToolSweeper.

func (*TaskToolSweeper) ShouldSweep added in v0.4.0

func (t *TaskToolSweeper) ShouldSweep(_ context.Context, entry StandardEntry) ToolSweepResult

type ToolEntry added in v0.4.0

type ToolEntry interface {
	Name() ToolName
}

ToolEntry represents a parsed permission entry routed to a specific tool.

type ToolName

type ToolName string

ToolName identifies a Claude Code tool for permission matching.

const (
	ToolRead  ToolName = "Read"
	ToolEdit  ToolName = "Edit"
	ToolWrite ToolName = "Write"
	ToolBash  ToolName = "Bash"
	ToolTask  ToolName = "Task"
	ToolSkill ToolName = "Skill"
	ToolMCP   ToolName = "mcp"
)

type ToolSweepResult

type ToolSweepResult struct {
	Sweep bool
	Warn  string
}

ToolSweepResult holds the result of a single tool sweeper evaluation. When Warn is non-empty the entry is kept and the warning is recorded.

type ToolSweeper

type ToolSweeper interface {
	ShouldSweep(ctx context.Context, entry ToolEntry) ToolSweepResult
}

ToolSweeper decides whether a permission entry should be swept.

func NewToolSweeper added in v0.4.0

func NewToolSweeper[E ToolEntry](fn func(context.Context, E) ToolSweepResult) ToolSweeper

NewToolSweeper wraps a concrete-typed sweep function as a ToolSweeper.

Directories

Path Synopsis
cmd
cctidy command
internal
md
set

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL