derot 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.
derot/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """derot: one console for every Claude Code and Codex session."""
2
+
3
+ __version__ = "0.1.0"
derot/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
derot/cli.py ADDED
@@ -0,0 +1,287 @@
1
+ """derot command-line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ import time
8
+ from typing import List, Optional
9
+
10
+ from . import __version__
11
+ from . import config as config_mod
12
+ from . import log as log_mod
13
+ from .config import ConfigError
14
+ from .log import log
15
+
16
+
17
+ def _load(create: bool = False):
18
+ path = config_mod.config_path()
19
+ created = config_mod.ensure_config(path) if create else False
20
+ return config_mod.load(path), created
21
+
22
+
23
+ def _hex_to_ansi(color: str, text: str, bold: bool) -> str:
24
+ r, g, b = (int(color[i:i + 2], 16) for i in (1, 3, 5))
25
+ return f"\033[{'1;' if bold else ''}38;2;{r};{g};{b}m{text}\033[0m"
26
+
27
+
28
+ def cmd_tui(args) -> int:
29
+ cfg, created = _load(create=True)
30
+ if created:
31
+ print(f"Created a starter config at {cfg.path}")
32
+ from . import tui
33
+ tui.run(cfg, created=created)
34
+ return 0
35
+
36
+
37
+ def cmd_init(args) -> int:
38
+ path = config_mod.config_path()
39
+ if config_mod.ensure_config(path):
40
+ print(f"Created {path}")
41
+ print("Next: edit it, then run `derot login ACCOUNT` and `derot setup`.")
42
+ else:
43
+ print(f"{path} already exists")
44
+ return 0
45
+
46
+
47
+ def cmd_setup(args) -> int:
48
+ from . import installer
49
+ cfg, _ = _load()
50
+ return installer.setup(cfg, remove=args.remove)
51
+
52
+
53
+ def cmd_ls(args) -> int:
54
+ from . import sessions
55
+ from .notify import brief
56
+ from .sessions import STYLE
57
+ cfg, _ = _load()
58
+ statuses, offline = sessions.poll_full(cfg)
59
+ if not statuses:
60
+ print(f"no sessions yet — add some to {cfg.path}")
61
+ return 0
62
+ color = sys.stdout.isatty()
63
+ now = time.time()
64
+ rows = []
65
+ for st in statuses:
66
+ icon, _, hexcolor, bold = STYLE[st.state]
67
+ name = st.name + (" ◂" if st.attached else "")
68
+ rows.append((icon, name, st.account.name, st.host.name,
69
+ sessions.status_text(st, now), brief(sessions.happening(st), 100),
70
+ hexcolor, bold))
71
+ headers = ("", "SESSION", "ACCOUNT", "MACHINE", "STATUS", "WHAT'S HAPPENING")
72
+ widths = [max(len(headers[i]), *(len(r[i]) for r in rows)) for i in range(5)]
73
+
74
+ def line(cells, hexcolor=None, bold=False) -> str:
75
+ parts = [cells[i].ljust(widths[i]) for i in range(5)] + [cells[5]]
76
+ if color and hexcolor:
77
+ parts[0] = _hex_to_ansi(hexcolor, parts[0], bold)
78
+ parts[4] = _hex_to_ansi(hexcolor, parts[4], bold)
79
+ return " ".join(parts).rstrip()
80
+
81
+ print(line(headers))
82
+ for r in rows:
83
+ print(line(r[:6], r[6], r[7]))
84
+ for host, err in offline.items():
85
+ print(f"✗ can't reach {host}: {err}", file=sys.stderr)
86
+ return 0
87
+
88
+
89
+ def cmd_start(args) -> int:
90
+ from . import installer, sessions
91
+ cfg, _ = _load()
92
+ cfg.session(args.name)
93
+ try:
94
+ s = cfg.session(args.name)
95
+ if not installer.ensure_hooks(cfg, cfg.host_of(s), cfg.account_of(s)):
96
+ print(f"derot: status hooks for {s.account} on {s.host} couldn't be set up "
97
+ "(see `derot log`)", file=sys.stderr)
98
+ sessions.start(cfg, args.name, cont=args.cont, resume=args.resume or "")
99
+ except sessions.SessionError as e:
100
+ print(f"derot: {e}", file=sys.stderr)
101
+ return 1
102
+ if args.detach:
103
+ print(f"started {args.name}")
104
+ return 0
105
+ return sessions.attach(cfg, args.name)
106
+
107
+
108
+ def cmd_history(args) -> int:
109
+ from . import sessions
110
+ cfg, _ = _load()
111
+ cfg.session(args.name)
112
+ try:
113
+ items = sessions.conversations(cfg, args.name, limit=args.n)
114
+ except sessions.SessionError as e:
115
+ print(f"derot: {e}", file=sys.stderr)
116
+ return 1
117
+ if not items:
118
+ print(f"no past conversations for {args.name}")
119
+ for c in items:
120
+ print(f"{c.id} {sessions.age(c.mtime):>4} ago {' '.join((c.title or c.first).split())[:70]}")
121
+ return 0
122
+
123
+
124
+ def cmd_attach(args) -> int:
125
+ from . import sessions
126
+ cfg, _ = _load()
127
+ cfg.session(args.name)
128
+ return sessions.attach(cfg, args.name)
129
+
130
+
131
+ def cmd_stop(args) -> int:
132
+ from . import sessions
133
+ cfg, _ = _load()
134
+ cfg.session(args.name)
135
+ try:
136
+ sessions.stop(cfg, args.name)
137
+ except sessions.SessionError as e:
138
+ print(f"derot: {e}", file=sys.stderr)
139
+ return 1
140
+ print(f"stopped {args.name}")
141
+ return 0
142
+
143
+
144
+ def cmd_watch(args) -> int:
145
+ from . import sessions
146
+ from .notify import Notifier
147
+ from .notify import acquire_watch_lock
148
+ cfg, _ = _load()
149
+ if not acquire_watch_lock():
150
+ print("derot watch is already running", file=sys.stderr)
151
+ return 1
152
+ notifier = Notifier(cfg.notify)
153
+ print(f"watching {len(cfg.sessions)} session(s) every {cfg.refresh:g}s — Ctrl-C to stop")
154
+ mtime = _mtime(cfg.path)
155
+ try:
156
+ while True:
157
+ m = _mtime(cfg.path)
158
+ if m != mtime:
159
+ mtime = m
160
+ try:
161
+ cfg = config_mod.load(cfg.path)
162
+ notifier.settings = cfg.notify
163
+ print("config reloaded")
164
+ except ConfigError as e:
165
+ log.error("watch: config not reloaded: %s", e)
166
+ print(f"config not reloaded: {e}", file=sys.stderr)
167
+ try:
168
+ statuses = sessions.poll(cfg)
169
+ for title, body in notifier.update(statuses):
170
+ print(f"{time.strftime('%H:%M:%S')} {title}: {body}", flush=True)
171
+ except Exception as e: # keep watching; logged too (a service's stderr is unread)
172
+ log.error("watch: poll failed: %s", e, exc_info=e)
173
+ print(f"poll failed: {e}", file=sys.stderr)
174
+ time.sleep(cfg.refresh)
175
+ except KeyboardInterrupt:
176
+ return 0
177
+
178
+
179
+ def _mtime(path) -> float:
180
+ try:
181
+ return path.stat().st_mtime if path else 0.0
182
+ except OSError:
183
+ return 0.0
184
+
185
+
186
+ def cmd_log(args) -> int:
187
+ path = log_mod.log_path()
188
+ if args.follow:
189
+ import subprocess
190
+ try:
191
+ return subprocess.call(["tail", "-n", str(args.lines), "-F", str(path)])
192
+ except KeyboardInterrupt:
193
+ return 0
194
+ text = log_mod.tail(args.lines)
195
+ print(text if text else "(empty)")
196
+ print(f"\n— {path} (derot log -f to follow; DEROT_DEBUG=1 logs every command)",
197
+ file=sys.stderr)
198
+ return 0
199
+
200
+
201
+ def cmd_login(args) -> int:
202
+ from . import sessions
203
+ cfg, _ = _load()
204
+ try:
205
+ return sessions.login(cfg, args.account, args.host)
206
+ except sessions.SessionError as e:
207
+ print(f"derot: {e}", file=sys.stderr)
208
+ return 1
209
+
210
+
211
+ def build_parser() -> argparse.ArgumentParser:
212
+ p = argparse.ArgumentParser(
213
+ prog="derot",
214
+ description="One console for every Claude Code and Codex session, across "
215
+ "projects, accounts and machines.",
216
+ )
217
+ p.add_argument("--version", action="version", version=f"derot {__version__}")
218
+ p.set_defaults(func=cmd_tui)
219
+ sub = p.add_subparsers(dest="command", metavar="COMMAND")
220
+
221
+ sub.add_parser("init", help="create the config file").set_defaults(func=cmd_init)
222
+
223
+ sp = sub.add_parser("setup", help="install / remove status hooks on every host+account")
224
+ sp.add_argument("--remove", action="store_true", help="remove the hooks instead")
225
+ sp.set_defaults(func=cmd_setup)
226
+
227
+ sub.add_parser("ls", help="print a status table").set_defaults(func=cmd_ls)
228
+
229
+ sp = sub.add_parser("start", help="start a session and attach to it")
230
+ sp.add_argument("name")
231
+ sp.add_argument("-c", "--continue", dest="cont", action="store_true",
232
+ help="resume the last conversation in that folder")
233
+ sp.add_argument("-r", "--resume", metavar="ID",
234
+ help="resume that conversation (ids from `derot history NAME`)")
235
+ sp.add_argument("-d", "--detach", action="store_true", help="don't attach")
236
+ sp.set_defaults(func=cmd_start)
237
+
238
+ sp = sub.add_parser("history", help="list a session's past conversations (newest first)")
239
+ sp.add_argument("name")
240
+ sp.add_argument("-n", type=int, default=20, help="how many (default 20)")
241
+ sp.set_defaults(func=cmd_history)
242
+
243
+ sp = sub.add_parser("attach", help="attach to a running session")
244
+ sp.add_argument("name")
245
+ sp.set_defaults(func=cmd_attach)
246
+
247
+ sp = sub.add_parser("stop", help="stop a session")
248
+ sp.add_argument("name")
249
+ sp.set_defaults(func=cmd_stop)
250
+
251
+ sub.add_parser("watch", help="poll and send notifications without the console"
252
+ ).set_defaults(func=cmd_watch)
253
+
254
+ sp = sub.add_parser("log", help="show the log (~/.derot/derot.log), for tracing errors")
255
+ sp.add_argument("-n", "--lines", type=int, default=60, help="how many lines (default 60)")
256
+ sp.add_argument("-f", "--follow", action="store_true", help="keep printing new lines")
257
+ sp.set_defaults(func=cmd_log)
258
+
259
+ sp = sub.add_parser("login", help="log in to an account (Claude Code: /login; Codex: codex login)")
260
+ sp.add_argument("account")
261
+ sp.add_argument("--host", default="local")
262
+ sp.set_defaults(func=cmd_login)
263
+ return p
264
+
265
+
266
+ def main(argv: Optional[List[str]] = None) -> int:
267
+ args = build_parser().parse_args(argv)
268
+ log_mod.setup()
269
+ if args.func is not cmd_log:
270
+ log.debug("derot %s", " ".join(sys.argv[1:] if argv is None else argv))
271
+ try:
272
+ return int(args.func(args) or 0)
273
+ except ConfigError as e:
274
+ log.error("config error: %s", e)
275
+ print(f"derot: {e}", file=sys.stderr)
276
+ return 2
277
+ except KeyboardInterrupt:
278
+ return 130
279
+ except Exception as e:
280
+ log.critical("unexpected error in `derot %s`", args.command or "", exc_info=e)
281
+ print(f"derot: unexpected error: {e}\n(full traceback in {log_mod.log_path()})",
282
+ file=sys.stderr)
283
+ return 1
284
+
285
+
286
+ if __name__ == "__main__":
287
+ sys.exit(main())
derot/config.py ADDED
@@ -0,0 +1,359 @@
1
+ """Load and validate ~/.config/derot/config.yaml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Dict, List, Optional
10
+
11
+ import yaml
12
+
13
+ from .log import log
14
+
15
+ NAME_RE = re.compile(r"^[A-Za-z0-9_-][A-Za-z0-9_.-]*$")
16
+ DEFAULT_CONFIG_DIR = "~/.claude"
17
+
18
+ # The coding agents derot can run. Each account belongs to one: its folder is that agent's
19
+ # config folder (CLAUDE_CONFIG_DIR / CODEX_HOME).
20
+ AGENTS = {
21
+ "claude": {"label": "Claude Code", "dir": "~/.claude", "env": "CLAUDE_CONFIG_DIR"},
22
+ "codex": {"label": "Codex", "dir": "~/.codex", "env": "CODEX_HOME"},
23
+ }
24
+
25
+
26
+ class ConfigError(Exception):
27
+ """A one-line, user-facing configuration problem."""
28
+
29
+
30
+ @dataclass
31
+ class Host:
32
+ name: str
33
+ ssh: Optional[str] = None
34
+ claude: str = "claude"
35
+ codex: str = "codex"
36
+
37
+ def command(self, agent: str) -> str:
38
+ """The command (or full path) for an agent on this machine."""
39
+ return self.codex if agent == "codex" else self.claude
40
+
41
+ @property
42
+ def is_local(self) -> bool:
43
+ return self.ssh is None
44
+
45
+ @property
46
+ def label(self) -> str:
47
+ return "this Mac" if self.is_local else str(self.ssh)
48
+
49
+
50
+ @dataclass
51
+ class Account:
52
+ name: str
53
+ config_dir: str = DEFAULT_CONFIG_DIR
54
+ env: Dict[str, str] = field(default_factory=dict)
55
+ agent: str = "claude"
56
+
57
+ @property
58
+ def default_dir(self) -> str:
59
+ return AGENTS[self.agent]["dir"]
60
+
61
+ @property
62
+ def dir_env(self) -> str:
63
+ """The variable that points the agent at this account's folder."""
64
+ return AGENTS[self.agent]["env"]
65
+
66
+ @property
67
+ def agent_label(self) -> str:
68
+ return AGENTS[self.agent]["label"]
69
+
70
+ @property
71
+ def is_default_dir(self) -> bool:
72
+ """True for the agent's default folder, however it is written (on this machine)."""
73
+ d = self.config_dir.rstrip("/")
74
+ if d == self.default_dir:
75
+ return True
76
+ return os.path.expanduser(d) == os.path.expanduser(self.default_dir)
77
+
78
+
79
+ @dataclass
80
+ class SessionDef:
81
+ name: str
82
+ host: str
83
+ dir: str
84
+ account: str
85
+ note: str = ""
86
+ args: str = ""
87
+
88
+
89
+ @dataclass
90
+ class Notify:
91
+ on_waiting: bool = True
92
+ on_done: bool = True
93
+ sound: str = "Glass"
94
+
95
+
96
+ @dataclass
97
+ class Config:
98
+ hosts: Dict[str, Host]
99
+ accounts: Dict[str, Account]
100
+ sessions: List[SessionDef]
101
+ notify: Notify = field(default_factory=Notify)
102
+ refresh: float = 3
103
+ detach_key: str = "C-q"
104
+ path: Optional[Path] = None
105
+
106
+ def session(self, name: str) -> SessionDef:
107
+ for s in self.sessions:
108
+ if s.name == name:
109
+ return s
110
+ raise ConfigError(f"no session named '{name}' in the config")
111
+
112
+ def host_of(self, s: SessionDef) -> Host:
113
+ return self.hosts[s.host]
114
+
115
+ def account_of(self, s: SessionDef) -> Account:
116
+ return self.accounts[s.account]
117
+
118
+ def used_hosts(self) -> List[Host]:
119
+ """Hosts that have at least one session, in config order."""
120
+ seen: List[str] = []
121
+ for s in self.sessions:
122
+ if s.host not in seen:
123
+ seen.append(s.host)
124
+ return [self.hosts[h] for h in seen]
125
+
126
+ def accounts_on(self, host: str) -> List[Account]:
127
+ seen: List[str] = []
128
+ for s in self.sessions:
129
+ if s.host == host and s.account not in seen:
130
+ seen.append(s.account)
131
+ return [self.accounts[a] for a in seen]
132
+
133
+
134
+ def config_path() -> Path:
135
+ env = os.environ.get("DEROT_CONFIG")
136
+ if env:
137
+ return Path(env).expanduser()
138
+ return Path("~/.config/derot/config.yaml").expanduser()
139
+
140
+
141
+ def example_text() -> str:
142
+ return (Path(__file__).parent / "example_config.yaml").read_text()
143
+
144
+
145
+ def ensure_config(path: Optional[Path] = None) -> bool:
146
+ """Write the example config if missing. Returns True if it was created."""
147
+ path = path or config_path()
148
+ if path.exists():
149
+ return False
150
+ path.parent.mkdir(parents=True, exist_ok=True)
151
+ path.write_text(example_text())
152
+ os.chmod(path, 0o600)
153
+ return True
154
+
155
+
156
+ def norm_path(path: str) -> str:
157
+ """`$HOME/x` / `${HOME}/x` -> `~/x`: everything downstream expands `~` (shell and
158
+ Python alike), but a quoted `$HOME` would reach Claude as a literal relative path."""
159
+ for prefix in ("${HOME}", "$HOME"):
160
+ if path == prefix or path.startswith(prefix + "/"):
161
+ return "~" + path[len(prefix):]
162
+ return path
163
+
164
+
165
+ def _mapping(value, where: str) -> dict:
166
+ if value is None:
167
+ return {}
168
+ if not isinstance(value, dict):
169
+ raise ConfigError(f"{where}: expected a mapping")
170
+ return value
171
+
172
+
173
+ def _str(value, where: str, default: str = "") -> str:
174
+ if value is None:
175
+ return default
176
+ if isinstance(value, (dict, list)):
177
+ raise ConfigError(f"{where}: expected a string")
178
+ return str(value)
179
+
180
+
181
+ def parse(data, path: Optional[Path] = None) -> Config:
182
+ data = _mapping(data, "config")
183
+
184
+ hosts: Dict[str, Host] = {"local": Host("local")}
185
+ for name, h in _mapping(data.get("hosts"), "hosts").items():
186
+ name = str(name)
187
+ h = _mapping(h, f"host '{name}'")
188
+ claude = _str(h.get("claude"), f"host '{name}' claude", "claude") or "claude"
189
+ codex = _str(h.get("codex"), f"host '{name}' codex", "codex") or "codex"
190
+ if name == "local":
191
+ hosts["local"] = Host("local", None, claude, codex)
192
+ continue
193
+ ssh = _str(h.get("ssh"), f"host '{name}' ssh")
194
+ if not ssh:
195
+ raise ConfigError(f"host '{name}': missing 'ssh' (e.g. ssh: user@my-vm)")
196
+ hosts[name] = Host(name, ssh, claude, codex)
197
+
198
+ accounts: Dict[str, Account] = {}
199
+ for name, a in _mapping(data.get("accounts"), "accounts").items():
200
+ name = str(name)
201
+ a = _mapping(a, f"account '{name}'")
202
+ agent = _str(a.get("agent"), f"account '{name}' agent", "claude") or "claude"
203
+ if agent not in AGENTS:
204
+ raise ConfigError(f"account '{name}': unknown agent '{agent}' (use "
205
+ + " or ".join(AGENTS) + ")")
206
+ default = AGENTS[agent]["dir"]
207
+ cdir = _str(a.get("config_dir"), f"account '{name}' config_dir", default)
208
+ env = {
209
+ str(k): _str(v, f"account '{name}' env {k}")
210
+ for k, v in _mapping(a.get("env"), f"account '{name}' env").items()
211
+ }
212
+ accounts[name] = Account(name, norm_path(cdir or default), env, agent)
213
+ if not accounts:
214
+ accounts["default"] = Account("default", DEFAULT_CONFIG_DIR)
215
+
216
+ raw_sessions = data.get("sessions") or []
217
+ if not isinstance(raw_sessions, list):
218
+ raise ConfigError("sessions: expected a list")
219
+ sessions: List[SessionDef] = []
220
+ names = set()
221
+ tmux_keys: Dict[str, str] = {}
222
+ for i, s in enumerate(raw_sessions):
223
+ s = _mapping(s, f"session #{i + 1}")
224
+ name = _str(s.get("name"), f"session #{i + 1} name")
225
+ label = f"session '{name}'" if name else f"session #{i + 1}"
226
+ if not name or not NAME_RE.match(name):
227
+ raise ConfigError(f"{label}: invalid name (use letters, digits, _ . -; not starting with .)")
228
+ if name in names:
229
+ raise ConfigError(f"{label}: duplicate session name")
230
+ clash = tmux_keys.get(name.replace(".", "_"))
231
+ if clash: # tmux names write `.` as `_`
232
+ raise ConfigError(f"{label}: too similar to session '{clash}' (. and _ count "
233
+ "as the same in tmux session names)")
234
+ names.add(name)
235
+ tmux_keys[name.replace(".", "_")] = name
236
+ host = _str(s.get("host"), f"{label} host", "local") or "local"
237
+ if host not in hosts:
238
+ raise ConfigError(f"{label}: unknown host '{host}'")
239
+ if len(accounts) == 1 and not s.get("account"):
240
+ account = next(iter(accounts))
241
+ else:
242
+ account = _str(s.get("account"), f"{label} account")
243
+ if account not in accounts:
244
+ raise ConfigError(f"{label}: unknown account '{account}'")
245
+ d = _str(s.get("dir"), f"{label} dir")
246
+ if not d:
247
+ raise ConfigError(f"{label}: missing 'dir'")
248
+ sessions.append(SessionDef(
249
+ name=name, host=host, dir=norm_path(d), account=account,
250
+ note=_str(s.get("note"), f"{label} note"),
251
+ args=_str(s.get("args"), f"{label} args"),
252
+ ))
253
+
254
+ n = _mapping(data.get("notify"), "notify")
255
+ notify = Notify(
256
+ on_waiting=bool(n.get("on_waiting", True)),
257
+ on_done=bool(n.get("on_done", True)),
258
+ sound=_str(n.get("sound", "Glass"), "notify sound"),
259
+ )
260
+
261
+ try:
262
+ refresh = float(data.get("refresh", 3))
263
+ except (TypeError, ValueError):
264
+ raise ConfigError("refresh: expected a number of seconds")
265
+ if not 0 < refresh <= 3600: # also rejects .inf and .nan
266
+ raise ConfigError("refresh: expected a number of seconds between 1 and 3600")
267
+ refresh = max(1.0, refresh)
268
+ detach_key = _str(data.get("detach_key"), "detach_key", "C-q") or "C-q"
269
+
270
+ return Config(hosts, accounts, sessions, notify, refresh, detach_key, path)
271
+
272
+
273
+ SAVED_HEADER = """\
274
+ # derot configuration (edited from the derot console; comments are not kept).
275
+ # Hosts: machines ("local" is this computer). Accounts: Claude Code or Codex folders.
276
+ # Sessions: a project folder + machine + account. See the README for every option.
277
+ """
278
+
279
+
280
+ def to_dict(cfg: Config) -> dict:
281
+ """The YAML-ready form of cfg, leaving out values that are at their defaults."""
282
+ hosts: dict = {}
283
+ for h in cfg.hosts.values():
284
+ entry: dict = {}
285
+ if not h.is_local:
286
+ entry["ssh"] = h.ssh
287
+ if h.claude != "claude":
288
+ entry["claude"] = h.claude
289
+ if h.codex != "codex":
290
+ entry["codex"] = h.codex
291
+ if entry or not h.is_local:
292
+ hosts[h.name] = entry
293
+ accounts: dict = {}
294
+ for a in cfg.accounts.values():
295
+ entry = {"config_dir": a.config_dir}
296
+ if a.agent != "claude":
297
+ entry = {"agent": a.agent, **entry}
298
+ if a.env:
299
+ entry["env"] = dict(a.env)
300
+ accounts[a.name] = entry
301
+ sessions = []
302
+ for s in cfg.sessions:
303
+ entry = {"name": s.name, "host": s.host, "dir": s.dir, "account": s.account}
304
+ if s.note:
305
+ entry["note"] = s.note
306
+ if s.args:
307
+ entry["args"] = s.args
308
+ sessions.append(entry)
309
+ data: dict = {}
310
+ if hosts:
311
+ data["hosts"] = hosts
312
+ data["accounts"] = accounts
313
+ data["sessions"] = sessions
314
+ data["notify"] = {"on_waiting": cfg.notify.on_waiting, "on_done": cfg.notify.on_done,
315
+ "sound": cfg.notify.sound}
316
+ data["refresh"] = int(cfg.refresh) if cfg.refresh == int(cfg.refresh) else cfg.refresh
317
+ data["detach_key"] = cfg.detach_key
318
+ return data
319
+
320
+
321
+ def save(cfg: Config, path: Optional[Path] = None) -> Config:
322
+ """Validate and write cfg. Keeps a one-time backup of the hand-written file."""
323
+ path = path or cfg.path or config_path()
324
+ data = to_dict(cfg)
325
+ checked = parse(data, path) # raises ConfigError if the change made it invalid
326
+ path.parent.mkdir(parents=True, exist_ok=True)
327
+ backup = path.with_name(path.name + ".bak")
328
+ if path.exists() and not backup.exists():
329
+ backup.write_text(path.read_text())
330
+ tmp = path.with_name(f".{path.name}.tmp")
331
+ text = SAVED_HEADER + "\n" + yaml.safe_dump(
332
+ data, sort_keys=False, allow_unicode=True, default_flow_style=False)
333
+ # 0600: the config can hold API keys (account env)
334
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
335
+ with os.fdopen(fd, "w") as f:
336
+ f.write(text)
337
+ os.chmod(tmp, 0o600)
338
+ os.replace(tmp, path)
339
+ if backup.exists():
340
+ os.chmod(backup, 0o600)
341
+ log.info("saved config %s (%d sessions, accounts: %s, hosts: %s)", path,
342
+ len(checked.sessions), ", ".join(checked.accounts), ", ".join(checked.hosts))
343
+ return checked
344
+
345
+
346
+ def load(path: Optional[Path] = None) -> Config:
347
+ path = path or config_path()
348
+ try:
349
+ text = path.read_text()
350
+ except FileNotFoundError:
351
+ raise ConfigError(f"config not found: {path} (run `derot init`)")
352
+ try:
353
+ data = yaml.safe_load(text)
354
+ except yaml.YAMLError as e:
355
+ mark = getattr(e, "problem_mark", None)
356
+ where = f" at line {mark.line + 1}" if mark else ""
357
+ problem = getattr(e, "problem", None) or "parse error"
358
+ raise ConfigError(f"invalid YAML in {path}{where}: {problem}")
359
+ return parse(data, path)
@@ -0,0 +1,43 @@
1
+ # derot configuration
2
+ # Docs: run `derot --help`, or see the README.
3
+
4
+ # Machines. "local" (this computer) always exists; add VMs reachable over SSH.
5
+ hosts:
6
+ local:
7
+ claude: claude # command or full path for Claude Code on this machine
8
+ codex: codex # command or full path for Codex on this machine
9
+ # vm:
10
+ # ssh: user@my-vm # anything `ssh` accepts, incl. ~/.ssh/config aliases
11
+ # claude: claude # optional override
12
+
13
+ # Accounts. Each account is an agent's config folder with its own login: Claude Code
14
+ # (agent: claude, the default; CLAUDE_CONFIG_DIR) or Codex (agent: codex; CODEX_HOME).
15
+ accounts:
16
+ personal:
17
+ config_dir: ~/.claude # Claude Code's default folder
18
+ # work:
19
+ # config_dir: ~/.claude-work
20
+ # env: # optional extra env vars for this account
21
+ # ANTHROPIC_API_KEY: sk-ant-...
22
+ # openai:
23
+ # agent: codex
24
+ # config_dir: ~/.codex # Codex's default folder
25
+ # env:
26
+ # OPENAI_API_KEY: sk-... # optional; otherwise log in with `l` (codex login)
27
+
28
+ # Sessions to manage. Each one runs in its own tmux session on its host.
29
+ sessions: []
30
+ # - name: shop-api # letters, digits, _ . - ; must be unique
31
+ # host: local # "local" or a key of hosts
32
+ # dir: ~/code/shop-api # project folder on that host (~ allowed)
33
+ # account: personal # key of accounts
34
+ # note: payments refactor # optional reminder shown in the UI
35
+ # args: "" # optional extra flags for `claude` / `codex`
36
+
37
+ notify:
38
+ on_waiting: true # alert when a session needs approval/input
39
+ on_done: true # alert when a session finishes
40
+ sound: Glass # macOS sound name, "" = silent
41
+
42
+ refresh: 3 # poll interval in seconds (min 1)
43
+ detach_key: C-q # key that returns to derot from a session