Skip to content
Open
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
31 changes: 18 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Developer control plane CLI — sync and manage developer tooling configurations
- **Self-updating binary** — Single executable with automatic updates from GitHub releases
- **Background config sync** — Optional hourly pull via launchd (macOS) or cron (Linux), with desktop notifications when updates land
- **One-shot install** — Optional `install.sh` env vars to install devctl, run `ai-kit setup`, and register background sync in one run
- **Backup before overwrite** — Snapshots targets before applying changes; old backups are pruned automatically (keeps last 3 per repo by default)
- **Backup before overwrite** — One run-level snapshot of protocol targets before applying; restores that snapshot if apply fails; old run backups are pruned (keeps last 3 runs per repo by default)

## High-Level Design (HLD)

Expand Down Expand Up @@ -47,9 +47,9 @@ Developer control plane CLI — sync and manage developer tooling configurations
| **CLI** | ai-kit | setup, sync, update, install/uninstall-background-sync, status, doctor |
| **CLI** | devspace / local | Domain stubs (planned) |
| **Core** | repo_manager | Clone/pull Git repos, URL → slug |
| **Core** | protocol_engine | Parse `protocol.yaml`, execute file_sync, etc. |
| **Core** | protocol_engine | Parse `protocol.yaml`, execute `file_sync` / `symlink_sync`, etc. |
| **Core** | versioning | Persist repo metadata in `state.json` |
| **Core** | backup | Snapshot target before overwrite |
| **Core** | backup | One run-level snapshot before apply; restore on failure; prune old runs |
| **Core** | updater | Self-update binary from GitHub releases |
| **Core** | config_sync | Check managed repos for new pushes, pull and re-apply, notify |
| **Core** | background_sync | Install/uninstall launchd (macOS) or cron (Linux) for hourly sync |
Expand All @@ -65,8 +65,9 @@ User: devctl ai-kit setup --repo https://github.com/org/configs
│ │ │
│ │ ├──► repo_manager.clone_or_pull()
│ │ ├──► protocol_engine.apply_protocols()
│ │ │ ├──► backup.backup_target()
│ │ │ └──► file_sync (merge copy)
│ │ │ ├──► backup.backup_apply_run() (once)
│ │ │ ├──► file_sync (merge copy)
│ │ │ └──► symlink_sync (shared skills/commands)
│ │ └──► versioning.register_repo()
│ │
│ └── check wi-devctl releases
Expand Down Expand Up @@ -161,7 +162,10 @@ protocols:
source: .cursor
target: ~/.cursor
obligations: [rules/security.json]
recommendations: [skills/debugging.md]
- name: cursor-skills
type: symlink_sync
source: .common/skills
target: ~/.cursor/skills
```

2. Run setup:
Expand All @@ -176,18 +180,19 @@ devctl ai-kit setup --repo https://github.com/your-org/ai-configs
devctl ai-kit install-background-sync
```

4. Repo is cloned to `~/.devctl/repos/`, configs are merged into `~/.cursor`.
4. Repo is cloned to `~/.devctl/repos/`. Vendor config is merge-copied (`file_sync`); shared skills/commands are symlinked (`symlink_sync`).

## Protocol Reference

| Field | Description |
|-------|-------------|
| `type` | `file_sync` (merge copy) or `symlink_sync` (directory symlink to source) |
| `source` | Path in repo (relative to root) |
| `target` | Local path (`~` expanded) |
| `obligations` | Required files under target (reported if missing) |
| `recommendations` | Optional files (reported if missing) |

`protocol.yaml` or `protocol.yml` must live at the **root** of the repo you sync from.
Declare `file_sync` entries before `symlink_sync` entries. `protocol.yaml` or `protocol.yml` must live at the **root** of the repo you sync from.

## Domains & Use Cases

Expand All @@ -201,11 +206,11 @@ A **domain** is a grouped set of CLI commands for a specific use case. Each doma

### How use cases work

All domains share the same flow: clone repo → parse `protocol.yaml` → apply protocols → track state. The protocol engine supports multiple types (currently `file_sync`; extensible to `env_sync`, `script_run`, etc.). Domain-specific logic sits on top of this core.
All domains share the same flow: clone repo → parse `protocol.yaml` → apply protocols → track state. The protocol engine supports `file_sync` and `symlink_sync` (extensible to `env_sync`, `script_run`, etc.). Domain-specific logic sits on top of this core.

| Use case | Domain | What it does | Example |
|----------|--------|---------------|---------|
| **AI configs** | ai-kit | Sync `.cursor` rules/skills to `~/.cursor` | Cursor rules, agent skills |
| **AI configs** | ai-kit | Sync vendor config via `file_sync`; shared skills/commands via `symlink_sync` | Cursor rules, shared agent skills |
| **Dev environments** | devspace | Define containers/VMs, start Docker/Podman | Dev containers, Colima setup |
| **Local tooling** | local | Sync env vars, run setup scripts | `.env` files, dev daemons |
| **Security** | (new domain) | Scan for secrets, enforce policies | Pre-commit hooks, policy configs |
Expand All @@ -232,7 +237,7 @@ Fork the repo and add domains for your org — no hardcoded URLs; each domain wo
| `DEVCTL_UPDATE_CHECK_INTERVAL_HOURS` | Hours between auto-update checks (default: 24) |
| `DEVCTL_CONFIG_SYNC_INTERVAL_MINUTES` | Minutes between ai-kit config sync rate-limit checks; overrides `DEVCTL_CONFIG_SYNC_INTERVAL_HOURS` when set (fractional ok) |
| `DEVCTL_CONFIG_SYNC_INTERVAL_HOURS` | Hours between config sync checks when minutes unset (default: 1; fractional ok) |
| `DEVCTL_BACKUP_RETENTION_COUNT` | Number of config backups to keep per repo slug (default: 3). Set to `0` to disable pruning |
| `DEVCTL_BACKUP_RETENTION_COUNT` | Number of **run-level** config backups to keep per repo slug (default: 3). Set to `0` to disable pruning |
| `DEVCTL_BACKUP_RETENTION_DISABLED` | Set to `1` to keep all backups (no automatic pruning) |
| `DEVCTL_AI_KIT_REPO` | *(install.sh only)* If set, run `ai-kit setup` after binary install |
| `DEVCTL_AI_KIT_BACKGROUND_SYNC` | *(install.sh only)* Set to `1` to run `install-background-sync` after setup |
Expand Down Expand Up @@ -323,15 +328,15 @@ Run `pytest` from the repo root (uses `pythonpath = ["src"]` in `pyproject.toml`

| Area | File | What it covers |
|------|------|----------------|
| **Protocols** | `tests/test_protocol_engine.py` | Load YAML/YML, validation errors, `file_sync`, merge behavior, **obligations / recommendations** present vs missing, unknown type, missing source, `apply_protocols` across multiple protocols |
| **Protocols** | `tests/test_protocol_engine.py` | Load YAML/YML, validation errors, `file_sync`, `symlink_sync`, merge behavior, **obligations / recommendations** present vs missing, unknown type, missing source, `apply_protocols` across multiple protocols |
| **Updater** | `tests/test_updater.py` | Manifest / version comparison, platform key shape, `perform_update` rate limit and force paths (no real download) |
| **Config sync** | `tests/test_config_sync.py` | `perform_config_sync`: no repos, rate limit, pull + notify, skip bad path / no remote updates |
| **CLI** | `tests/test_cli.py` | `list`, `ai-kit sync`, `update-cli`, `--version`, `devspace`/`local` help (`DEVCTL_SKIP_AUTO_UPDATE=1`, isolated `HOME`) |
| **State** | `tests/test_versioning.py` | `state.json`, `register_repo`, `get_repo_version` |
| **Background install** | `tests/test_background_sync.py` | launchd plist write (mocked `launchctl`), missing binary, uninstall when absent |
| **Notifications** | `tests/test_notify.py` | `DEVCTL_SKIP_NOTIFY`, macOS `osascript` path |
| **Repos** | `tests/test_repo_manager.py` | URL → slug, `fetch_and_has_updates` (mocked git) |
| **Backups** | `tests/test_backup.py` | Backup snapshots, retention pruning, dry-run |
| **Backups** | `tests/test_backup.py` | Run-level snapshots, restore, retention pruning, dry-run |
| **SSL** | `tests/test_ssl_certs.py` | certifi CA bundle configuration for HTTPS |

End-to-end **git clone**, **auto-update binary replace**, and **real launchd/cron** are not run in CI (use a manual machine or staging for those).
Expand Down
14 changes: 14 additions & 0 deletions examples/protocol.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
# Place this file in the ROOT of the repo you want to sync from.
# Then run: devctl ai-kit setup --repo <your-repo-url>
# Repo URL can be HTTPS (https://github.com/org/repo) or SSH (git@github.com:org/repo.git)
#
# Protocol types:
# file_sync — merge-copy vendor-specific config into the target directory
# symlink_sync — ensure target is a symlink to source (shared skills/commands)
# Declare all file_sync entries before symlink_sync entries.

version: v1

Expand All @@ -17,3 +22,12 @@ protocols:
recommendations:
- skills/debugging.md

- name: cursor-skills
type: symlink_sync
source: .common/skills
target: ~/.cursor/skills

- name: cursor-commands
type: symlink_sync
source: .common/commands
target: ~/.cursor/commands
12 changes: 10 additions & 2 deletions src/devctl/cli/ai_kit.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import click

from devctl.core.protocol_engine import apply_protocols, load_protocols
from devctl.core.protocol_engine import apply_protocols, check_symlink_integrity, load_protocols
from devctl.core.repo_manager import clone_or_pull, get_repo_path, url_to_slug
from devctl.core.versioning import list_repos, register_repo
from devctl.utils.logging import log_verbose
Expand Down Expand Up @@ -217,8 +217,11 @@ def status(repo_url: str | None) -> None:
target = expand_path(p.target)
missing_obl = [target / r for r in p.obligations if not (target / r).exists()]
missing_rec = [target / r for r in p.recommendations if not (target / r).exists()]
if missing_obl or missing_rec:
link_issue = check_symlink_integrity(p, path)
if missing_obl or missing_rec or link_issue:
click.echo(f" protocol {p.name}:")
if link_issue:
click.echo(f" symlink: {link_issue}")
if missing_obl:
click.echo(f" drift (obligations): {[str(x) for x in missing_obl]}")
if missing_rec:
Expand Down Expand Up @@ -261,6 +264,11 @@ def doctor(repo_url: str | None) -> None:
log_verbose(f"Validating {slug}")
_, protocols = load_protocols(path)
for p in protocols:
link_issue = check_symlink_integrity(p, path)
if link_issue:
click.echo(f"{slug}: {link_issue}")
click.echo(f" Fix: run 'devctl ai-kit update --repo {info.get('url', '?')}'")
issues += 1
target = expand_path(p.target)
for r in p.obligations:
full = target / r
Expand Down
Loading