This document describes the internal architecture of mcs for contributors and anyone extending the codebase.
Package.swift # swift-tools-version: 6.0, macOS 13+
Sources/mcs/
CLI.swift # @main entry, version, subcommand registration
Core/ # Shared infrastructure
Commands/ # CLI subcommands (sync, doctor, cleanup, pack, export, check-updates, config)
Sync/ # Convergence engine, project configuration, installation logic
Export/ # Export wizard (ConfigurationDiscovery, ManifestBuilder, PackWriter)
TechPack/ # Tech pack protocol, component model, dependency resolver
Templates/ # Template engine and section-based file composition
Doctor/ # Diagnostic checks and fix logic
ExternalPack/ # YAML manifest parsing, Git fetching, adapter, script runner
Tests/MCSTests/ # Test target
mcs is a pure pack management engine with zero bundled content. It ships no templates, hooks, settings, skills, or slash commands. Everything comes from external tech packs that users add via mcs pack add (git URL, GitHub shorthand, or local path).
The primary command is mcs sync, which handles both global and per-project configuration:
mcs sync [path]— per-project setup with multi-pack selection and convergent artifact managementmcs sync --global— global-scope component installation (brew packages, MCP servers, plugins)
Central path resolution for all file locations. Detects architecture (arm64/x86_64), resolves Homebrew path, and locates the user's shell RC file. Key paths:
~/.claude/— Claude Code configuration directory~/.claude/settings.json— user settings (global)~/.claude.json— MCP server registrations (global + per-project vialocalscope)~/.mcs/packs/— external tech pack checkouts~/.mcs/registry.yaml— registry of installed external packs~/.mcs/global-state.json— global sync state~/.mcs/lock— concurrency lock file
Per-project paths (created by mcs sync):
<project>/.claude/settings.local.json— per-project settings with hook entries<project>/.claude/skills/— per-project skills<project>/.claude/hooks/— per-project hook scripts<project>/.claude/commands/— per-project slash commands<project>/.claude/agents/— per-project subagents<project>/.claude/.mcs-project— per-project state (JSON)<project>/CLAUDE.local.md— per-project instructions with section markers<project>/mcs.lock.yaml— lockfile pinning pack commits
Settings is a Codable model that mirrors the structure of Claude Code settings files. It supports deep-merge: when merging, hooks are deduplicated by command string, plugins are merged additively, and scalar values from the template take precedence.
In the per-project model, Configurator (with ProjectSyncStrategy) composes settings.local.json from all selected packs' hook entries. Each pack gets its own HookGroup entry pointing to a script in <project>/.claude/hooks/:
{
"hooks": {
"SessionStart": [
{ "hooks": [{ "type": "command", "command": "bash .claude/hooks/core-session-start.sh" }] },
{ "hooks": [{ "type": "command", "command": "bash .claude/hooks/ios-session-start.sh" }] }
]
}
}Per-project state stored as JSON at <project>/.claude/.mcs-project. Tracks:
- Configured packs: which packs are configured for this project
- Per-pack artifact records (
PackArtifactRecord): for each pack, what was installedmcpServers: name + scope (forclaude mcp remove)files: project-relative paths (for deletion)templateSections: section identifiers (for CLAUDE.local.md removal)hookCommands: hook commands (for settings.local.json cleanup)settingsKeys: settings keys contributed by this pack
- mcs version: the version that last wrote the file
- Timestamp: when the file was last updated
Written by mcs sync after convergence.
~/.mcs/global-state.json |
<project>/.claude/.mcs-project |
|
|---|---|---|
| Scope | Machine-wide | Single project |
| Written by | mcs sync --global |
mcs sync |
| Format | JSON | JSON |
| Tracks | Globally installed components, pack IDs, file hashes | Per-pack artifact records, configured pack IDs |
Before modifying files with user content (e.g., CLAUDE.local.md), a timestamped backup is created (e.g., CLAUDE.local.md.backup.20260222_143000). Tool-managed files are not backed up since they can be regenerated. The mcs cleanup command discovers and deletes these backups.
mcs.lock.yaml pins pack commits for reproducible builds. Generation is opt-in (default off) — enable persistently with mcs config set generate-lockfile true or write once with mcs sync --update. Tri-state on generate-lockfile: true writes on every sync; false is fully silent (explicit opt-out); nil (never configured) reports SHA drift against a pre-existing lockfile so users upgrading from the auto-generation era see their stale lockfile. Used with --lock to checkout pinned commits.
Wraps claude mcp add/remove and claude plugin install/remove CLI commands. MCP server registration supports three scopes:
local(default): per-user, per-project — stored in~/.claude.jsonkeyed by project pathproject: team-shared — stored in.mcp.jsonin the project directoryuser: cross-project — stored in~/.claude.jsonglobally
External packs are directories containing a techpack.yaml manifest — either Git repositories cloned into ~/.mcs/packs/ or local directories registered in-place. The system has these layers:
- PackSourceResolver — resolves user input into a git URL or local path (URL schemes → filesystem → GitHub shorthand)
- PackFetcher — clones/pulls git pack repos into
~/.mcs/packs/<name>/ - ExternalPackManifest — Codable model for
techpack.yaml(components, templates, hooks, doctor checks, prompts, configure scripts). Supports shorthand syntax for concise component definitions - ExternalPackAdapter — bridges
ExternalPackManifestto theTechPackprotocol so external packs participate in all sync/doctor flows - PackRegistryFile — YAML registry (
~/.mcs/registry.yaml) tracking which packs are installed - PackUpdater — shared fetch → validate → trust cycle for updating a single git pack
- TechPackRegistry — unified registry that loads external packs from disk
Shorthand syntax (preferred):
identifier: my-pack
displayName: My Pack
description: What this pack provides
components:
- id: my-server
description: My MCP server
mcp:
command: npx
args: ["-y", "my-server@latest"]
templates:
- sectionIdentifier: instructions
contentFile: templates/claude-local.mdVerbose form is also supported — see Tech Pack Schema.
Configurator (with ProjectSyncStrategy) is the per-project convergence engine:
- Multi-select: shows all registered packs, pre-selects previously configured packs
- Compute diff:
removals = previous - selected,additions = selected - previous - Resolve template values (multi-step):
- 3a. Resolve built-in values (
__REPO_NAME__,__PROJECT_DIR_NAME__) - 3b–3c. Collect all prompt definitions from packs via
declaredPrompts(), group shared keys (same key from 2+ packs,input/selecttypes only) - 3d. Execute shared prompts once via
CrossPackPromptResolverwith combined display - 3e. Execute remaining per-pack prompts (skip already-resolved keys)
- 3a. Resolve built-in values (
- Scan for undeclared placeholders: warn about
__KEY__tokens in copyPackFile sources, settings files, and MCP configs that have no matching prompt - Unconfigure removed packs: remove MCP servers (via CLI), delete project files, using stored
PackArtifactRecord - Auto-install global deps: brew packages and plugins for all selected packs
- Install per-project artifacts: copy skills/hooks/commands to
<project>/.claude/, register MCP servers withlocalscope (with placeholder substitution in env/command/args) - Compose
settings.local.json: build from all selected packs' hook entries and settings files (with placeholder substitution) - Compose
CLAUDE.local.md: gather template sections from all selected packs - Run pack configure hooks: pack-specific setup (e.g., generate config files)
- Ensure gitignore entries: add
.claude/entries to global gitignore - Save project state: write
.mcs-projectwith artifact records for each pack - Write lockfile (opt-in): if
--updatewas passed orgenerate-lockfile: true, savemcs.lock.yamlwith current pack state. Whengenerate-lockfileis unset (upgrade path) and a stale lockfile exists, emit a drift warning with a migration hint. Explicitgenerate-lockfile: falseis silent
The --pack flag bypasses multi-select for CI use: mcs sync --pack ios --pack web.
Configurator (with GlobalSyncStrategy) handles global-scope installation:
- Selection: interactive multi-select,
--pack <name>, or--all - Component install: brew packages, MCP servers (user scope), plugins
- Record state: update
~/.mcs/global-state.json
DependencyResolver performs a topological sort of selected components plus their transitive dependencies. It detects cycles and auto-adds dependencies that weren't explicitly selected (marking them as "(auto-resolved)" in the summary).
Each installable unit is a ComponentDefinition with:
- id: unique identifier (e.g.,
ios.xcodebuildmcp) - type:
mcpServer,plugin,skill,hookFile,command,agent,brewPackage,configuration - packIdentifier: pack ID for the owning pack
- dependencies: IDs of components this depends on
- isRequired: if true, always installed with its pack
- installAction: how to install (see below)
- supplementaryChecks: doctor checks that can't be auto-derived
enum ComponentInstallAction {
case mcpServer(MCPServerConfig) // Register via `claude mcp add -s <scope>`
case plugin(name: String) // Install via `claude plugin install`
case brewInstall(package: String) // Install via Homebrew
case shellCommand(command: String, interactive: Bool = false) // Run shell command (interactive: PTY for sudo)
case settingsMerge // Deep-merge settings (project-level)
case gitignoreEntries(entries) // Add to global gitignore
case copyPackFile(source, dest, type) // Copy from pack checkout to project .claude/
}copyPackFile destinations are installed flat by default (e.g., .claude/commands/pr.md). When two or more packs define the same (destination, fileType), the DestinationCollisionResolver auto-namespaces them: subdirectory prefix (<pack-id>/) for hooks, commands, agents, and generic files, or directory name suffix (-<pack-id>) for skills (which require flat one-level directories for Claude Code discovery).
MCPServerConfig includes a scope field:
nil/"local"(default) — per-user, per-project isolation"project"— team-shared (.mcp.json)"user"— cross-project global
protocol TechPack: Sendable {
var identifier: String { get }
var displayName: String { get }
var description: String { get }
var components: [ComponentDefinition] { get }
var templates: [TemplateContribution] { get }
var supplementaryDoctorChecks: [any DoctorCheck] { get }
func templateValues(context: ProjectConfigContext) -> [String: String]
func declaredPrompts(context: ProjectConfigContext) -> [PromptDefinition]
func configureProject(at path: URL, context: ProjectConfigContext) throws
}Packs provide:
- Components: installable units (MCP servers, skills, etc.)
- Templates: sections to inject into
CLAUDE.local.md - Supplementary doctor checks: pack-level diagnostics not derivable from components
- Template values: resolved via prompts or scripts during sync
- Declared prompts: prompt definitions for cross-pack deduplication (without executing them)
- Project configuration: pack-specific setup (e.g., generate config files)
DoctorRunner orchestrates checks across five layers:
- Derived checks: auto-generated from each component's
installActionviaderiveDoctorCheck() - Supplementary component checks: additional checks declared on components
- Supplementary pack checks: pack-level concerns not tied to a specific component
- Standalone checks: cross-component concerns (hook event registration, settings validation, gitignore)
- Project checks: CLAUDE.local.md freshness, project state file
doctor --fix only handles:
- Cleanup: removing deprecated components
- Trivial repairs: permission fixes, gitignore additions, symlink creation
- Project state: creating missing
.mcs-projectby inferring from section markers
doctor --fix does NOT handle additive operations (installing packages, registering servers, copying files). These are handled by mcs sync.
Individual checks resolve component presence through three tiers:
- Project path: when packs are resolved from project scope, checks look in
<project>/.claude/first (e.g.,<project>/.claude/skills/my-skill.md) - Global fallback: if not found at project scope, checks fall back to
~/.claude/(covers globally-installed components) - Exclusion suppression: components excluded via
--customizeshow as dimmed○ excluded via --customizeinstead of failing
MCP server checks follow the same pattern: project-scoped entries (projects[path].mcpServers in ~/.claude.json) are checked before global entries (mcpServers).
When determining which packs to check, doctor uses a priority chain:
- Explicit
--packflag - Project
.mcs-projectstate file - Inferred from
CLAUDE.local.mdsection markers - Global manifest
__PLACEHOLDER__ substitution across multiple artifact types. Values are passed as [String: String] dictionaries. Packs can resolve values via prompts (interactive) or scripts (automated) during sync.
Substitution applies to:
- Templates: CLAUDE.local.md sections (Phase 7)
- copyPackFile artifacts: hooks, commands, skills, generic files (Phase 5)
- Settings files:
.settingsMergeJSON — text-level substitution before JSON parsing viaSettings.load(from:substituting:) - MCP server configs:
envvalues,command, andargsviaMCPServerConfig.substituting(_:)(name is preserved as artifact tracking key)
Manages section markers in CLAUDE.local.md:
<!-- mcs:begin core -->
... managed content ...
<!-- mcs:end core -->
<!-- mcs:begin ios -->
... managed content ...
<!-- mcs:end ios -->
(user content preserved outside markers)Key operations:
compose(): create a new file from contributionsreplaceSection(): update a section in an existing fileextractUserContent(): preserve content outside markers during updatesparseSections(): extract section identifiers
mcs export is the inverse of mcs sync: it reads installed artifacts and generates a techpack.yaml manifest. The export flow uses three dedicated types:
- ConfigurationDiscovery (
Export/ConfigurationDiscovery.swift) — reads live config files (~/.claude.json,settings.json,.claude/directories,CLAUDE.md, global gitignore) and produces aDiscoveredConfigurationmodel - ManifestBuilder (
Export/ManifestBuilder.swift) — converts selected artifacts into a YAML string using shorthand syntax. Handles sensitive env var replacement (__PLACEHOLDER__tokens +prompts:entries), brew dependency hints, and section comments - PackWriter (
Export/PackWriter.swift) — writes the output directory (techpack.yaml+ copied files + config/settings.json + templates/)
The command (Commands/ExportCommand.swift) is a read-only ParsableCommand (no lock needed). It supports --global for global scope, --dry-run for preview, and --non-interactive for CI use.
mcs is designed to be safe to run repeatedly, non-destructive by default, and transparent about what it changes.
| Guarantee | How it works |
|---|---|
| Backups | Timestamped backup before modifying files with user content (e.g., CLAUDE.local.md). Tool-managed files are not backed up since they can be regenerated. Clean up with mcs cleanup. |
| Dry Run | mcs sync --dry-run previews all changes without writing any files, so you can inspect exactly what will happen before committing. |
| Selective Install | mcs sync --customize lets you deselect individual components. --all applies everything without prompts. Both are safe — the engine tracks what was selected. |
| Idempotent | Every mcs sync run converges to the same desired state. Safe to run any number of times — re-copies files, re-composes settings, re-registers MCP servers. |
| Non-Destructive | User content in CLAUDE.local.md is preserved via <!-- mcs:begin/end --> section markers. Content outside markers is never touched. |
| Convergent | Deselected packs are fully cleaned up — MCP servers removed, project files deleted, template sections stripped, settings keys cleaned. No orphaned artifacts. |
| Trust Verification | Pack scripts are SHA-256 hashed at mcs pack add time and verified at load time. Modified scripts are detected and the user is prompted to re-trust before proceeding. Local packs skip verification since scripts change during development. |
| Lockfile (opt-in) | mcs.lock.yaml pins pack commits for reproducible environments. Generation is off by default; enable with mcs config set generate-lockfile true or write once with mcs sync --update. Explicit generate-lockfile: false is silent; only the never-configured nil state surfaces drift warnings against a stale pre-existing lockfile. Use --lock to check out pinned versions from an existing lockfile. |
The codebase uses Swift 6's strict concurrency. All core types conform to Sendable. TechPack is a Sendable protocol. No mutable global state exists outside the installer's in-progress mutation context.
Next: Having issues? See Troubleshooting.
Home | CLI Reference | Creating Tech Packs | Schema | Architecture | Troubleshooting