# How Sync Works

How kst sync resolves sources, hashes content, diffs against kasetto.lock, and writes only what actually changed.

A look at what `kst sync` actually does: how skills are installed, how MCP configs are merged, what gets overwritten, and what's always left alone.

## Sync Flow

```
1. Load config (`$KASETTO_CONFIG` → `./kasetto.yaml` → `source:` in `$XDG_CONFIG_HOME/kasetto/config.yaml` → `$XDG_CONFIG_HOME/kasetto/kasetto.yaml`, or explicit `--config` path/URL)
2. Load lock file (kasetto.lock)
3. Sync skills
   a. For each skill source: decide whether a fetch is needed (the lock pins it and the destinations already match → no network)
   b. Materialize (download if remote) only when a fetch is needed
   c. Discover skills in source directory
   d. For each skill: hash → compare to lock → copy if changed
   e. Remove skills no longer in config
4. Sync commands
   a. For each command source: decide whether a fetch is needed (the lock pins it and the destination files already exist → no network)
   b. Materialize (download if remote) only when a fetch is needed
   c. Discover commands in source, transform into each agent's native format
   d. For each command: hash → compare to lock → write if changed
   e. Remove commands no longer in config
5. Sync MCPs
   a. For each MCP source: decide whether a fetch is needed (the lock pins it and its servers are already present in the agent settings → no network)
   b. Materialize (download if remote) only when a fetch is needed
   c. Discover MCP pack files in source
   d. For each pack: hash → compare to lock → merge into agent settings if changed
   e. Remove MCP entries no longer in config
6. Save lock file and report (unless --dry-run)
```

## Skills: Copy and Replace

Skills are plain directories with a `SKILL.md` file (see [writing skills](/docs/writing-skills)). On each sync, Kasetto:

- **Resolves** the source root (optionally via `sub-dir`).
- **Discovers** skills from:
  - top-level `SKILL.md` in the resolved root,
  - root-level subdirectories with `SKILL.md`, and
  - `skills/<name>/SKILL.md`.
- **Hashes** the source skill directory.
- **Compares** the hash to the lock file. If unchanged, the skill is skipped.
- **Copies** the entire directory to the destination, replacing any previous version (including
  symlinked files/directories inside the skill).
- **Removes** skill directories that are no longer listed in the config.

Skills are fully replaced on update. There are no partial merges.

## MCP Servers: Discovery and Additive Merge

Kasetto auto-discovers MCP pack files in the source:

1. `.mcp.json` at the source root
2. `mcp.json` at the source root
3. Any `.json` file inside the `mcps/` subdirectory

Each file must contain a `mcpServers` JSON object. Use a `mcps:` list to pick specific files instead of discovering all.

Server entries are merged into each agent's native settings file (e.g., `.claude.json`, `.cursor/mcp.json`). The merge follows two rules:

1. **New entries are added.** If the settings file doesn't have a server with that name, it's
   inserted.
2. **Existing entries are never overwritten.** If a server name already exists, whether added by
   Kasetto or by hand, Kasetto leaves it untouched.

In practice, this means:

- **Manual edits are safe.** If you add API keys, environment variables, or tweak server settings
  after a sync, those changes survive future syncs.
- **Re-sync is idempotent.** Running `sync` twice with the same config produces the same result.
- **First write wins.** If two sources define a server with the same name, only the first one is
  installed.

### Supported Config Formats

Kasetto auto-detects the right format for each agent:

| Format               | Used by                                          | Target file example                |
| -------------------- | ------------------------------------------------ | ---------------------------------- |
| McpServers JSON      | Claude Code, Cursor, Gemini CLI, Roo, and others | `.claude.json`, `.cursor/mcp.json` |
| VS Code servers JSON | GitHub Copilot                                   | `.vscode/mcp.json`                 |
| OpenCode JSON        | OpenCode                                         | `.config/opencode/opencode.json`   |
| Codex TOML           | Codex                                            | `.codex/config.toml`               |
| ZCode JSON           | ZCode (nested `mcp.servers`)                     | `.zcode/cli/config.json`           |

All formats follow the same additive-merge rules.

## Change Detection

Kasetto uses SHA-256 hashes so it only does work when something actually changed:

- **Skills:** The entire skill directory is hashed. If the hash matches the lock file, the skill is
  skipped.
- **MCP packs:** The pack file is hashed. If the hash matches **and** all server names are still
  present in the target settings, the pack is skipped.

This keeps re-sync fast: unchanged sources require no file I/O beyond reading the lock.

## The Lockfile Contract

`kasetto.lock` is a real lockfile, in the same spirit as `Cargo.lock`, `package-lock.json`, or `go.sum`. It pins exactly which skills, commands, instructions, and MCP assets were installed (by source, revision, destination, and content hash), so anyone who runs `kst sync` against the same config and lock gets an identical setup.

Where it lives depends on the scope:

| Scope   | Location                                                                               |
| ------- | -------------------------------------------------------------------------------------- |
| Global  | `$XDG_DATA_HOME/kasetto/kasetto.lock` (default: `~/.local/share/kasetto/kasetto.lock`) |
| Project | `./kasetto.lock` in the project root                                                   |

The lock is also how Kasetto knows what to remove when you drop a source from the config or run `kst clean`. You generally won't need to touch it by hand.

**Skills** are tracked by a composite key (`source::skill-name`) along with their destination path and hash.

**MCPs** are tracked as assets with the server names they installed (e.g., `destination: "git-tools,airflow"`). Only these tracked names are removed during cleanup.

<Callout type="info">

Kasetto never touches entries it didn't install. Manually added servers, skills from other tools, or entries from a different scope are always left alone.

</Callout>

### Commit It

For project scope, commit **both** `kasetto.yaml` and `kasetto.lock`. The config says *what* you want; the lock says *exactly which versions* everyone gets. The lock is deterministic and portable by design:

- Destination paths are stored **relative to the scope root**, so the file is identical on every machine and diffs cleanly in review.
- It contains **no timestamps or machine-specific data**, so nothing run-specific leaks in.

Machine-local runtime state (last-run report, per-skill install times used by `kst list` and `kst doctor`) lives separately under `$XDG_CACHE_HOME/kasetto/runtime/` and is **never committed**. It is safe to delete and regenerates on the next sync.

<Callout type="info">

A custom **absolute** `destination:` stays non-portable by design. An absolute path can't be relativized, so it's stored as-is. Use a relative `destination:` (or the defaults) if you want the lock to travel between machines.

</Callout>

### How `sync` Honors the Lock

| Command                            | Behavior                                                                                                                              |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `kst sync`                         | Installs exactly what the lock pins. If the on-disk destinations already match the locked hashes, **no network fetch happens**, and moving refs like a branch are not re-resolved. |
| `kst sync --update` (`-u`)         | Re-resolves branch / default-HEAD sources, downloads the latest, and rewrites the pins (hash + revision) in the lock.                |
| `kst sync --update <name>`         | Updates only the selected entries; everything else is honored from the lock. (Naming one skill re-resolves its whole source.)        |
| `kst sync --locked` / `--frozen`   | Strict mode for CI: installs from the lock and **never fetches**. Errors only if the config needs something the lock can't satisfy.  |

A plain `kst sync` always re-hashes the destination contents locally (cheap, no network) and repairs any tampered or missing copy from the lock, but it will not phone home to check whether an upstream branch moved. That is what `--update` is for. `--update` and `--locked` together are contradictory and rejected.

### Wildcards Are Frozen to the Locked Set

When a source uses `skills: "*"`, a plain `kst sync` installs exactly the set recorded in the lock, not whatever the source currently contains. New skills added upstream and skills removed upstream are only reflected after `kst sync --update`, which re-discovers the source and prunes accordingly. This keeps the lock authoritative: the wildcard resolves the same way for every teammate until someone deliberately updates.

<Callout type="warn">

**One-time `updated` flood after upgrading.** A release normalizes the skill content hash across operating systems (path separators are unified before hashing). The first `kst sync` after upgrading will therefore show every skill as `updated` once as it rewrites the lock with the new hash format. This is expected and self-heals: subsequent syncs report `unchanged` again.

</Callout>
## Removal Behavior

### Removing a Source from Config

Remove a source from `kasetto.yaml` and re-sync:

- **Skills:** The skill directory is deleted from disk.
- **MCPs:** The specific server entries that Kasetto installed are removed from the agent's settings
  file. The file itself is preserved.

### `kst clean`

Removes everything tracked in the lock file for the given scope:

- Deletes all tracked skill directories
- Removes all tracked MCP server entries from every agent's settings file
- Clears the lock file

`--dry-run` prints what would be removed (skill destinations and MCP pack lines) without changing disk.

Anything you added by hand outside the lock is never touched.

## Edge Cases

**Conflicting server names across sources.** If source A and source B both define a server named `"my-server"`, source A wins (it's processed first). If you later remove source A, `"my-server"` gets removed, even though source B also wanted it. Re-sync will then install source B's version.

**Renamed servers in upstream.** If an upstream MCP pack renames a server from `"old"` to `"new"`, Kasetto treats it as: remove `"old"` (no longer in pack) + add `"new"` (not yet in settings). The old entry is cleaned up and the new one is added.

**Corrupted settings file.** If an agent's settings file is malformed JSON/TOML, the merge for that file fails and is reported as an error. The lock file isn't updated for that pack, so the next sync retries the merge automatically.
