devin-switch 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ """devin-switch — switch between Devin configuration profiles.
2
+
3
+ Manages the config files in the Devin config/data dirs (``User/settings.json``,
4
+ ``config.json`` hooks, ``mcp_config.json`` MCP entries) as declarative
5
+ profiles, with verified snapshots before any write and a rollback journal.
6
+
7
+ ``credentials.toml`` is never touched — not even read.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ __version__ = "0.1.0"
devin_switch/cli.py ADDED
@@ -0,0 +1,320 @@
1
+ """Thin CLI wrapper — all logic lives in the library modules.
2
+
3
+ - ``devin-switch list`` — discovered profiles (read-only)
4
+ - ``devin-switch show <profile>`` — what the profile manages (read-only)
5
+ - ``devin-switch diff <a> <b>`` — masked per-file diff between two profiles
6
+ - ``devin-switch use <profile>`` — DRY-RUN by default; ``--apply`` writes
7
+ - ``devin-switch rollback`` — DRY-RUN by default; ``--apply`` restores the
8
+ last journal backup
9
+ - ``devin-switch doctor`` — config sanity + closest-profile ranking
10
+
11
+ Exit codes: 0 on success / clean dry-run; 1 on any error (unknown profile,
12
+ nothing to roll back, snapshot verification failure) or — for ``doctor``
13
+ only — on any FAIL check. ``credentials.toml`` is never touched, ever.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import sys
20
+ from pathlib import Path
21
+ from typing import Sequence
22
+
23
+ from devin_switch import engine, journal
24
+ from devin_switch import paths as paths_mod
25
+ from devin_switch.health import check_config_files, rank_profiles
26
+ from devin_switch.paths import Roots, target_for
27
+ from devin_switch.plan import (
28
+ build_plan,
29
+ profile_diff,
30
+ render_plan,
31
+ summarize_json_top_keys,
32
+ )
33
+ from devin_switch.profiles import ProfileError, discover, get
34
+ from devin_switch.redact import is_credential_file, is_never_touch
35
+
36
+
37
+ def _add_roots(p: argparse.ArgumentParser) -> None:
38
+ p.add_argument(
39
+ "--data-dir",
40
+ type=Path,
41
+ default=None,
42
+ help="Devin data dir (default: platform location — "
43
+ "%%APPDATA%%/devin on Windows, ~/.config/devin elsewhere)",
44
+ )
45
+ p.add_argument(
46
+ "--config-dir",
47
+ type=Path,
48
+ default=None,
49
+ help="Devin UI config dir holding User/ (default: platform "
50
+ "location; falls back to --data-dir when only that is given)",
51
+ )
52
+ p.add_argument(
53
+ "--profiles-dir",
54
+ type=Path,
55
+ default=None,
56
+ help="directory of profile overlays (default: "
57
+ "$DEVIN_SWITCH_PROFILES_DIR, ./profiles, or bundled examples)",
58
+ )
59
+
60
+
61
+ def _add_apply(p: argparse.ArgumentParser) -> None:
62
+ p.add_argument(
63
+ "--apply",
64
+ action="store_true",
65
+ help="WRITE MODE: perform the switch after a verified snapshot. "
66
+ "Without it the command is a dry-run and writes nothing.",
67
+ )
68
+
69
+
70
+ def build_parser() -> argparse.ArgumentParser:
71
+ parser = argparse.ArgumentParser(
72
+ prog="devin-switch",
73
+ description="Switch between Devin configuration profiles (hooks, "
74
+ "MCP, models) with snapshot and verification. Read-only unless "
75
+ "--apply is passed; credentials.toml is never touched.",
76
+ )
77
+ sub = parser.add_subparsers(dest="command", required=True)
78
+
79
+ p_list = sub.add_parser("list", help="list discovered profiles")
80
+ _add_roots(p_list)
81
+
82
+ p_show = sub.add_parser(
83
+ "show", help="show what a profile manages (values masked)"
84
+ )
85
+ _add_roots(p_show)
86
+ p_show.add_argument("profile")
87
+
88
+ p_diff = sub.add_parser(
89
+ "diff", help="masked diff between two profiles (read-only)"
90
+ )
91
+ _add_roots(p_diff)
92
+ p_diff.add_argument("a")
93
+ p_diff.add_argument("b")
94
+
95
+ p_use = sub.add_parser(
96
+ "use", help="preview a switch (dry-run) or apply it with --apply"
97
+ )
98
+ _add_roots(p_use)
99
+ _add_apply(p_use)
100
+ p_use.add_argument("profile")
101
+
102
+ p_rb = sub.add_parser(
103
+ "rollback",
104
+ help="preview restoring the last backup (dry-run) or restore it "
105
+ "with --apply",
106
+ )
107
+ _add_roots(p_rb)
108
+ _add_apply(p_rb)
109
+
110
+ p_doc = sub.add_parser(
111
+ "doctor",
112
+ help="config sanity checks + closest-profile ranking (read-only)",
113
+ )
114
+ _add_roots(p_doc)
115
+
116
+ return parser
117
+
118
+
119
+ def _roots(args: argparse.Namespace) -> Roots:
120
+ data_dir = (args.data_dir or paths_mod.default_data_dir()).expanduser()
121
+ if args.config_dir is not None:
122
+ config_dir = args.config_dir.expanduser()
123
+ elif args.data_dir is not None:
124
+ # single-root mode: one dir holds both data files and User/
125
+ config_dir = data_dir
126
+ else:
127
+ config_dir = paths_mod.default_config_dir()
128
+ return Roots(
129
+ data_dir=data_dir,
130
+ config_dir=config_dir,
131
+ profiles_dir=paths_mod.resolve_profiles_dir(args.profiles_dir),
132
+ )
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # subcommands
137
+ # ---------------------------------------------------------------------------
138
+
139
+
140
+ def cmd_list(roots: Roots) -> int:
141
+ profiles = discover(roots.profiles_dir)
142
+ if not profiles:
143
+ print(f"no profiles found in {roots.profiles_dir}")
144
+ print("create profiles/<name>/ with config overlays — see README")
145
+ return 0
146
+ print(f"profiles in {roots.profiles_dir}:")
147
+ for profile in profiles.values():
148
+ n = len(profile.files)
149
+ desc = f" — {profile.description}" if profile.description else ""
150
+ print(f" {profile.name:<16} {n:>2} file(s){desc}")
151
+ return 0
152
+
153
+
154
+ def cmd_show(roots: Roots, name: str) -> int:
155
+ profile = get(roots.profiles_dir, name)
156
+ print(f"profile: {profile.name}")
157
+ print(f"root: {profile.root}")
158
+ if profile.description:
159
+ print(f"desc: {profile.description}")
160
+ for note in profile.notes:
161
+ print(f"note: {note}")
162
+ print("files:")
163
+ for rel in sorted(profile.files):
164
+ src = profile.files[rel]
165
+ size = src.stat().st_size
166
+ if is_never_touch(rel):
167
+ target_desc = "(never managed — skipped)"
168
+ else:
169
+ try:
170
+ target_desc = str(target_for(rel, roots))
171
+ except ValueError as exc:
172
+ target_desc = f"(unsafe — {exc})"
173
+ print(f" {rel} [{size} B]")
174
+ print(f" -> {target_desc}")
175
+ if is_credential_file(rel) or is_never_touch(rel):
176
+ print(" keys: <withheld — credential-carrying file>")
177
+ continue
178
+ keys = summarize_json_top_keys(src.read_bytes())
179
+ if keys:
180
+ print(f" keys: {', '.join(keys)}")
181
+ return 0
182
+
183
+
184
+ def cmd_diff(roots: Roots, a: str, b: str) -> int:
185
+ profile_a = get(roots.profiles_dir, a)
186
+ profile_b = get(roots.profiles_dir, b)
187
+ print(f"diff {a} -> {b} (values masked):")
188
+ print(profile_diff(profile_a, profile_b))
189
+ return 0
190
+
191
+
192
+ def _plan_counts(plans) -> dict[str, int]:
193
+ counts: dict[str, int] = {}
194
+ for fp in plans:
195
+ counts[fp.action] = counts.get(fp.action, 0) + 1
196
+ return counts
197
+
198
+
199
+ def cmd_use(roots: Roots, name: str, apply: bool) -> int:
200
+ profile = get(roots.profiles_dir, name)
201
+ plans = build_plan(profile, roots)
202
+ counts = _plan_counts(plans)
203
+ summary = ", ".join(
204
+ f"{n} {action}" for action, n in sorted(counts.items())
205
+ )
206
+ if not apply:
207
+ print(f"DRY-RUN use {name} — nothing will be written ({summary})")
208
+ print(render_plan(plans))
209
+ print()
210
+ print("re-run with --apply to snapshot, verify and write")
211
+ return 0
212
+ try:
213
+ result = engine.apply_plan(name, plans, roots)
214
+ except engine.SnapshotError as exc:
215
+ print(f"ABORTED — snapshot verification failed; no files written",
216
+ file=sys.stderr)
217
+ for problem in exc.problems:
218
+ print(f" {problem}", file=sys.stderr)
219
+ print(f" (backup kept for inspection: {exc.backup_dir})",
220
+ file=sys.stderr)
221
+ return 1
222
+ if result.files_changed:
223
+ print(f"applied profile '{name}' — {len(result.files_changed)} "
224
+ "file(s) written:")
225
+ for rel in result.files_changed:
226
+ print(f" wrote {rel}")
227
+ print(f"backup: {result.backup_dir}")
228
+ else:
229
+ print(f"profile '{name}' already in effect — nothing to write")
230
+ if result.skipped:
231
+ print(f"skipped (never managed): {', '.join(result.skipped)}")
232
+ print(f"journal: {roots.journal_path}")
233
+ print("undo: devin-switch rollback --apply")
234
+ return 0
235
+
236
+
237
+ def cmd_rollback(roots: Roots, apply: bool) -> int:
238
+ entry = journal.last_use_entry(roots.journal_path)
239
+ if entry is None:
240
+ print("nothing to roll back — no 'use' entry with an existing "
241
+ "backup in the journal", file=sys.stderr)
242
+ return 1
243
+ try:
244
+ steps = engine.plan_rollback(entry, roots)
245
+ except (FileNotFoundError, engine.SnapshotError, ValueError) as exc:
246
+ print(f"cannot roll back: {exc}", file=sys.stderr)
247
+ return 1
248
+ if not apply:
249
+ print(f"DRY-RUN rollback — would undo 'use {entry.get('profile')}' "
250
+ f"from {entry.get('ts')} (nothing written)")
251
+ for step in steps:
252
+ verb = "restore" if step.action == "restore" else "delete "
253
+ print(f" {verb} {step.target}")
254
+ print()
255
+ print("re-run with --apply to restore the backup")
256
+ return 0
257
+ result = engine.apply_rollback(entry, steps, roots)
258
+ print(f"rolled back to pre-'use {result.profile}' state:")
259
+ for step in result.steps:
260
+ verb = "restored" if step.action == "restore" else "deleted "
261
+ print(f" {verb} {step.target}")
262
+ print(f"journal: {roots.journal_path}")
263
+ return 0
264
+
265
+
266
+ def cmd_doctor(roots: Roots) -> int:
267
+ results = check_config_files(roots)
268
+ worst = 0
269
+ for r in results:
270
+ print(f"{r.status:<4} {r.label} — {r.detail}")
271
+ if r.status == "FAIL":
272
+ worst = 1
273
+ print()
274
+ profiles = discover(roots.profiles_dir)
275
+ if profiles:
276
+ ranked = rank_profiles(profiles, roots)
277
+ best, dist = ranked[0]
278
+ noun = "file" if dist == 1 else "files"
279
+ state = "exactly matches" if dist == 0 else f"{dist} {noun} away"
280
+ print(f"closest profile: {best.name} ({state})")
281
+ for profile, d in ranked[1:]:
282
+ noun = "file" if d == 1 else "files"
283
+ print(f" {profile.name}: {d} {noun} away")
284
+ else:
285
+ print(f"no profiles in {roots.profiles_dir} — nothing to rank")
286
+ return worst
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # entry point
291
+ # ---------------------------------------------------------------------------
292
+
293
+
294
+ def main(argv: Sequence[str] | None = None) -> int:
295
+ for stream in (sys.stdout, sys.stderr):
296
+ if hasattr(stream, "reconfigure"):
297
+ stream.reconfigure(encoding="utf-8", errors="replace")
298
+ args = build_parser().parse_args(argv)
299
+ roots = _roots(args)
300
+ try:
301
+ if args.command == "list":
302
+ return cmd_list(roots)
303
+ if args.command == "show":
304
+ return cmd_show(roots, args.profile)
305
+ if args.command == "diff":
306
+ return cmd_diff(roots, args.a, args.b)
307
+ if args.command == "use":
308
+ return cmd_use(roots, args.profile, args.apply)
309
+ if args.command == "rollback":
310
+ return cmd_rollback(roots, args.apply)
311
+ if args.command == "doctor":
312
+ return cmd_doctor(roots)
313
+ except ProfileError as exc:
314
+ print(f"error: {exc}", file=sys.stderr)
315
+ return 1
316
+ return 2
317
+
318
+
319
+ if __name__ == "__main__":
320
+ raise SystemExit(main())
devin_switch/engine.py ADDED
@@ -0,0 +1,256 @@
1
+ """The write path — snapshot, verify, apply, rollback.
2
+
3
+ Order of operations for ``use <profile> --apply``:
4
+
5
+ 1. snapshot every managed file the profile will change into
6
+ ``.devin-ecosystem/switch-backups/<ts>/`` (files that do not exist yet
7
+ are recorded as ``existed: false`` in the manifest — nothing copied);
8
+ 2. verify sha256 of every backup copy against both the recorded hash and
9
+ the live source — refuse to write anything on mismatch;
10
+ 3. write the overlay atomically (sibling tmp file + ``os.replace``);
11
+ 4. append the journal entry.
12
+
13
+ ``credentials.toml`` is excluded upstream (``plan`` marks it ``skip``) and
14
+ never reaches this module's file loops.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import json
21
+ import os
22
+ import shutil
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from devin_switch import journal
28
+ from devin_switch.paths import Roots, target_for
29
+ from devin_switch.plan import FilePlan
30
+
31
+ MANIFEST_NAME = "manifest.json"
32
+
33
+
34
+ class SnapshotError(Exception):
35
+ """Snapshot verification failed — nothing was applied."""
36
+
37
+ def __init__(self, problems: list[str], backup_dir: Path):
38
+ self.problems = problems
39
+ self.backup_dir = backup_dir
40
+ super().__init__("; ".join(problems))
41
+
42
+
43
+ def sha256_file(path: Path) -> str:
44
+ digest = hashlib.sha256()
45
+ with path.open("rb") as fh:
46
+ for chunk in iter(lambda: fh.read(1 << 20), b""):
47
+ digest.update(chunk)
48
+ return digest.hexdigest()
49
+
50
+
51
+ def atomic_write(target: Path, data: bytes) -> None:
52
+ """Write via a sibling tmp file + ``os.replace`` (same-dir rename is
53
+ atomic on POSIX and Windows)."""
54
+ target.parent.mkdir(parents=True, exist_ok=True)
55
+ tmp = target.with_name(target.name + ".devin-switch.tmp")
56
+ tmp.write_bytes(data)
57
+ os.replace(tmp, target)
58
+
59
+
60
+ def _unique_backup_dir(roots: Roots, stamp: str) -> Path:
61
+ base = roots.backups_dir / stamp
62
+ candidate, i = base, 1
63
+ while candidate.exists():
64
+ i += 1
65
+ candidate = Path(f"{base}-{i}")
66
+ return candidate
67
+
68
+
69
+ @dataclass
70
+ class ApplyResult:
71
+ profile: str
72
+ files_changed: list[str]
73
+ backup_dir: Path | None
74
+ journal_entry: dict[str, Any] | None
75
+ skipped: list[str]
76
+
77
+
78
+ def snapshot(
79
+ plans: list[FilePlan], backup_dir: Path, roots: Roots
80
+ ) -> dict[str, Any]:
81
+ """Copy the pre-switch bytes of every file about to change.
82
+
83
+ The manifest records, per managed file: the profile-relative path, the
84
+ absolute target, whether it existed and its sha256 (when it did).
85
+ """
86
+ backup_dir.mkdir(parents=True, exist_ok=False)
87
+ files: list[dict[str, Any]] = []
88
+ for fp in plans:
89
+ if not fp.needs_write:
90
+ continue
91
+ record: dict[str, Any] = {
92
+ "rel": fp.rel,
93
+ "target": str(fp.target),
94
+ "existed": fp.old_bytes is not None,
95
+ "sha256": None,
96
+ }
97
+ if fp.old_bytes is not None:
98
+ dest = backup_dir / fp.rel
99
+ dest.parent.mkdir(parents=True, exist_ok=True)
100
+ shutil.copy2(fp.target, dest)
101
+ record["sha256"] = sha256_file(dest)
102
+ files.append(record)
103
+ manifest = {
104
+ "version": 1,
105
+ "files": files,
106
+ }
107
+ (backup_dir / MANIFEST_NAME).write_text(
108
+ json.dumps(manifest, indent=2), encoding="utf-8"
109
+ )
110
+ return manifest
111
+
112
+
113
+ def verify_snapshot(manifest: dict[str, Any], backup_dir: Path) -> list[str]:
114
+ """Every backup copy must hash to its recorded sha256 AND still match
115
+ the live source file (which could have changed mid-switch)."""
116
+ problems: list[str] = []
117
+ for record in manifest.get("files", []):
118
+ if not record.get("existed"):
119
+ continue
120
+ rel, recorded = record["rel"], record["sha256"]
121
+ copy = backup_dir / rel
122
+ if not copy.is_file():
123
+ problems.append(f"{rel}: backup copy missing")
124
+ continue
125
+ if sha256_file(copy) != recorded:
126
+ problems.append(f"{rel}: backup copy sha256 mismatch")
127
+ continue
128
+ source = Path(record["target"])
129
+ if not source.is_file() or sha256_file(source) != recorded:
130
+ problems.append(
131
+ f"{rel}: source changed between snapshot and verify"
132
+ )
133
+ return problems
134
+
135
+
136
+ def apply_plan(
137
+ profile_name: str, plans: list[FilePlan], roots: Roots
138
+ ) -> ApplyResult:
139
+ """Snapshot → verify → write → journal. Raises ``SnapshotError``
140
+ before any file is written when verification fails."""
141
+ changed = [fp for fp in plans if fp.needs_write]
142
+ skipped = [fp.rel for fp in plans if fp.action == "skip"]
143
+ if not changed:
144
+ entry = journal.append(
145
+ roots.journal_path, "use", profile_name, [], None
146
+ )
147
+ return ApplyResult(profile_name, [], None, entry, skipped)
148
+
149
+ backup_dir = _unique_backup_dir(roots, journal.backup_stamp())
150
+ manifest = snapshot(changed, backup_dir, roots)
151
+ problems = verify_snapshot(manifest, backup_dir)
152
+ if problems:
153
+ # Leave the backup dir in place — it is the evidence of what the
154
+ # files looked like when the switch was attempted.
155
+ raise SnapshotError(problems, backup_dir)
156
+
157
+ for fp in changed:
158
+ atomic_write(fp.target, fp.new_bytes)
159
+
160
+ files_changed = [fp.rel for fp in changed]
161
+ entry = journal.append(
162
+ roots.journal_path, "use", profile_name, files_changed, backup_dir
163
+ )
164
+ return ApplyResult(profile_name, files_changed, backup_dir, entry, skipped)
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # rollback
169
+ # ---------------------------------------------------------------------------
170
+
171
+
172
+ @dataclass
173
+ class RollbackStep:
174
+ rel: str
175
+ target: Path
176
+ action: str # "restore" | "delete"
177
+ backup_copy: Path | None
178
+
179
+
180
+ @dataclass
181
+ class RollbackResult:
182
+ profile: str
183
+ steps: list[RollbackStep]
184
+ journal_entry: dict[str, Any]
185
+
186
+
187
+ def _load_manifest(backup_dir: Path) -> dict[str, Any]:
188
+ manifest_path = backup_dir / MANIFEST_NAME
189
+ if not manifest_path.is_file():
190
+ raise FileNotFoundError(
191
+ f"backup manifest missing: {manifest_path}"
192
+ )
193
+ return json.loads(manifest_path.read_text(encoding="utf-8"))
194
+
195
+
196
+ def plan_rollback(entry: dict[str, Any], roots: Roots) -> list[RollbackStep]:
197
+ """Steps to undo a ``use`` entry: restore files that existed, delete
198
+ files the switch created."""
199
+ backup_dir = Path(entry["backup_dir"])
200
+ manifest = _load_manifest(backup_dir)
201
+ steps: list[RollbackStep] = []
202
+ for record in manifest.get("files", []):
203
+ rel = record["rel"]
204
+ target = Path(record["target"])
205
+ if record["existed"]:
206
+ copy = backup_dir / rel
207
+ expected = record.get("sha256")
208
+ if not copy.is_file():
209
+ raise FileNotFoundError(
210
+ f"backup copy missing for {rel}: {copy}"
211
+ )
212
+ if expected and sha256_file(copy) != expected:
213
+ raise SnapshotError(
214
+ [f"{rel}: backup copy sha256 mismatch — refusing to "
215
+ "restore a corrupted snapshot"],
216
+ backup_dir,
217
+ )
218
+ steps.append(RollbackStep(rel, target, "restore", copy))
219
+ else:
220
+ steps.append(RollbackStep(rel, target, "delete", None))
221
+ return steps
222
+
223
+
224
+ def _prune_empty_dirs(path: Path, stop_at: Path) -> None:
225
+ path = path.parent
226
+ while path != stop_at and stop_at in path.parents:
227
+ try:
228
+ path.rmdir()
229
+ except OSError:
230
+ break
231
+ path = path.parent
232
+
233
+
234
+ def apply_rollback(
235
+ entry: dict[str, Any], steps: list[RollbackStep], roots: Roots
236
+ ) -> RollbackResult:
237
+ for step in steps:
238
+ if step.action == "restore":
239
+ assert step.backup_copy is not None
240
+ atomic_write(step.target, step.backup_copy.read_bytes())
241
+ else:
242
+ if step.target.is_file():
243
+ step.target.unlink()
244
+ _prune_empty_dirs(step.target, roots.data_dir)
245
+ _prune_empty_dirs(step.target, roots.config_dir)
246
+ files_changed = [s.rel for s in steps]
247
+ entry_out = journal.append(
248
+ roots.journal_path,
249
+ "rollback",
250
+ str(entry.get("profile", "")),
251
+ files_changed,
252
+ None,
253
+ )
254
+ return RollbackResult(
255
+ str(entry.get("profile", "")), steps, entry_out
256
+ )